@aiwg/cockpit 2026.7.7 → 2026.7.9

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.
@@ -45,6 +45,7 @@
45
45
  "items": {
46
46
  "type": "object",
47
47
  "required": ["id", "title", "source"],
48
+ "additionalProperties": false,
48
49
  "properties": {
49
50
  "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" },
50
51
  "title": { "type": "string" },
@@ -52,11 +53,38 @@
52
53
  }
53
54
  }
54
55
  },
56
+ "workflows": {
57
+ "type": "array",
58
+ "items": {
59
+ "type": "object",
60
+ "required": ["id", "title", "steps"],
61
+ "additionalProperties": false,
62
+ "properties": {
63
+ "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" },
64
+ "title": { "type": "string" },
65
+ "description": { "type": "string" },
66
+ "steps": {
67
+ "type": "array",
68
+ "minItems": 1,
69
+ "items": {
70
+ "type": "object",
71
+ "required": ["action"],
72
+ "additionalProperties": false,
73
+ "properties": {
74
+ "action": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$" },
75
+ "label": { "type": "string" }
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ },
55
82
  "hooks": {
56
83
  "type": "array",
57
84
  "items": {
58
85
  "type": "object",
59
86
  "required": ["on", "action"],
87
+ "additionalProperties": false,
60
88
  "properties": {
61
89
  "on": { "type": "string", "description": "event name, e.g. session.output, instance.state-changed" },
62
90
  "action": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,63}$", "description": "id of a contributed action to run" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cockpit",
3
- "version": "2026.7.7",
3
+ "version": "2026.7.9",
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",
@@ -38,6 +38,9 @@ async function canRun(cmd) {
38
38
  }
39
39
 
40
40
  export async function storeCockpitToken(token, account = `bridge-${process.pid}`) {
41
+ if (process.env.AIWG_COCKPIT_KEYCHAIN_DISABLED === '1') {
42
+ throw new Error('OS keychain disabled by AIWG_COCKPIT_KEYCHAIN_DISABLED');
43
+ }
41
44
  const os = platform();
42
45
  if (os === 'darwin' && await canRun('security')) {
43
46
  await collect('security', ['add-generic-password', '-a', account, '-s', SERVICE, '-w', token, '-U']);
@@ -14,6 +14,9 @@ export const RUNTIME_FILE = join(homedir(), '.aiwg', 'cockpit', 'runtime', 'brid
14
14
  /** Read the per-launch Bridge connection (token, port, url). Throws if not launched. */
15
15
  export async function readRuntime(file = RUNTIME_FILE) {
16
16
  const r = JSON.parse(await readFile(file, 'utf8'));
17
+ if (process.env.AIWG_COCKPIT_KEYCHAIN_STRICT === '1' && !r.token_ref) {
18
+ throw new Error(`runtime file ${file} is not keychain-backed in strict mode`);
19
+ }
17
20
  const token = r.token || await readCockpitToken(r.token_ref);
18
21
  if (!token || !r.port) throw new Error(`runtime file ${file} missing token/port`);
19
22
  return { ...r, token, url: `http://127.0.0.1:${r.port}` };
@@ -12,10 +12,10 @@ beforeEach(() => {
12
12
  });
13
13
  afterEach(() => { cleanup(); vi.restoreAllMocks(); });
14
14
 
15
- const TAB_LABELS = ['Home', 'Inventory', 'Running', 'Sessions', 'Approvals', 'Explore', 'Library', 'Actions'];
15
+ const TAB_LABELS = ['Home', 'Inventory', 'Running', 'Missions', 'Sessions', 'Approvals', 'Explore', 'Library', 'Telemetry', 'Memory', 'Actions'];
16
16
 
17
17
  describe('App shell (rendered DOM)', () => {
18
- it('renders an ARIA tablist with all eight tabs', () => {
18
+ it('renders an ARIA tablist with all Cockpit tabs', () => {
19
19
  render(<App />);
20
20
  expect(screen.getByRole('tablist', { name: /cockpit views/i })).toBeTruthy();
21
21
  expect(screen.getAllByRole('tab')).toHaveLength(TAB_LABELS.length);
@@ -45,6 +45,108 @@ describe('App shell (rendered DOM)', () => {
45
45
  expect(screen.getByText(/start a session automatically/i)).toBeTruthy();
46
46
  });
47
47
 
48
+ it('renders durable Missions projection from aiwg mc state and live executor work', async () => {
49
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
50
+ const url = String(input);
51
+ if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
52
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [instance('host-1', 'host', 'Codex host')] });
53
+ if (url.includes('/api/running')) return jsonResponse({ count: 1, running: [{ instance_id: 'host-1', task_id: 'task-abc', state: 'working', tenant: 'local' }] });
54
+ if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
55
+ if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
56
+ if (url.includes('/api/missions')) return jsonResponse({
57
+ source: 'aiwg-mc + agentic-sandbox',
58
+ fetched_at: new Date().toISOString(),
59
+ count: 2,
60
+ sessions: [{
61
+ id: 'mc-1',
62
+ name: 'Release hardening',
63
+ state: 'active',
64
+ source: 'aiwg-mc',
65
+ audit_count: 1,
66
+ audit_tail: [{ event: 'mission_dispatched', ts: '2026-07-04T12:00:00.000Z', missionId: 'm-1' }],
67
+ missions: [{ id: 'm-1', session_id: 'mc-1', source: 'aiwg-mc', title: 'Finish cockpit', status: 'running', loop: 1, max_iterations: 5, terminal: false }],
68
+ }, {
69
+ id: 'executor-live',
70
+ name: 'Executor live tasks',
71
+ state: 'active',
72
+ source: 'agentic-sandbox',
73
+ audit_count: 0,
74
+ audit_tail: [],
75
+ missions: [{ id: 'host-1::task-abc', session_id: 'executor-live', source: 'executor-task', title: 'Task task-abc', status: 'working', instance_id: 'host-1', task_id: 'task-abc', terminal: false }],
76
+ }],
77
+ missions: [
78
+ { id: 'm-1', session_id: 'mc-1', source: 'aiwg-mc', title: 'Finish cockpit', status: 'running', terminal: false },
79
+ { id: 'host-1::task-abc', session_id: 'executor-live', source: 'executor-task', title: 'Task task-abc', status: 'working', terminal: false },
80
+ ],
81
+ });
82
+ return jsonResponse({});
83
+ }) as typeof fetch;
84
+
85
+ render(<App />);
86
+ fireEvent.click(screen.getByRole('tab', { name: 'Missions' }));
87
+
88
+ expect((await screen.findAllByText('Release hardening')).length).toBeGreaterThanOrEqual(1);
89
+ expect(screen.getByLabelText('Mission status summary').textContent).toContain('2 total');
90
+ expect(screen.getByText('Finish cockpit')).toBeTruthy();
91
+ expect(screen.getByText(/mission_dispatched/)).toBeTruthy();
92
+ });
93
+
94
+ it('renders Bridge-backed Telemetry from unified events and cost', async () => {
95
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
96
+ const url = String(input);
97
+ if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
98
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [instance('host-1', 'host', 'Codex host')] });
99
+ if (url.includes('/api/running')) return jsonResponse({ count: 1, running: [{ instance_id: 'host-1', task_id: 'task-abc', state: 'working', tenant: 'local' }] });
100
+ if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
101
+ if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 1000, output_tokens: 2000, usd: 0.42 }, per_instance: [] });
102
+ if (url.includes('/api/missions')) return jsonResponse({ count: 1, sessions: [], missions: [{ id: 'm-1', session_id: 'mc-1', source: 'aiwg-mc', title: 'Mission', status: 'completed', terminal: true }] });
103
+ if (url.includes('/api/events/snapshot')) return jsonResponse({
104
+ source: 'cockpit.unified-event-model/v1',
105
+ fetched_at: new Date().toISOString(),
106
+ count: 2,
107
+ events: [
108
+ { id: 'mission:m-1', type: 'mission.lifecycle', source: 'aiwg-mc', subject: 'm-1', state: 'completed', ts: new Date().toISOString() },
109
+ { id: 'session:host-1:sess-1', type: 'session.lifecycle', source: 'pty-session', subject: 'sess-1', state: 'available', ts: new Date().toISOString() },
110
+ { id: 'task:task-abc', type: 'task.lifecycle', source: 'a2a', subject: 'task-abc', state: 'working', ts: new Date().toISOString() },
111
+ ],
112
+ });
113
+ return jsonResponse({});
114
+ }) as typeof fetch;
115
+
116
+ render(<App />);
117
+ fireEvent.click(screen.getByRole('tab', { name: 'Telemetry' }));
118
+
119
+ expect(await screen.findByText('cockpit.unified-event-model/v1')).toBeTruthy();
120
+ expect(screen.getByText('$0.42')).toBeTruthy();
121
+ expect(screen.getByText('mission.lifecycle')).toBeTruthy();
122
+ fireEvent.click(screen.getByRole('button', { name: 'session' }));
123
+ expect(screen.getByText('session.lifecycle')).toBeTruthy();
124
+ });
125
+
126
+ it('renders local Memory and auto-notes terminal Missions', async () => {
127
+ localStorage.clear();
128
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
129
+ const url = String(input);
130
+ if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
131
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [instance('host-1', 'host', 'Codex host')] });
132
+ if (url.includes('/api/running')) return jsonResponse({ count: 0, running: [] });
133
+ if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
134
+ if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
135
+ if (url.includes('/api/missions')) return jsonResponse({
136
+ count: 1,
137
+ sessions: [],
138
+ missions: [{ id: 'm-done', session_id: 'mc-1', source: 'aiwg-mc', title: 'Closed release gate', status: 'completed', completed_at: '2026-07-04T12:00:00.000Z', terminal: true }],
139
+ });
140
+ return jsonResponse({});
141
+ }) as typeof fetch;
142
+
143
+ render(<App />);
144
+ fireEvent.click(screen.getByRole('tab', { name: 'Memory' }));
145
+
146
+ expect(await screen.findByText('Closed release gate')).toBeTruthy();
147
+ expect(screen.getByText(/1 saved/i)).toBeTruthy();
148
+ });
149
+
48
150
  it('renders the welcome heading and its tabpanel (not blank)', () => {
49
151
  render(<App />);
50
152
  expect(screen.getByRole('heading', { name: /work alongside your agents/i })).toBeTruthy();
@@ -158,18 +260,60 @@ describe('App shell (rendered DOM)', () => {
158
260
  source: 'test',
159
261
  inject: { command: 'aiwg doctor' },
160
262
  }],
263
+ screens: [{ id: 'index-live', title: 'Live Index', source: 'cockpit://index/live', contribution: 'test' }],
264
+ workflows: [{
265
+ id: 'maintenance-check',
266
+ title: 'Maintenance Check',
267
+ description: 'Run doctor through an agentic session.',
268
+ source: 'test',
269
+ steps: [{ action: 'doctor', label: 'Doctor' }],
270
+ }],
161
271
  });
162
272
  return jsonResponse({});
163
273
  }) as typeof fetch;
