@aiwg/cockpit 2026.7.0 → 2026.7.2

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.
@@ -31,6 +31,7 @@ const RUNTIME_DIR = join(homedir(), '.aiwg', 'cockpit', 'runtime');
31
31
  // legacy vanilla page so the Bridge works even before a web build.
32
32
  const WEB_DIST = fileURLToPath(new URL('../../web/dist', import.meta.url));
33
33
  const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.json': 'application/json', '.ico': 'image/x-icon', '.png': 'image/png', '.woff2': 'font/woff2', '.map': 'application/json' };
34
+ const CAPABILITY_TYPES = new Set(['skill', 'agent', 'command', 'rule', 'flow']);
34
35
 
35
36
  /** Serve a static file from the built web app, sandboxed to WEB_DIST. Returns true if served. */
36
37
  async function serveDistFile(res, relPath) {
@@ -294,6 +295,26 @@ async function probeExecutor(executorUrl) {
294
295
  return false;
295
296
  }
296
297
 
298
+ async function getExecutorCapabilities(executorUrl) {
299
+ const candidates = ['/healthz/deep', '/healthz', '/health'].map((path) => `${executorUrl}${path}`);
300
+ try {
301
+ const { target, body } = await fetchJsonFirst(candidates);
302
+ return {
303
+ status: 'ok',
304
+ source: new URL(target).pathname,
305
+ host_runtime_enabled: body.host_runtime_enabled === true || body.hostRuntimeEnabled === true,
306
+ raw_status: body.status ?? body.state ?? 'unknown',
307
+ };
308
+ } catch (err) {
309
+ return {
310
+ status: 'unreachable',
311
+ source: null,
312
+ host_runtime_enabled: false,
313
+ error: String(err?.message ?? err),
314
+ };
315
+ }
316
+ }
317
+
297
318
  function defaultExecutorCommand() {
298
319
  if (EXECUTOR_COMMAND) return EXECUTOR_COMMAND.split(/\s+/).filter(Boolean);
299
320
  const candidates = [
@@ -1074,6 +1095,7 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1074
1095
  return;
1075
1096
  }
1076
1097
  if (url.pathname === '/api/inventory') return json(res, 200, await getInventory(upstreamUrl));
1098
+ if (url.pathname === '/api/executor/capabilities') return json(res, 200, await getExecutorCapabilities(upstreamUrl));
1077
1099
  if (url.pathname === '/api/running') return json(res, 200, await getRunning(upstreamUrl));
1078
1100
  if (url.pathname === '/api/loadouts') return json(res, 200, await getLoadouts(upstreamUrl));
1079
1101
  let m;
@@ -1133,9 +1155,20 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1133
1155
  if (url.pathname === '/api/capabilities') {
1134
1156
  const q = (url.searchParams.get('q') || '').trim();
1135
1157
  if (!q) return json(res, 400, { error: 'q_required' });
1136
- const args = ['discover', q, '--json', '--limit', String(Number(url.searchParams.get('limit')) || 8)];
1158
+ const rawLimit = url.searchParams.get('limit') ?? '8';
1159
+ const limit = Number(rawLimit);
1160
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
1161
+ return json(res, 400, { error: 'invalid_limit', detail: 'limit must be an integer from 1 to 50' });
1162
+ }
1163
+ const args = ['discover', q, '--json', '--limit', String(limit)];
1137
1164
  const type = url.searchParams.get('type');
1138
- if (type && type !== 'all') args.push('--type', type);
1165
+ if (type && type !== 'all') {
1166
+ const types = type.split(',').map((t) => t.trim()).filter(Boolean);
1167
+ if (!types.length || types.some((t) => !CAPABILITY_TYPES.has(t))) {
1168
+ return json(res, 400, { error: 'invalid_type', detail: 'type must be all, skill, agent, command, rule, flow, or a comma list of those kinds' });
1169
+ }
1170
+ args.push('--type', types.join(','));
1171
+ }
1139
1172
  const data = JSON.parse(await runAiwg(args));
1140
1173
  data.results = (data.results || []).map((r) => ({ ...r, name: deriveName(r.path) }));
1141
1174
  return json(res, 200, data);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cockpit",
3
- "version": "2026.7.0",
3
+ "version": "2026.7.2",
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",
@@ -22,13 +22,22 @@ const LOADOUTS = [
22
22
  { id: 'agentic-dev', label: 'agentic-dev', description: 'Developer tools', runtimes: ['docker', 'container'] },
23
23
  ];
24
24
 
25
- function mockFetch() {
25
+ function mockFetch({
26
+ instances = [HOST_INSTANCE],
27
+ executorCaps = null,
28
+ createdInstanceId = 'docker-1',
29
+ }: {
30
+ instances?: unknown[];
31
+ executorCaps?: unknown;
32
+ createdInstanceId?: string;
33
+ } = {}) {
26
34
  return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
27
35
  const url = String(input);
28
36
  const ok = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
29
37
  if (url.includes('/api/loadouts')) return ok({ loadouts: LOADOUTS });
30
- if (url.includes('/api/inventory')) return ok({ instances: [HOST_INSTANCE] });
31
- if (url.includes('/api/instances') && init?.method === 'POST') return ok({ instance_id: 'docker-1' });
38
+ if (url.includes('/api/inventory')) return ok({ instances });
39
+ if (url.includes('/api/executor/capabilities') && executorCaps) return ok(executorCaps);
40
+ if (url.includes('/api/instances') && init?.method === 'POST') return ok({ instance_id: createdInstanceId });
32
41
  return new Response('{}', { status: 404 });
33
42
  }) as unknown as typeof fetch;
34
43
  }
@@ -37,7 +46,7 @@ beforeEach(() => { (window as unknown as { __COCKPIT_TOKEN__: string }).__COCKPI
37
46
  afterEach(() => { cleanup(); vi.restoreAllMocks(); });
38
47
 
39
48
  describe('LaunchInstanceModal', () => {
40
- it('uses an existing host target instead of provisioning unsupported host instances', async () => {
49
+ it('uses an existing host target when one is already registered', async () => {
41
50
  globalThis.fetch = mockFetch();
42
51
  const onLaunched = vi.fn();
43
52
  const onClose = vi.fn();
@@ -56,6 +65,30 @@ describe('LaunchInstanceModal', () => {
56
65
  expect(onClose).toHaveBeenCalled();
57
66
  });
58
67
 
68
+ it('provisions the first host instance when the executor host supervisor is enabled', async () => {
69
+ globalThis.fetch = mockFetch({
70
+ instances: [],
71
+ executorCaps: { status: 'ok', source: '/healthz/deep', host_runtime_enabled: true },
72
+ createdInstanceId: 'host-created-1',
73
+ });
74
+ const onLaunched = vi.fn();
75
+ const onClose = vi.fn();
76
+ render(<LaunchInstanceModal open onClose={onClose} onLaunched={onLaunched} />);
77
+
78
+ expect(await screen.findByRole('option', { name: /local host runtime - create new/i })).toBeTruthy();
79
+ fireEvent.click(screen.getByRole('button', { name: /start host session/i }));
80
+
81
+ await waitFor(() => expect(onLaunched).toHaveBeenCalledWith('host-created-1', true, undefined));
82
+ const postCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls
83
+ .find((call) => String(call[0]).includes('/api/instances') && call[1]?.method === 'POST');
84
+ expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({
85
+ runtime: 'host',
86
+ loadout: 'host-tools',
87
+ start: true,
88
+ });
89
+ expect(onClose).toHaveBeenCalled();
90
+ });
91
+
59
92
  it('keeps Docker on the real provisioning path with Docker loadouts and images', async () => {
60
93
  globalThis.fetch = mockFetch();
61
94
  const onLaunched = vi.fn();
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { api } from '../api';
3
3
  import { fmtId } from '../util';
4
- import type { Instance, Loadout } from '../types';
4
+ import type { ExecutorCapabilities, Instance, Loadout } from '../types';
5
5
 
6
6
  type Runtime = 'host' | 'docker' | 'qemu';
7
7
 
@@ -46,6 +46,7 @@ export function LaunchInstanceModal({
46
46
  const [loadouts, setLoadouts] = useState<Loadout[]>([]);
47
47
  const [instances, setInstances] = useState<Instance[]>([]);
48
48
  const [hostId, setHostId] = useState('');
49
+ const [executorCaps, setExecutorCaps] = useState<ExecutorCapabilities | null>(null);
49
50
  const [image, setImage] = useState('agentic/codex:latest');
50
51
  const [customImage, setCustomImage] = useState('');
51
52
  const [profile, setProfile] = useState('');
@@ -65,11 +66,13 @@ export function LaunchInstanceModal({
65
66
  Promise.all([
66
67
  api<{ loadouts: Loadout[] }>('/api/loadouts').catch(() => ({ loadouts: [] as Loadout[] })),
67
68
  api<{ instances: Instance[] }>('/api/inventory').catch(() => ({ instances: [] as Instance[] })),
69
+ api<ExecutorCapabilities>('/api/executor/capabilities').catch(() => null),
68
70
  ])
69
- .then(([lo, inv]) => {
71
+ .then(([lo, inv, caps]) => {
70
72
  if (cancelled) return;
71
73
  setLoadouts(lo.loadouts ?? []);
72
74
  setInstances(inv.instances ?? []);
75
+ setExecutorCaps(caps);
73
76
  const firstHost = (inv.instances ?? []).find(isUsableHost);
74
77
  setHostId((current) => current || firstHost?.id || '');
75
78
  });
@@ -95,12 +98,36 @@ export function LaunchInstanceModal({
95
98
  try {
96
99
  if (runtime === 'host') {
97
100
  const host = hostTargets(instances).find((i) => i.id === hostId) ?? hostTargets(instances)[0];
98
- if (!host) throw new Error('No registered host target is available. Start/register the host agent first, or choose Docker container.');
101
+ if (host) {
102
+ setResult(openSession
103
+ ? `Using host target ${host.launch_context?.name ?? fmtId(host.id)}; starting session...`
104
+ : `Using host target ${host.launch_context?.name ?? fmtId(host.id)}`);
105
+ await onLaunched(host.id, openSession);
106
+ if (openSession) onClose();
107
+ return;
108
+ }
109
+ if (!executorCaps?.host_runtime_enabled) {
110
+ throw new Error('Host runtime is not enabled on this executor. Enable the host supervisor or choose Docker container.');
111
+ }
112
+ const body = {
113
+ name: name.replace(/[^a-z0-9-]/g, '-').replace(/^-+/, 'a-').slice(0, 63),
114
+ runtime: 'host',
115
+ loadout: 'host-tools',
116
+ start: true,
117
+ };
118
+ const op = await api<{ id?: string; instance_id?: string; instanceId?: string; operation?: { id?: string }; result?: { instance_id?: string; instanceId?: string } }>('/api/instances', {
119
+ method: 'POST',
120
+ headers: { 'content-type': 'application/json' },
121
+ body: JSON.stringify(body),
122
+ });
123
+ const instanceId = op.instance_id ?? op.instanceId ?? op.result?.instance_id ?? op.result?.instanceId;
124
+ const operationId = op.id ?? op.operation?.id;
99
125
  setResult(openSession
100
- ? `Using host target ${host.launch_context?.name ?? fmtId(host.id)}; starting session...`
101
- : `Using host target ${host.launch_context?.name ?? fmtId(host.id)}`);
102
- await onLaunched(host.id, openSession);
126
+ ? `Host launch accepted: ${instanceId ?? operationId ?? 'operation pending'}; waiting for session...`
127
+ : `Host launch accepted: ${instanceId ?? operationId ?? 'operation pending'}`);
128
+ await onLaunched(instanceId, openSession, operationId);
103
129
  if (openSession) onClose();
130
+ else setName(genInstanceName());
104
131
  return;
105
132
  }
106
133
  const body: Record<string, unknown> = {
@@ -155,8 +182,15 @@ export function LaunchInstanceModal({
155
182
  <label htmlFor="li-host-target">Host target</label>
156
183
  <select id="li-host-target" value={hostId} onChange={(e) => setHostId(e.target.value)}>
157
184
  {hostTargets(instances).map((i) => <option key={i.id} value={i.id}>{i.launch_context?.name ?? fmtId(i.id)} - {i.loadout}</option>)}
158
- {!hostTargets(instances).length && <option value="">No registered host available</option>}
185
+ {!hostTargets(instances).length && executorCaps?.host_runtime_enabled && <option value="">Local host runtime - create new</option>}
186
+ {!hostTargets(instances).length && !executorCaps?.host_runtime_enabled && <option value="">Host runtime not enabled</option>}
159
187
  </select>
188
+ {!hostTargets(instances).length && executorCaps?.host_runtime_enabled && (
189
+ <>
190
+ <label htmlFor="li-name">Name</label>
191
+ <input id="li-name" value={name} onChange={(e) => setName(e.target.value)} />
192
+ </>
193
+ )}
160
194
  </>
161
195
  ) : (
162
196
  <>
@@ -218,7 +252,7 @@ export function LaunchInstanceModal({
218
252
  </div>
219
253
  <div className="modal-actions">
220
254
  <button onClick={onClose} disabled={busy}>Close</button>
221
- <button className="cta" onClick={launch} disabled={busy || (runtime === 'host' && !hostId) || (runtime !== 'host' && !name.trim()) || (runtime === 'docker' && image === '__custom__' && !customImage.trim())}>
255
+ <button className="cta" onClick={launch} disabled={busy || (runtime === 'host' && !hostId && !executorCaps?.host_runtime_enabled) || (runtime === 'host' && !hostId && !name.trim()) || (runtime !== 'host' && !name.trim()) || (runtime === 'docker' && image === '__custom__' && !customImage.trim())}>
222
256
  {busy ? 'Working...' : runtime === 'host' ? (openSession ? 'Start host session' : 'Use host') : openSession ? 'Create + start session' : 'Create instance'}
223
257
  </button>
224
258
  </div>
package/web/src/types.ts CHANGED
@@ -41,6 +41,7 @@ export interface ResponseNeeded { id: string; instance_id: string; prompt: strin
41
41
  export interface InstanceCost { instance_id: string; tenant: string; input_tokens: number; output_tokens: number; usd: number }
42
42
  export interface Cost { total: { input_tokens: number; output_tokens: number; usd: number }; per_instance: InstanceCost[] }
43
43
  export interface Loadout { id: string; label: string; description?: string; runtimes?: string[] }
44
+ export interface ExecutorCapabilities { status: string; source?: string | null; host_runtime_enabled: boolean; raw_status?: string; error?: string }
44
45
  export interface CapabilityResult { path: string; type: string; title?: string; capability?: string; score?: number; name: string; triggers?: string[] }
45
46
  export interface ContribAction { id: string; title: string; icon?: string; group?: string; source: string; inject: { command: string; target?: string; needs_args?: boolean; args_hint?: string } }
46
47
  export type Role = 'controller' | 'observer' | null;