@aiwg/cockpit 2026.8.2 → 2026.8.4

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.
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>AIWG Cockpit</title>
7
- <script type="module" crossorigin src="./assets/index-ZER1lrej.js"></script>
7
+ <script type="module" crossorigin src="./assets/index-DNlFgn6L.js"></script>
8
8
  <link rel="stylesheet" crossorigin href="./assets/index-CQkRFleq.css">
9
9
  </head>
10
10
  <body>
@@ -99,6 +99,7 @@ describe('App shell (rendered DOM)', () => {
99
99
  name: 'Release hardening',
100
100
  state: 'active',
101
101
  source: 'aiwg-mc',
102
+ updated_at: '2026-07-04T12:00:00.000Z',
102
103
  audit_count: 1,
103
104
  audit_tail: [{ event: 'mission_dispatched', ts: '2026-07-04T12:00:00.000Z', missionId: 'm-1' }],
104
105
  missions: [{ id: 'm-1', session_id: 'mc-1', source: 'aiwg-mc', title: 'Finish cockpit', status: 'running', loop: 1, max_iterations: 5, terminal: false }],
@@ -126,6 +127,23 @@ describe('App shell (rendered DOM)', () => {
126
127
  expect(screen.getByLabelText('Mission status summary').textContent).toContain('2 total');
127
128
  expect(screen.getByText('Finish cockpit')).toBeTruthy();
128
129
  expect(screen.getByText(/mission_dispatched/)).toBeTruthy();
130
+ fireEvent.change(screen.getByLabelText('Objective'), { target: { value: 'Run release verification' } });
131
+ fireEvent.change(screen.getByLabelText('Completion criteria'), { target: { value: 'all gates pass' } });
132
+ fireEvent.click(screen.getByRole('button', { name: 'Queue mission' }));
133
+ await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledWith(
134
+ '/api/missions',
135
+ expect.objectContaining({ method: 'POST', body: expect.stringContaining('Run release verification') }),
136
+ ));
137
+ fireEvent.click(screen.getByRole('button', { name: 'Pause session' }));
138
+ await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledWith(
139
+ '/api/missions/mc-1/pause',
140
+ expect.objectContaining({ method: 'POST' }),
141
+ ));
142
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel mission' }));
143
+ await waitFor(() => expect(globalThis.fetch).toHaveBeenCalledWith(
144
+ '/api/missions/mc-1/m-1/cancel',
145
+ expect.objectContaining({ method: 'POST' }),
146
+ ));
129
147
  });
130
148
 