164
274
 
165
275
  render(<App />);
166
276
  fireEvent.click(screen.getByRole('tab', { name: 'Actions' }));
277
+ expect(await screen.findByText('Live Index')).toBeTruthy();
278
+ expect(screen.getByText('Maintenance Check')).toBeTruthy();
167
279
  fireEvent.click(await screen.findByRole('button', { name: /copy CLI command for run doctor/i }));
168
280
 
169
281
  expect(writeText).toHaveBeenCalledWith('aiwg doctor');
170
282
  await waitFor(() => expect(screen.getByText(/copied "aiwg doctor"/i)).toBeTruthy());
171
283
  });
172
284
 
285
+ it('renders live index status and query results in Explore', async () => {
286
+ globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
287
+ const url = String(input);
288
+ if (url.includes('/api/health')) return jsonResponse({ executor_url: 'http://127.0.0.1:8122' });
289
+ if (url.includes('/api/inventory')) return jsonResponse({ instances: [instance('host-1', 'host', 'Codex host')] });
290
+ if (url.includes('/api/running')) return jsonResponse({ count: 0, running: [] });
291
+ if (url.includes('/api/approvals')) return jsonResponse({ approvals: [] });
292
+ if (url.includes('/api/cost')) return jsonResponse({ total: { input_tokens: 0, output_tokens: 0, usd: 0 }, per_instance: [] });
293
+ if (url.includes('/api/index/status')) return jsonResponse({
294
+ graphs: [{ name: 'project', origin: 'builtin', shared: false, defaultBuild: true, location: '.aiwg/.index/project', built: true, builtAt: '2026-07-04T12:00:00.000Z', ageHours: 1, entries: 42, missing: false }],
295
+ orphanIndexDirs: [],
296
+ warnings: [],
297
+ summary: { total: 1, built: 1, missing: 0, orphans: 0, warnings: 0 },
298
+ });
299
+ if (url.includes('/api/index/query')) return jsonResponse({
300
+ results: [{ path: '.aiwg/requirements/UC-001.md', title: 'Mission requirements', type: 'use-case', phase: 'requirements', summary: 'Operator workflow coverage', score: 0.91 }],
301
+ total: 1,
302
+ });
303
+ return jsonResponse({});
304
+ }) as typeof fetch;
305
+
306
+ render(<App />);
307
+ fireEvent.click(screen.getByRole('tab', { name: 'Explore' }));
308
+
309
+ expect(await screen.findByText(/1\/1 graphs built/i)).toBeTruthy();
310
+ fireEvent.change(screen.getByLabelText(/index query/i), { target: { value: 'mission' } });
311
+ fireEvent.click(screen.getByRole('button', { name: /search index/i }));
312
+
313
+ expect(await screen.findByText('Mission requirements')).toBeTruthy();
314
+ expect(screen.getByText(/operator workflow coverage/i)).toBeTruthy();
315
+ });
316
+
173
317
  it('can open the mission-handoff review layout from a shareable URL', async () => {
174
318
  window.history.replaceState({}, '', '/?wall=handoff');
175
319
  globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
package/web/src/App.tsx CHANGED
@@ -5,11 +5,14 @@ import type { Approval, Instance, ResponseNeeded } from './types';
5
5
  import { Welcome } from './components/Welcome';
6
6
  import { Inventory } from './components/Inventory';
7
7
  import { Running } from './components/Running';
8
+ import { Missions } from './components/Missions';
8
9
  import { Sessions } from './components/Sessions';
9
10
  import { Approvals } from './components/Approvals';
10
11
  import { Explore } from './components/Explore';
11
12
  import { Library } from './components/Library';
12
13
  import { Actions } from './components/Actions';
14
+ import { Telemetry } from './components/Telemetry';
15
+ import { Memory } from './components/Memory';
13
16
  import { StartSessionModal } from './components/StartSessionModal';
14
17
  import { LaunchInstanceModal } from './components/LaunchInstanceModal';
15
18
 
@@ -17,10 +20,13 @@ const TABS = [
17
20
  { id: 'welcome', label: 'Home' },
18
21
  { id: 'inventory', label: 'Inventory' },
19
22
  { id: 'running', label: 'Running' },
23
+ { id: 'missions', label: 'Missions' },
20
24
  { id: 'sessions', label: 'Sessions' },
21
25
  { id: 'approvals', label: 'Approvals' },
22
26
  { id: 'explore', label: 'Explore' },
23
27
  { id: 'library', label: 'Library' },
28
+ { id: 'telemetry', label: 'Telemetry' },
29
+ { id: 'memory', label: 'Memory' },
24
30
  { id: 'actions', label: 'Actions' },
25
31
  ] as const;
26
32
  type TabId = (typeof TABS)[number]['id'];
@@ -165,6 +171,7 @@ export function App() {
165
171
  <Panel id="welcome" tab={tab}><Welcome onStartSession={() => requestStart()} onLaunchInstance={() => setLaunchOpen(true)} goTo={(t) => setTab(t as TabId)} /></Panel>
166
172
  <Panel id="inventory" tab={tab}><Inventory onStartSession={requestStart} onLaunchInstance={() => setLaunchOpen(true)} /></Panel>
167
173
  <Panel id="running" tab={tab}><Running refreshTick={refreshTick} /></Panel>
174
+ <Panel id="missions" tab={tab}><Missions refreshTick={refreshTick} /></Panel>
168
175
  {/* Sessions stays mounted so the WebSocket survives tab switches */}
169
176
  <section id="panel-sessions" role="tabpanel" aria-labelledby="tab-sessions" hidden={tab !== 'sessions'}>
170
177
  <Sessions session={session} composer={composer} setComposer={setComposer} onRequestStart={requestStart} />
@@ -174,6 +181,8 @@ export function App() {
174
181
  <Panel id="library" tab={tab}>
175
182
  <Library session={session} setComposer={setComposer} goSessions={() => setTab('sessions')} />
176
183
  </Panel>
184
+ <Panel id="telemetry" tab={tab}><Telemetry refreshTick={refreshTick} /></Panel>
185
+ <Panel id="memory" tab={tab}><Memory refreshTick={refreshTick} /></Panel>
177
186
  <Panel id="actions" tab={tab}>
178
187
  <Actions refreshTick={refreshTick} session={session} setComposer={setComposer} goSessions={() => setTab('sessions')} />
179
188
  </Panel>
@@ -1,25 +1,45 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { api } from '../api';
3
- import type { ContribAction } from '../types';
3
+ import type { ContribAction, ContribScreen, ContribWorkflow, ContributionsResponse } from '../types';
4
4
  import type { SessionApi } from '../useSession';
5
5
 
6
6
  // Actions INJECT a command into an agentic session — the Cockpit never runs the CLI.
7
7
  export function Actions({ refreshTick = 0, session, setComposer, goSessions }: { refreshTick?: number; session: SessionApi; setComposer: (v: string) => void; goSessions: () => void }) {
8
8
  const [actions, setActions] = useState<ContribAction[]>([]);
9
+ const [screens, setScreens] = useState<ContribScreen[]>([]);
10
+ const [workflows, setWorkflows] = useState<ContribWorkflow[]>([]);
9
11
  const [err, setErr] = useState('');
10
12
  const [note, setNote] = useState('');
11
13
 
12
14
  useEffect(() => {
13
- api<{ actions: ContribAction[] }>('/api/contributions').then((d) => setActions(d.actions)).catch((e) => setErr((e as Error).message));
15
+ api<ContributionsResponse>('/api/contributions').then((d) => {
16
+ setActions(d.actions ?? []);
17
+ setScreens(d.screens ?? []);
18
+ setWorkflows(d.workflows ?? []);
19
+ }).catch((e) => setErr((e as Error).message));
14
20
  }, [refreshTick]);
15
21
 
16
- const inject = (a: ContribAction) => {
22
+ const inject = async (a: ContribAction) => {
17
23
  let command = a.inject.command;
18
24
  if (a.inject.needs_args) {
19
25
  const extra = prompt(`Arguments for ${a.title}${a.inject.args_hint ? ` (${a.inject.args_hint})` : ''}:`, '');
20
26
  if (extra === null) return;
21
27
  if (extra.trim()) command += ' ' + extra.trim();
22
28
  }
29
+ await api('/api/audit/intent', {
30
+ method: 'POST',
31
+ headers: { 'content-type': 'application/json' },
32
+ body: JSON.stringify({
33
+ event: 'action.inject.requested',
34
+ detail: {
35
+ action_id: a.id,
36
+ source: a.source,
37
+ target: a.inject.target ?? 'focused',
38
+ command,
39
+ controller_attached: session.isController,
40
+ },
41
+ }),
42
+ }).catch(() => undefined);
23
43
  if (session.isController && session.sendInput(command)) {
24
44
  setNote(`Injected "${command}" into the attached session — the agent runs it.`);
25
45
  } else {
@@ -32,6 +52,20 @@ export function Actions({ refreshTick = 0, session, setComposer, goSessions }: {
32
52
  await navigator.clipboard?.writeText(a.inject.command);
33
53
  setNote(`Copied "${a.inject.command}" to the clipboard.`);
34
54
  };
55
+ const actionById = new Map(actions.map((a) => [a.id, a]));
56
+ const openScreen = (screen: ContribScreen) => {
57
+ if (screen.source === 'cockpit://index/live') {
58
+ window.location.hash = '#explore';
59
+ setNote('Opened the contributed Live Index screen.');
60
+ return;
61
+ }
62
+ if (screen.source === 'cockpit://issues/workbench') {
63
+ window.location.hash = '#actions';
64
+ setNote('Issue Workbench is available here through the contributed issue actions and workflows.');
65
+ return;
66
+ }
67
+ setNote(`Unsupported contributed screen source: ${screen.source}`);
68
+ };
35
69
 
36
70
  return (
37
71
  <>
@@ -54,6 +88,49 @@ export function Actions({ refreshTick = 0, session, setComposer, goSessions }: {
54
88
  ))
55
89
  : <p className="empty">No contributed actions.</p>}
56
90
  </div>
91
+ <div className="contrib-layout">
92
+ <section aria-label="Contributed screens">
93
+ <div className="section-toolbar">
94
+ <h2>Screens</h2>
95
+ </div>
96
+ <div className="contrib-grid">
97
+ {screens.length
98
+ ? screens.map((s) => (
99
+ <article className="contrib-item" key={s.id}>
100
+ <strong>{s.title}</strong>
101
+ <p>{s.source}</p>
102
+ <button onClick={() => openScreen(s)}>Open</button>
103
+ </article>
104
+ ))
105
+ : <p className="empty">No contributed screens.</p>}
106
+ </div>
107
+ </section>
108
+ <section aria-label="Contributed workflows">
109
+ <div className="section-toolbar">
110
+ <h2>Workflows</h2>
111
+ </div>
112
+ <div className="contrib-grid">
113
+ {workflows.length
114
+ ? workflows.map((w) => (
115
+ <article className="contrib-item" key={w.id}>
116
+ <strong>{w.title}</strong>
117
+ {w.description && <p>{w.description}</p>}
118
+ <div className="workflow-steps">
119
+ {w.steps.map((step, idx) => {
120
+ const action = actionById.get(step.action);
121
+ return (
122
+ <button key={`${w.id}-${idx}-${step.action}`} disabled={!action} onClick={() => action && inject(action)}>
123
+ {step.label || action?.title || step.action}
124
+ </button>
125
+ );
126
+ })}
127
+ </div>
128
+ </article>
129
+ ))
130
+ : <p className="empty">No contributed workflows.</p>}
131
+ </div>
132
+ </section>
133
+ </div>
57
134
  <p className="empty">{note || 'Output appears in the session terminal — actions drop a command into a session and the agent executes it.'}</p>
58
135
  </>
59
136
  );
@@ -1,12 +1,22 @@
1
- import { useState } from 'react';
1
+ import { useEffect, useState } from 'react';
2
2
  import { api } from '../api';
3
3
  import { CapabilitySearch } from './CapabilitySearch';
4
- import type { CapabilityResult } from '../types';
4
+ import type { CapabilityResult, IndexQueryResponse, IndexStatusResponse } from '../types';
5
5
 
6
6
  export function Explore() {
7
7
  const [body, setBody] = useState<{ type: string; name: string; body: string } | null>(null);
8
+ const [status, setStatus] = useState<IndexStatusResponse | null>(null);
9
+ const [query, setQuery] = useState('requirements');
10
+ const [queryResults, setQueryResults] = useState<IndexQueryResponse | null>(null);
11
+ const [busy, setBusy] = useState('');
8
12
  const [err, setErr] = useState('');
9
13
 
14
+ const loadStatus = () => {
15
+ api<IndexStatusResponse>('/api/index/status').then(setStatus).catch((e) => setErr((e as Error).message));
16
+ };
17
+
18
+ useEffect(() => { loadStatus(); }, []);
19
+
10
20
  const show = (r: CapabilityResult) => {
11
21
  setBody(null); setErr('');
12
22
  // Fetch by the discovered path — deterministic even when a name is shared by two
@@ -16,6 +26,39 @@ export function Explore() {
16
26
  .then(setBody).catch((e) => setErr((e as Error).message));
17
27
  };
18
28
 
29
+ const runIndexQuery = async () => {
30
+ const q = query.trim();
31
+ if (!q) return;
32
+ setBusy('query'); setErr('');
33
+ try {
34
+ setQueryResults(await api<IndexQueryResponse>(`/api/index/query?q=${encodeURIComponent(q)}&limit=8`));
35
+ } catch (e) {
36
+ setErr((e as Error).message);
37
+ } finally {
38
+ setBusy('');
39
+ }
40
+ };
41
+
42
+ const rebuild = async () => {
43
+ setBusy('rebuild'); setErr('');
44
+ try {
45
+ const result = await api<{ status: IndexStatusResponse }>('/api/index/rebuild', {
46
+ method: 'POST',
47
+ headers: { 'content-type': 'application/json' },
48
+ body: JSON.stringify({ all: true }),
49
+ });
50
+ setStatus(result.status);
51
+ setQueryResults(null);
52
+ } catch (e) {
53
+ setErr((e as Error).message);
54
+ } finally {
55
+ setBusy('');
56
+ }
57
+ };
58
+
59
+ const graphs = status?.graphs ?? [];
60
+ const queryItems = queryResults?.results ?? [];
61
+
19
62
  return (
20
63
  <>
21
64
  <p className="hint">
@@ -23,6 +66,50 @@ export function Explore() {
23
66
  session (Actions/Sessions). Search modeled on the fortemi-react patterns.
24
67
  </p>
25
68
  {err && <p className="err">{err}</p>}
69
+ <section className="index-live" aria-label="Live index status">
70
+ <div className="section-toolbar">
71
+ <div>
72
+ <h2>Live Index</h2>
73
+ <p className="hint">
74
+ {status
75
+ ? `${status.summary.built}/${status.summary.total} graphs built, ${status.summary.missing} missing, ${status.summary.orphans} orphan dirs.`
76
+ : 'Loading index status.'}
77
+ </p>
78
+ </div>
79
+ <div className="manage-actions">
80
+ <button onClick={loadStatus} disabled={busy === 'rebuild'}>Refresh</button>
81
+ <button onClick={rebuild} disabled={busy === 'rebuild'}>{busy === 'rebuild' ? 'Rebuilding...' : 'Rebuild all'}</button>
82
+ </div>
83
+ </div>
84
+ <div className="index-grid">
85
+ {graphs.map((g) => (
86
+ <article className={g.missing ? 'index-graph missing' : 'index-graph'} key={g.name}>
87
+ <div><strong>{g.name}</strong> <span className="badge">{g.origin}</span></div>
88
+ <p>{g.built ? `${g.entries ?? 0} entries` : 'not built'}{g.ageHours !== null ? ` · ${g.ageHours}h old` : ''}</p>
89
+ </article>
90
+ ))}
91
+ </div>
92
+ <div className="index-query">
93
+ <label>
94
+ Index query
95
+ <input value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void runIndexQuery(); }} />
96
+ </label>
97
+ <button onClick={runIndexQuery} disabled={busy === 'query'}>{busy === 'query' ? 'Searching...' : 'Search index'}</button>
98
+ </div>
99
+ {queryResults && (
100
+ <div className="index-results" aria-label="Index query results">
101
+ {queryItems.length
102
+ ? queryItems.map((r) => (
103
+ <article key={r.path}>
104
+ <strong>{r.title || r.path}</strong>
105
+ <p>{r.summary || r.path}</p>
106
+ <small>{r.type || 'artifact'}{r.phase ? ` · ${r.phase}` : ''}{typeof r.score === 'number' ? ` · ${r.score.toFixed(2)}` : ''}</small>
107
+ </article>
108
+ ))
109
+ : <p className="empty">No index hits.</p>}
110
+ </div>
111
+ )}
112
+ </section>
26
113
  <div className="grid2">
27
114
  <CapabilitySearch onPick={show} autoFocus />
28
115
  <div role="region" aria-label="Capability detail">