131
149
  it('renders an AIWG parent mission with distinct Agentic Sandbox workload semantics and recovery posture', async () => {
package/web/src/App.tsx CHANGED
@@ -254,7 +254,7 @@ export function App() {
254
254
  <Sessions session={session} composer={composer} setComposer={setComposer} onRequestStart={requestStart} refreshTick={refreshTick} />
255
255
  </section>
256
256
  <Panel id="approvals" tab={tab}><Approvals refreshTick={refreshTick} responses={[...registryResponses, ...(session.responseNeeded.needed ? [sessionResponse(session)] : [])]} goSessions={() => setTab('sessions')} /></Panel>
257
- <Panel id="explore" tab={tab}><Explore /></Panel>
257
+ <Panel id="explore" tab={tab}><Explore refreshTick={refreshTick} /></Panel>
258
258
  <Panel id="library" tab={tab}>
259
259
  <Library session={session} setComposer={setComposer} goSessions={() => setTab('sessions')} />
260
260
  </Panel>
@@ -81,6 +81,7 @@ export function Actions({ refreshTick = 0, session, setComposer, goSessions }: {
81
81
  <button className="act" title={`injects: ${a.inject.command}`} onClick={() => inject(a)}>
82
82
  {a.icon ? a.icon + ' ' : ''}{a.title}
83
83
  </button>
84
+ {a.trust_tier === 'sandboxed-third-party' && <span className="badge">sandboxed</span>}
84
85
  <button className="copy-command" aria-label={`Copy CLI command for ${a.title}`} title={a.inject.command} onClick={() => copyCommand(a)}>
85
86
  Copy CLI
86
87
  </button>
@@ -3,15 +3,16 @@ import { api } from '../api';
3
3
  import { useDebounced } from '../useDebounce';
4
4
  import type { CapabilityResult } from '../types';
5
5
 
6
- const TYPES = ['all', 'skill', 'agent', 'command', 'rule', 'flow'];
6
+ const TYPES = ['all', 'skill', 'agent', 'command', 'rule', 'flow', 'behavior', 'hook', 'template', 'tool', 'addon', 'framework', 'extension', 'plugin', 'provider', 'document'];
7
7
 
8
8
  // Tenor-style capability search — modeled on fortemi-react's SearchBar + SearchResults
9
9
  // (debounced input, Ctrl/⌘-K focus, card grid with rank + snippet + trigger tags),
10
10
  // wired to the AIWG registry via the Bridge (read-only catalog data; never execution).
11
- export function CapabilitySearch({ onPick, autoFocus, compact }: {
11
+ export function CapabilitySearch({ onPick, autoFocus, compact, refreshTick = 0 }: {
12
12
  onPick: (c: CapabilityResult) => void;
13
13
  autoFocus?: boolean;
14
14
  compact?: boolean;
15
+ refreshTick?: number;
15
16
  }) {
16
17
  const [q, setQ] = useState('');
17
18
  const [type, setType] = useState('all');
@@ -36,7 +37,7 @@ export function CapabilitySearch({ onPick, autoFocus, compact }: {
36
37
  .then((d) => { if (live) { setResults(d.results); setErr(''); } })
37
38
  .catch((e) => { if (live) setErr((e as Error).message); });
38
39
  return () => { live = false; };
39
- }, [debounced, type]);
40
+ }, [debounced, type, refreshTick]);
40
41
 
41
42
  return (
42
43
  <div>
@@ -49,7 +50,7 @@ export function CapabilitySearch({ onPick, autoFocus, compact }: {
49
50
  </div>
50
51
  {err && <p className="err">{err}</p>}
51
52
  {results === null
52
- ? <p className="hint">Type to search skills, agents, commands, rules, flows.</p>
53
+ ? <p className="hint">Type to search every indexed capability and artifact type.</p>
53
54
  : !results.length
54
55
  ? <p className="empty">No matches.</p>
55
56
  : (
@@ -3,19 +3,29 @@ import { api } from '../api';
3
3
  import { CapabilitySearch } from './CapabilitySearch';
4
4
  import type { CapabilityResult, IndexQueryResponse, IndexStatusResponse } from '../types';
5
5
 
6
- export function Explore() {
6
+ export function Explore({ refreshTick = 0 }: { refreshTick?: number }) {
7
7
  const [body, setBody] = useState<{ type: string; name: string; body: string } | null>(null);
8
8
  const [status, setStatus] = useState<IndexStatusResponse | null>(null);
9
9
  const [query, setQuery] = useState('requirements');
10
10
  const [queryResults, setQueryResults] = useState<IndexQueryResponse | null>(null);
11
+ const [graphName, setGraphName] = useState('');
12
+ const [scanDirs, setScanDirs] = useState('docs');
11
13
  const [busy, setBusy] = useState('');
12
14
  const [err, setErr] = useState('');
15
+ const [stale, setStale] = useState(false);
13
16
 
14
17
  const loadStatus = () => {
15
- api<IndexStatusResponse>('/api/index/status').then(setStatus).catch((e) => setErr((e as Error).message));
18
+ api<IndexStatusResponse>('/api/index/status').then((next) => {
19
+ setStatus(next);
20
+ setStale(false);
21
+ setErr('');
22
+ }).catch((e) => {
23
+ setErr((e as Error).message);
24
+ setStale(true);
25
+ });
16
26
  };
17
27
 
18
- useEffect(() => { loadStatus(); }, []);
28
+ useEffect(() => { loadStatus(); }, [refreshTick]);
19
29
 
20
30
  const show = (r: CapabilityResult) => {
21
31
  setBody(null); setErr('');
@@ -56,6 +66,24 @@ export function Explore() {
56
66
  }
57
67
  };
58
68
 
69
+ const createGraph = async () => {
70
+ if (!graphName.trim() || !scanDirs.trim()) return;
71
+ setBusy('create'); setErr('');
72
+ try {
73
+ await api('/api/index/graphs', {
74
+ method: 'POST',
75
+ headers: { 'content-type': 'application/json' },
76
+ body: JSON.stringify({ name: graphName.trim(), scanDirs: scanDirs.split(',').map((value) => value.trim()).filter(Boolean) }),
77
+ });
78
+ setGraphName('');
79
+ loadStatus();
80
+ } catch (e) {
81
+ setErr((e as Error).message);
82
+ } finally {
83
+ setBusy('');
84
+ }
85
+ };
86
+
59
87
  const graphs = status?.graphs ?? [];
60
88
  const queryItems = queryResults?.results ?? [];
61
89
 
@@ -65,7 +93,7 @@ export function Explore() {
65
93
  Read-only catalog from the AIWG registry — display, not execution. To <em>run</em> a capability, inject it into a
66
94
  session (Actions/Sessions). Search modeled on the fortemi-react patterns.
67
95
  </p>
68
- {err && <p className="err">{err}</p>}
96
+ {err && <p className="err">{err}{status ? ' — showing last-known index state.' : ''}</p>}
69
97
  <section className="index-live" aria-label="Live index status">
70
98
  <div className="section-toolbar">
71
99
  <div>
@@ -74,6 +102,7 @@ export function Explore() {
74
102
  {status
75
103
  ? `${status.summary.built}/${status.summary.total} graphs built, ${status.summary.missing} missing, ${status.summary.orphans} orphan dirs.`
76
104
  : 'Loading index status.'}
105
+ {stale && status ? ' Stale: live refresh is temporarily unavailable.' : ''}
77
106
  </p>
78
107
  </div>
79
108
  <div className="manage-actions">
@@ -96,6 +125,17 @@ export function Explore() {
96
125
  </label>
97
126
  <button onClick={runIndexQuery} disabled={busy === 'query'}>{busy === 'query' ? 'Searching...' : 'Search index'}</button>
98
127
  </div>
128
+ <div className="index-query" aria-label="Create user index graph">
129
+ <label>
130
+ New graph name
131
+ <input value={graphName} onChange={(e) => setGraphName(e.target.value)} placeholder="references" />
132
+ </label>
133
+ <label>
134
+ Scan directories (comma-separated)
135
+ <input value={scanDirs} onChange={(e) => setScanDirs(e.target.value)} placeholder="docs,research" />
136
+ </label>
137
+ <button onClick={createGraph} disabled={busy === 'create' || !graphName.trim()}>{busy === 'create' ? 'Creating...' : 'Create graph'}</button>
138
+ </div>
99
139
  {queryResults && (
100
140
  <div className="index-results" aria-label="Index query results">
101
141
  {queryItems.length
@@ -111,7 +151,7 @@ export function Explore() {
111
151
  )}
112
152
  </section>
113
153
  <div className="grid2">
114
- <CapabilitySearch onPick={show} autoFocus />
154
+ <CapabilitySearch onPick={show} autoFocus refreshTick={refreshTick} />
115
155
  <div role="region" aria-label="Capability detail">
116
156
  {!body
117
157
  ? <p className="empty">Select a capability to inspect its definition.</p>
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from 'react';
1
+ import { useCallback, useEffect, useState, type FormEvent } from 'react';
2
2
  import { api } from '../api';
3
3
  import { fmtId } from '../util';
4
4
  import type { MissionProjection, MissionSession, MissionsResponse } from '../types';
@@ -39,6 +39,7 @@ export function Missions({ refreshTick = 0 }: { refreshTick?: number }) {
39
39
  <span><strong>{totals.awaiting}</strong> awaiting approval</span>
40
40
  <span><strong>{totals.terminal}</strong> terminal</span>
41
41
  </div>
42
+ <MissionComposer sessions={data.sessions.filter((session) => session.source === 'aiwg-mc')} onChanged={load} />
42
43
  {!data.sessions.length ? <p className="empty">No Mission Control sessions or live executor tasks are visible yet.</p> : (
43
44
  <div className="missions-layout">
44
45
  <aside className="mission-sessions" aria-label="Mission sessions">
@@ -54,15 +55,91 @@ export function Missions({ refreshTick = 0 }: { refreshTick?: number }) {
54
55
  </button>
55
56
  ))}
56
57
  </aside>
57
- {active && <MissionSessionView session={active} />}
58
+ {active && <MissionSessionView session={active} onChanged={load} />}
58
59
  </div>
59
60
  )}
60
61
  </>
61
62
  );
62
63
  }
63
64
 
64
- function MissionSessionView({ session }: { session: MissionSession }) {
65
+ function MissionComposer({ sessions, onChanged }: { sessions: MissionSession[]; onChanged: () => void }) {
66
+ const [sessionId, setSessionId] = useState(sessions[0]?.id ?? '');
67
+ const [objective, setObjective] = useState('');
68
+ const [completion, setCompletion] = useState('');
69
+ const [runNow, setRunNow] = useState(false);
70
+ const [acceptCost, setAcceptCost] = useState(false);
71
+ const [error, setError] = useState('');
72
+ const [busy, setBusy] = useState(false);
73
+ useEffect(() => {
74
+ if (!sessions.some((session) => session.id === sessionId)) setSessionId(sessions[0]?.id ?? '');
75
+ }, [sessions, sessionId]);
76
+ if (!sessions.length) return <p className="hint">Create a Mission Control session with <code>aiwg mc start</code> before dispatching from Cockpit.</p>;
77
+ const selected = sessions.find((session) => session.id === sessionId) ?? sessions[0]!;
78
+ const submit = async (event: FormEvent) => {
79
+ event.preventDefault();
80
+ setBusy(true);
81
+ setError('');
82
+ try {
83
+ await api('/api/missions', {
84
+ method: 'POST',
85
+ headers: { 'content-type': 'application/json' },
86
+ body: JSON.stringify({
87
+ session_id: selected.id,
88
+ objective,
89
+ completion,
90
+ expected_updated_at: selected.updated_at,
91
+ request_id: globalThis.crypto?.randomUUID?.() ?? `cockpit-${Date.now()}`,
92
+ run: runNow,
93
+ accept_cost: acceptCost,
94
+ }),
95
+ });
96
+ setObjective('');
97
+ setCompletion('');
98
+ onChanged();
99
+ } catch (caught) {
100
+ setError(`Mission dispatch failed: ${(caught as Error).message}`);
101
+ } finally {
102
+ setBusy(false);
103
+ }
104
+ };
105
+ return <form className="mission-composer" aria-label="Dispatch durable mission" onSubmit={submit}>
106
+ <label>Session<select value={selected.id} onChange={(event) => setSessionId(event.target.value)}>{sessions.map((session) => <option key={session.id} value={session.id}>{session.name}</option>)}</select></label>
107
+ <label>Objective<input required maxLength={4096} value={objective} onChange={(event) => setObjective(event.target.value)} /></label>
108
+ <label>Completion criteria<input maxLength={4096} value={completion} onChange={(event) => setCompletion(event.target.value)} /></label>
109
+ <label><input type="checkbox" checked={runNow} onChange={(event) => setRunNow(event.target.checked)} /> Run after queueing</label>
110
+ {runNow && <label><input type="checkbox" checked={acceptCost} onChange={(event) => setAcceptCost(event.target.checked)} /> Accept estimated provider cost if the non-interactive gate requires it</label>}
111
+ <button disabled={busy || !objective.trim()} type="submit">{runNow ? 'Queue and run mission' : 'Queue mission'}</button>
112
+ {error && <p className="err" role="alert">{error}</p>}
113
+ </form>;
114
+ }
115
+
116
+ function MissionSessionView({ session, onChanged }: { session: MissionSession; onChanged: () => void }) {
65
117
  const fleet = session.source === 'agentic-sandbox-fleet';
118
+ const controllable = session.source === 'aiwg-mc';
119
+ const [mutationError, setMutationError] = useState('');
120
+ const [mutating, setMutating] = useState('');
121
+ const mutate = async (action: 'pause' | 'resume' | 'cancel', missionId?: string) => {
122
+ setMutating(`${action}:${missionId ?? session.id}`);
123
+ setMutationError('');
124
+ const path = action === 'cancel'
125
+ ? `/api/missions/${encodeURIComponent(session.id)}/${encodeURIComponent(missionId!)}/cancel`
126
+ : `/api/missions/${encodeURIComponent(session.id)}/${action}`;
127
+ try {
128
+ await api(path, {
129
+ method: 'POST',
130
+ headers: { 'content-type': 'application/json' },
131
+ body: JSON.stringify({
132
+ expected_updated_at: session.updated_at,
133
+ request_id: globalThis.crypto?.randomUUID?.() ?? `cockpit-${Date.now()}`,
134
+ }),
135
+ });
136
+ onChanged();
137
+ } catch (error) {
138
+ setMutationError(`Mission control failed: ${(error as Error).message}. Refresh and retry if the state changed elsewhere.`);
139
+ } finally {
140
+ setMutating('');
141
+ }
142
+ };
66
143
  return (
67
144
  <section className="mission-detail" aria-label={`${session.name} missions`}>
68
145
  <div className="mission-detail-head">
@@ -75,8 +152,13 @@ function MissionSessionView({ session }: { session: MissionSession }) {
75
152
  {session.updated_at ? ` · updated ${new Date(session.updated_at).toLocaleString()}` : ''}
76
153
  </p>
77
154
  </div>
78
- <span className="badge">{session.audit_count} audit events</span>
155
+ <div>
156
+ <span className="badge">{session.audit_count} audit events</span>
157
+ {controllable && session.state === 'active' && <button disabled={Boolean(mutating)} onClick={() => mutate('pause')}>Pause session</button>}
158
+ {controllable && session.state === 'paused' && <button disabled={Boolean(mutating)} onClick={() => mutate('resume')}>Resume session</button>}
159
+ </div>
79
160
  </div>
161
+ {mutationError && <p className="err" role="alert">{mutationError}</p>}
80
162
  {!session.missions.length ? <p className="empty">This session has no missions.</p> : (
81
163
  <table className={fleet ? 'fleet-missions' : undefined}>
82
164
  <caption>{session.missions.length} mission projection(s)</caption>
@@ -93,6 +175,9 @@ function MissionSessionView({ session }: { session: MissionSession }) {
93
175
  {fleet && <small className="block">{workloadSemantics(m)}</small>}
94
176
  {m.completion && <small className="block">Done when: {m.completion}</small>}
95
177
  {m.error && <small className="block err">{m.error}</small>}
178
+ {controllable && !m.terminal && m.status !== 'aborted' && m.status !== 'failed' && m.status !== 'completed' && m.status !== 'done' && (
179
+ <button disabled={Boolean(mutating)} onClick={() => mutate('cancel', m.id)}>Cancel mission</button>
180
+ )}
96
181
  </td>
97
182
  <td>
98
183
  <span className={`state ${statusClass(m.status)}`}><span className="dot" aria-hidden="true" />{m.status}</span>
package/web/src/types.ts CHANGED
@@ -308,10 +308,10 @@ export interface McpDiscovery {
308
308
  notes: string[];
309
309
  }
310
310
  export interface CapabilityResult { path: string; type: string; title?: string; capability?: string; score?: number; name: string; triggers?: string[] }
311
- export interface ContribAction { id: string; title: string; icon?: string; group?: string; source: string; inject: { command: string; target?: string; needs_args?: boolean; args_hint?: string } }
312
- export interface ContribScreen { id: string; title: string; source: string; contribution: string }
311
+ export interface ContribAction { id: string; title: string; icon?: string; group?: string; source: string; trust_tier?: 'first-party' | 'sandboxed-third-party'; inject: { command: string; target?: string; needs_args?: boolean; args_hint?: string } }
312
+ export interface ContribScreen { id: string; title: string; source: string; contribution: string; trust_tier?: 'first-party' | 'sandboxed-third-party' }
313
313
  export interface ContribWorkflowStep { action: string; label?: string }
314
- export interface ContribWorkflow { id: string; title: string; description?: string; source: string; steps: ContribWorkflowStep[] }
314
+ export interface ContribWorkflow { id: string; title: string; description?: string; source: string; trust_tier?: 'first-party' | 'sandboxed-third-party'; steps: ContribWorkflowStep[] }
315
315
  export interface ContributionsResponse { actions: ContribAction[]; screens: ContribScreen[]; workflows: ContribWorkflow[] }
316
316
  export interface IndexGraphStatus { name: string; origin: string; shared: boolean; defaultBuild: boolean; location: string; built: boolean; builtAt: string | null; ageHours: number | null; entries: number | null; missing: boolean }
317
317
  export interface IndexStatusResponse { graphs: IndexGraphStatus[]; orphanIndexDirs: string[]; warnings: unknown[]; summary: { total: number; built: number; missing: number; orphans: number; warnings: number } }