@aiwg/cockpit 2026.7.7 → 2026.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,9 +60,11 @@ AIWG_COCKPIT_EXECUTOR_URL=http://127.0.0.1:8122 aiwg cockpit
60
60
  - **Running** — active work across stacks, spend posture, and task stop controls.
61
61
  - **Sessions** — observe-first terminal attach, explicit drive/control, and replay posture.
62
62
  - **Approvals** — unified human-in-the-loop decision inbox.
63
- - **Explore** — read-only AIWG capability catalog for discovery and routing.
63
+ - **Explore** — live index status/query/rebuild plus read-only AIWG capability catalog.
64
64
  - **Library** — user-owned assets cloned/imported under `~/.aiwg/cockpit/library`.
65
- - **Actions** — contributed buttons that inject commands into an agentic session.
65
+ - **Telemetry** — unified event feed, Mission/session/task/approval/inventory posture, and spend.
66
+ - **Memory** — browser-local operator notes plus Mission completion notes from MC state.
67
+ - **Actions** — contributed actions, first-party screens, and workflows; action steps inject commands into an agentic session.
66
68
 
67
69
  ## What Cockpit Is
68
70
 
@@ -138,9 +140,11 @@ operator / CLI: aiwg cockpit
138
140
  | **Running** | Running work across stacks + cross-stack spend + per-task Stop. |
139
141
  | **Sessions** | Live pty terminal — observe/drive, keyframe, non-destructive replay; inline **+ capability picker**. |
140
142
  | **Approvals** | Unified HITL inbox (`hitl-prompt/v1`); decisions = operator authorization. |
141
- | **Explore** | Read-only AIWG catalog — Tenor-style capability search (fortemi-react-modeled). |
143
+ | **Explore** | Live artifact-index status/query/rebuild plus read-only AIWG catalog search. |
142
144
  | **Library** | Your own assets — clone from the catalog / import / remove. AIWG files never overwritten. |
143
- | **Actions** | Contributed buttons that **inject a command into a session** (the agent runs it). |
145
+ | **Telemetry** | Unified event model over inventory, sessions, tasks, approvals, Missions, and cost posture. |
146
+ | **Memory** | Operator notes and auto-created Mission completion notes from durable `aiwg mc` state. |
147
+ | **Actions** | Contributed actions, first-party screens, and workflows. Action steps **inject a command into a session** (the agent runs it). |
144
148
 
145
149
  ## Runtime, Session, and Trust Posture
146
150
 
@@ -169,6 +173,14 @@ activity payloads. Legacy shared-secret and TOFU paths render as compatibility o
169
173
  degraded, not default-green. Agentic-sandbox owns transport provisioning and peer
170
174
  identity enforcement; Cockpit owns visibility and audit presentation.
171
175
 
176
+ Set `AIWG_COCKPIT_KEYCHAIN_STRICT=1` when shells must refuse plaintext runtime
177
+ tokens. In strict mode the Bridge exits if it cannot persist the per-launch token
178
+ to the OS keychain, and shell-core refuses runtime files that only contain a
179
+ plaintext token. Operator intent is also recorded in a local redacted audit log
180
+ under `~/.aiwg/cockpit/audit/events.jsonl` for lifecycle, session, action-inject,
181
+ and approval-response decisions; bearer material and provider credentials are
182
+ redacted before write.
183
+
172
184
  ## Run (dev/test, against a real agentic-sandbox executor)
173
185
 
174
186
  One command (#1634) — prefers a reachable real executor, builds the web UI if
@@ -7,7 +7,7 @@
7
7
  // per-launch token + OS-keychain (roctinam/aiwg#1595).
8
8
  import http from 'node:http';
9
9
  import { spawn } from 'node:child_process';
10
- import { readFile, mkdir, writeFile, chmod, readdir, cp, rm, stat } from 'node:fs/promises';
10
+ import { readFile, mkdir, writeFile, chmod, readdir, cp, rm, stat, appendFile } from 'node:fs/promises';
11
11
  import { existsSync } from 'node:fs';
12
12
  import { randomBytes, timingSafeEqual } from 'node:crypto';
13
13
  import { homedir } from 'node:os';
@@ -27,11 +27,14 @@ const ALLOW_MOCK_EXECUTOR = process.env.AIWG_COCKPIT_ALLOW_MOCK_EXECUTOR === '1'
27
27
  const AUTOSTART_EXECUTOR = process.env.AIWG_COCKPIT_AUTOSTART_EXECUTOR !== '0';
28
28
  const EXECUTOR_COMMAND = process.env.AIWG_COCKPIT_EXECUTOR_COMMAND ?? '';
29
29
  const RUNTIME_DIR = join(homedir(), '.aiwg', 'cockpit', 'runtime');
30
+ const auditDir = () => process.env.AIWG_COCKPIT_AUDIT_DIR || join(homedir(), '.aiwg', 'cockpit', 'audit');
31
+ const auditLog = () => join(auditDir(), 'events.jsonl');
30
32
  // The built React app (apps/cockpit/web/dist). Served when present; falls back to the
31
33
  // legacy vanilla page so the Bridge works even before a web build.
32
34
  const WEB_DIST = fileURLToPath(new URL('../../web/dist', import.meta.url));
33
35
  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
36
  const CAPABILITY_TYPES = new Set(['skill', 'agent', 'command', 'rule', 'flow']);
37
+ const mcSessionsDir = () => join(process.cwd(), '.aiwg', 'ralph-external', 'mc', 'sessions');
35
38
 
36
39
  /** Serve a static file from the built web app, sandboxed to WEB_DIST. Returns true if served. */
37
40
  async function serveDistFile(res, relPath) {
@@ -86,13 +89,14 @@ async function writeRuntimeToken({ token, port, pid }) {
86
89
  await mkdir(RUNTIME_DIR, { recursive: true, mode: 0o700 });
87
90
  const file = join(RUNTIME_DIR, 'bridge.json');
88
91
  const runtime = { token, port, pid, started_at: new Date().toISOString(), keychain_backed: false };
92
+ const strict = process.env.AIWG_COCKPIT_KEYCHAIN_STRICT === '1';
89
93
  try {
90
94
  runtime.token_ref = await storeCockpitToken(token, `bridge-${pid}`);
91
95
  runtime.keychain_backed = true;
92
- if (process.env.AIWG_COCKPIT_KEYCHAIN_STRICT === '1') delete runtime.token;
96
+ if (strict) delete runtime.token;
93
97
  } catch (e) {
94
98
  runtime.keychain_error = String(e?.message ?? e);
95
- if (process.env.AIWG_COCKPIT_REQUIRE_KEYCHAIN === '1') throw e;
99
+ if (strict || process.env.AIWG_COCKPIT_REQUIRE_KEYCHAIN === '1') throw e;
96
100
  }
97
101
  await writeFile(file, JSON.stringify(runtime, null, 2), { mode: 0o600 });
98
102
  await chmod(file, 0o600);
@@ -201,13 +205,25 @@ function validateContribution(m, where) {
201
205
  if (!a.inject || typeof a.inject.command !== 'string') fail(`action ${a.id}: inject.command (string) required`);
202
206
  if (a.inject.target && !['focused', 'new'].includes(a.inject.target)) fail(`action ${a.id}: inject.target must be focused|new`);
203
207
  }
204
- for (const s of c.screens || []) { if (!ID_RE.test(s.id || '') || typeof s.source !== 'string') fail(`screen invalid: ${s.id}`); }
208
+ for (const s of c.screens || []) {
209
+ if (!ID_RE.test(s.id || '')) fail(`screen.id invalid: ${s.id}`);
210
+ if (typeof s.title !== 'string') fail(`screen ${s.id}: title required`);
211
+ if (typeof s.source !== 'string') fail(`screen ${s.id}: source required`);
212
+ }
213
+ for (const w of c.workflows || []) {
214
+ if (!ID_RE.test(w.id || '')) fail(`workflow.id invalid: ${w.id}`);
215
+ if (typeof w.title !== 'string') fail(`workflow ${w.id}: title required`);
216
+ if (!Array.isArray(w.steps) || w.steps.length === 0) fail(`workflow ${w.id}: steps required`);
217
+ for (const step of w.steps) {
218
+ if (!step || typeof step !== 'object' || !ID_RE.test(step.action || '')) fail(`workflow ${w.id}: step.action invalid`);
219
+ }
220
+ }
205
221
  for (const h of c.hooks || []) { if (typeof h.on !== 'string' || !ID_RE.test(h.action || '')) fail(`hook invalid: on=${h.on}`); }
206
222
  return m;
207
223
  }
208
224
  /** Load + validate + merge all contribution manifests across the configured dirs. */
209
225
  async function loadContributions() {
210
- const sources = [], actions = [], screens = [], hooks = [];
226
+ const sources = [], actions = [], screens = [], hooks = [], workflows = [];
211
227
  for (const dir of CONTRIB_DIRS) {
212
228
  let entries = [];
213
229
  try { entries = (await readdir(dir)).filter((f) => f.endsWith('.json') && f !== 'contribution.schema.json'); } catch { continue; }
@@ -215,11 +231,65 @@ async function loadContributions() {
215
231
  const m = validateContribution(JSON.parse(await readFile(join(dir, file), 'utf8')), file);
216
232
  sources.push({ id: m.id, version: m.version, title: m.title ?? m.id, file });
217
233
  for (const a of m.contributes?.actions || []) actions.push({ ...a, source: m.id });
218
- for (const s of m.contributes?.screens || []) screens.push({ ...s, source: m.id });
234
+ for (const s of m.contributes?.screens || []) screens.push({ ...s, contribution: m.id });
219
235
  for (const h of m.contributes?.hooks || []) hooks.push({ ...h, source: m.id });
236
+ for (const w of m.contributes?.workflows || []) workflows.push({ ...w, source: m.id });
220
237
  }
221
238
  }
222
- return { sources, actions, screens, hooks };
239
+ return { sources, actions, screens, hooks, workflows };
240
+ }
241
+
242
+ function safeIndexGraph(value) {
243
+ const graph = String(value ?? '').trim();
244
+ if (!graph) return '';
245
+ if (!ID_RE.test(graph)) throw new Error('graph must match [a-z0-9._-]{1,64}');
246
+ return graph;
247
+ }
248
+
249
+ function safeIndexLimit(value, fallback = 20) {
250
+ const n = Number(value ?? fallback);
251
+ if (!Number.isInteger(n) || n < 1 || n > 100) throw new Error('limit must be an integer from 1 to 100');
252
+ return n;
253
+ }
254
+
255
+ async function getIndexStatus() {
256
+ return JSON.parse(await runAiwg(['index', 'status', '--json']));
257
+ }
258
+
259
+ async function queryIndex(url) {
260
+ const q = (url.searchParams.get('q') || '').trim();
261
+ if (!q) return { status: 400, body: { error: 'q_required' } };
262
+ let limit;
263
+ try { limit = safeIndexLimit(url.searchParams.get('limit'), 20); }
264
+ catch (e) { return { status: 400, body: { error: 'invalid_limit', detail: String(e?.message ?? e) } }; }
265
+ const args = ['index', 'query', q, '--json', '--backend', 'local', '--limit', String(limit)];
266
+ try {
267
+ const graph = safeIndexGraph(url.searchParams.get('graph'));
268
+ if (graph) args.push('--graph', graph);
269
+ } catch (e) { return { status: 400, body: { error: 'invalid_graph', detail: String(e?.message ?? e) } }; }
270
+ for (const flag of ['type', 'phase', 'tags', 'path']) {
271
+ const value = (url.searchParams.get(flag) || '').trim();
272
+ if (value) args.push(`--${flag}`, value);
273
+ }
274
+ return { status: 200, body: JSON.parse(await runAiwg(args)) };
275
+ }
276
+
277
+ async function rebuildIndex(req) {
278
+ const parsed = await readJsonBody(req);
279
+ if (parsed.error) return { status: 400, body: { error: parsed.error } };
280
+ const body = parsed.body || {};
281
+ const args = ['index', 'build'];
282
+ try {
283
+ const graph = safeIndexGraph(body.graph);
284
+ if (graph) args.push('--graph', graph);
285
+ } catch (e) { return { status: 400, body: { error: 'invalid_graph', detail: String(e?.message ?? e) } }; }
286
+ if (body.all === true) args.push('--all');
287
+ if (body.force === true) args.push('--force');
288
+ const requested = await appendAudit('index.rebuild.requested', { graph: body.graph ?? null, all: body.all === true, force: body.force === true });
289
+ const output = await runAiwg(args);
290
+ const status = await getIndexStatus();
291
+ await appendAudit('index.rebuild.completed', { request_ts: requested.ts, graph: body.graph ?? null, all: body.all === true, force: body.force === true });
292
+ return { status: 200, body: { ok: true, command: `aiwg ${args.join(' ')}`, output, status } };
223
293
  }
224
294
 
225
295
  function json(res, status, body) {
@@ -227,6 +297,59 @@ function json(res, status, body) {
227
297
  res.end(JSON.stringify(body));
228
298
  }
229
299
 
300
+ function redactAuditValue(value) {
301
+ if (value === undefined || value === null) return value;
302
+ if (Array.isArray(value)) return value.map(redactAuditValue);
303
+ if (typeof value === 'object') {
304
+ const out = {};
305
+ for (const [k, v] of Object.entries(value)) {
306
+ if (/token|secret|password|credential|api[_-]?key|authorization|csrf/i.test(k)) out[k] = '[redacted]';
307
+ else out[k] = redactAuditValue(v);
308
+ }
309
+ return out;
310
+ }
311
+ if (typeof value === 'string' && /(bearer\s+[a-z0-9._-]+|sk-[a-z0-9]|gh[pousr]_[a-z0-9])/i.test(value)) return '[redacted]';
312
+ return value;
313
+ }
314
+
315
+ async function appendAudit(event, fields = {}) {
316
+ const dir = auditDir();
317
+ const log = auditLog();
318
+ await mkdir(dir, { recursive: true, mode: 0o700 });
319
+ const entry = redactAuditValue({
320
+ event,
321
+ ts: new Date().toISOString(),
322
+ actor: 'operator',
323
+ surface: 'cockpit-bridge',
324
+ ...fields,
325
+ });
326
+ await appendFile(log, JSON.stringify(entry) + '\n', { mode: 0o600 });
327
+ await chmod(log, 0o600).catch(() => undefined);
328
+ return entry;
329
+ }
330
+
331
+ async function readAudit({ limit = 50 } = {}) {
332
+ try {
333
+ const raw = await readFile(auditLog(), 'utf8');
334
+ return raw.trim().split(/\n+/).filter(Boolean).slice(-limit).map((line) => {
335
+ try { return JSON.parse(line); } catch { return { event: 'unparsed', line }; }
336
+ });
337
+ } catch {
338
+ return [];
339
+ }
340
+ }
341
+
342
+ async function readJsonBody(req) {
343
+ const chunks = [];
344
+ for await (const chunk of req) chunks.push(chunk);
345
+ const rawBody = Buffer.concat(chunks).toString('utf8') || '{}';
346
+ try {
347
+ return { body: JSON.parse(rawBody) };
348
+ } catch {
349
+ return { error: 'invalid_json' };
350
+ }
351
+ }
352
+
230
353
  /** Forward a control-plane call to the executor admin surface, relaying status + body. */
231
354
  async function proxy(res, method, target) {
232
355
  const r = await fetch(target, { method });
@@ -917,6 +1040,185 @@ async function getApprovals(executorUrl, status) {
917
1040
  };
918
1041
  }
919
1042
 
1043
+ const TERMINAL_MISSION_STATES = new Set(['done', 'completed', 'complete', 'failed', 'aborted', 'canceled', 'cancelled', 'rejected']);
1044
+
1045
+ function normalizeMissionStatus(status) {
1046
+ const value = String(status ?? 'unknown').toLowerCase();
1047
+ if (value === 'done' || value === 'complete') return 'completed';
1048
+ if (value === 'cancelled' || value === 'canceled') return 'aborted';
1049
+ if (['queued', 'running', 'paused', 'completed', 'failed', 'aborted', 'input-required', 'awaiting-approval', 'unknown'].includes(value)) return value;
1050
+ return value;
1051
+ }
1052
+
1053
+ function missionSummary(mission) {
1054
+ const status = normalizeMissionStatus(mission.status);
1055
+ return {
1056
+ id: String(mission.id ?? mission.mission_id ?? mission.missionId ?? ''),
1057
+ title: mission.objective ?? mission.goal ?? mission.task ?? mission.title ?? 'Untitled mission',
1058
+ completion: mission.completion ?? mission.completionCriterion ?? mission.completion_criterion,
1059
+ status,
1060
+ loop: mission.loop ?? mission.iteration ?? mission.currentIteration ?? 0,
1061
+ max_iterations: mission.maxIterations ?? mission.max_iterations ?? mission.maxIterations ?? 0,
1062
+ priority: mission.priority ?? 'normal',
1063
+ mode: mission.mode ?? 'direct',
1064
+ target_agent: mission.targetAgent ?? mission.target_agent,
1065
+ ralph_loop_id: mission.ralphLoopId ?? mission.ralph_loop_id,
1066
+ ralph_pid: mission.ralphPid ?? mission.ralph_pid,
1067
+ started_at: mission.startedAt ?? mission.started_at,
1068
+ completed_at: mission.completedAt ?? mission.completed_at,
1069
+ error: mission.error,
1070
+ terminal: TERMINAL_MISSION_STATES.has(status),
1071
+ };
1072
+ }
1073
+
1074
+ async function readMcAudit(sessionId) {
1075
+ const logPath = join(mcSessionsDir(), sessionId, 'log.jsonl');
1076
+ try {
1077
+ const raw = await readFile(logPath, 'utf8');
1078
+ return raw.trim().split(/\n+/).filter(Boolean).map((line) => {
1079
+ try { return JSON.parse(line); } catch { return { event: 'unparsed', line }; }
1080
+ });
1081
+ } catch {
1082
+ return [];
1083
+ }
1084
+ }
1085
+
1086
+ async function readMcSessions() {
1087
+ let entries = [];
1088
+ const sessionsDir = mcSessionsDir();
1089
+ try { entries = await readdir(sessionsDir, { withFileTypes: true }); } catch { return []; }
1090
+ const sessions = [];
1091
+ for (const entry of entries) {
1092
+ if (!entry.isDirectory()) continue;
1093
+ try {
1094
+ const raw = await readFile(join(sessionsDir, entry.name, 'session.json'), 'utf8');
1095
+ const session = JSON.parse(raw);
1096
+ const audit = await readMcAudit(entry.name);
1097
+ sessions.push({
1098
+ id: String(session.id ?? entry.name),
1099
+ name: session.name ?? entry.name,
1100
+ state: session.state ?? 'unknown',
1101
+ source: 'aiwg-mc',
1102
+ created_at: session.createdAt ?? session.created_at,
1103
+ updated_at: session.updatedAt ?? session.updated_at,
1104
+ max_missions: session.maxMissions ?? session.max_missions,
1105
+ audit_count: audit.length,
1106
+ audit_tail: audit.slice(-8),
1107
+ missions: (session.missions ?? []).map((m) => ({ ...missionSummary(m), session_id: session.id ?? entry.name, source: 'aiwg-mc' })),
1108
+ });
1109
+ } catch {
1110
+ // Ignore malformed or half-written sessions; the next refresh will retry.
1111
+ }
1112
+ }
1113
+ sessions.sort((a, b) => String(b.updated_at ?? '').localeCompare(String(a.updated_at ?? '')));
1114
+ return sessions;
1115
+ }
1116
+
1117
+ async function taskMissionSession(executorUrl) {
1118
+ const running = await getRunning(executorUrl).catch(() => ({ running: [] }));
1119
+ const approvals = await getApprovals(executorUrl, 'pending').catch(() => ({ approvals: [] }));
1120
+ const taskMissions = [
1121
+ ...(running.running ?? []).map((t) => ({
1122
+ id: `${t.instance_id}::${t.task_id}`,
1123
+ session_id: 'executor-live',
1124
+ source: 'executor-task',
1125
+ title: `Task ${t.task_id}`,
1126
+ status: normalizeMissionStatus(t.state),
1127
+ instance_id: t.instance_id,
1128
+ task_id: t.task_id,
1129
+ tenant: t.tenant,
1130
+ runtime_posture: t.runtime_posture,
1131
+ transport: t.transport,
1132
+ terminal: false,
1133
+ })),
1134
+ ...(approvals.approvals ?? []).map((a) => ({
1135
+ id: a.id,
1136
+ session_id: 'executor-live',
1137
+ source: 'hitl-approval',
1138
+ title: a.prompt,
1139
+ status: 'awaiting-approval',
1140
+ instance_id: a.instance_id,
1141
+ task_id: a.task_id,
1142
+ tenant: a.tenant,
1143
+ risk: a.risk,
1144
+ terminal: false,
1145
+ })),
1146
+ ];
1147
+ if (!taskMissions.length) return null;
1148
+ return {
1149
+ id: 'executor-live',
1150
+ name: 'Executor live tasks',
1151
+ state: 'active',
1152
+ source: 'agentic-sandbox',
1153
+ updated_at: new Date().toISOString(),
1154
+ audit_count: 0,
1155
+ audit_tail: [],
1156
+ missions: taskMissions,
1157
+ };
1158
+ }
1159
+
1160
+ async function getMissions(executorUrl) {
1161
+ const sessions = await readMcSessions();
1162
+ const live = await taskMissionSession(executorUrl);
1163
+ if (live) sessions.unshift(live);
1164
+ const missions = sessions.flatMap((s) => s.missions);
1165
+ return {
1166
+ source: 'aiwg-mc + agentic-sandbox',
1167
+ fetched_at: new Date().toISOString(),
1168
+ count: missions.length,
1169
+ sessions,
1170
+ missions,
1171
+ };
1172
+ }
1173
+
1174
+ async function getSessionEventRows(executorUrl, instances) {
1175
+ const rows = [];
1176
+ await Promise.all((instances ?? []).map(async (inst) => {
1177
+ let sessions;
1178
+ try { sessions = (await getSessions(executorUrl, inst.id)).sessions; } catch { return; }
1179
+ for (const session of sessions) {
1180
+ rows.push({
1181
+ id: session.id,
1182
+ instance_id: inst.id,
1183
+ agent_id: session.agent_id,
1184
+ state: session.state ?? session.status ?? session.session_state ?? 'available',
1185
+ role_policy: session.role_policy,
1186
+ backend: session.backend ?? session.session_backend,
1187
+ mode: session.mode ?? session.session_class,
1188
+ });
1189
+ }
1190
+ }));
1191
+ return rows;
1192
+ }
1193
+
1194
+ async function getEventSnapshot(executorUrl) {
1195
+ const inventory = await getInventory(executorUrl).catch(() => ({ instances: [] }));
1196
+ const [running, approvals, missions, sessions] = await Promise.all([
1197
+ getRunning(executorUrl).catch(() => ({ running: [] })),
1198
+ getApprovals(executorUrl, 'pending').catch(() => ({ approvals: [] })),
1199
+ getMissions(executorUrl).catch(() => ({ missions: [] })),
1200
+ getSessionEventRows(executorUrl, inventory.instances).catch(() => []),
1201
+ ]);
1202
+ const ts = new Date().toISOString();
1203
+ const events = [];
1204
+ for (const inst of inventory.instances ?? []) {
1205
+ events.push({ id: `instance:${inst.id}`, type: 'inventory.instance', source: 'agentic-sandbox', subject: inst.id, state: inst.state, ts, ref: { instance_id: inst.id } });
1206
+ }
1207
+ for (const task of running.running ?? []) {
1208
+ events.push({ id: `task:${task.instance_id}:${task.task_id}`, type: 'task.lifecycle', source: 'a2a', subject: task.task_id, state: task.state, ts, ref: { instance_id: task.instance_id, task_id: task.task_id } });
1209
+ }
1210
+ for (const approval of approvals.approvals ?? []) {
1211
+ events.push({ id: `approval:${approval.id}`, type: 'hitl.approval', source: 'a2a', subject: approval.task_id ?? approval.id, state: approval.status, severity: approval.risk, ts, ref: { instance_id: approval.instance_id, approval_id: approval.id } });
1212
+ }
1213
+ for (const session of sessions ?? []) {
1214
+ events.push({ id: `session:${session.instance_id}:${session.id}`, type: 'session.lifecycle', source: 'pty-session', subject: session.id, state: session.state, ts, ref: { instance_id: session.instance_id, session_id: session.id, agent_id: session.agent_id, backend: session.backend, mode: session.mode, role_policy: session.role_policy } });
1215
+ }
1216
+ for (const mission of missions.missions ?? []) {
1217
+ events.push({ id: `mission:${mission.id}`, type: 'mission.lifecycle', source: mission.source ?? 'aiwg-mc', subject: mission.id, state: mission.status, ts, ref: { session_id: mission.session_id, mission_id: mission.id, ralph_loop_id: mission.ralph_loop_id } });
1218
+ }
1219
+ return { source: 'cockpit.unified-event-model/v1', fetched_at: ts, count: events.length, events };
1220
+ }
1221
+
920
1222
  async function respondApproval(executorUrl, approvalId, decision) {
921
1223
  if (!['approve', 'deny'].includes(decision)) return { status: 400, body: { error: 'decision must be approve|deny' } };
922
1224
  const [instanceId, taskId] = String(approvalId).split('::');
@@ -1097,18 +1399,35 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1097
1399
  if (url.pathname === '/api/inventory') return json(res, 200, await getInventory(upstreamUrl));
1098
1400
  if (url.pathname === '/api/executor/capabilities') return json(res, 200, await getExecutorCapabilities(upstreamUrl));
1099
1401
  if (url.pathname === '/api/running') return json(res, 200, await getRunning(upstreamUrl));
1402
+ if (url.pathname === '/api/missions') return json(res, 200, await getMissions(upstreamUrl));
1403
+ if (url.pathname === '/api/events/snapshot') return json(res, 200, await getEventSnapshot(upstreamUrl));
1100
1404
  if (url.pathname === '/api/loadouts') return json(res, 200, await getLoadouts(upstreamUrl));
1405
+ if (url.pathname === '/api/index/status' && req.method === 'GET') return json(res, 200, await getIndexStatus());
1406
+ if (url.pathname === '/api/index/query' && req.method === 'GET') {
1407
+ const result = await queryIndex(url);
1408
+ return json(res, result.status, result.body);
1409
+ }
1410
+ if (url.pathname === '/api/index/rebuild' && req.method === 'POST') {
1411
+ const result = await rebuildIndex(req);
1412
+ return json(res, result.status, result.body);
1413
+ }
1414
+ if (url.pathname === '/api/audit' && req.method === 'GET') {
1415
+ const limit = Math.max(1, Math.min(200, Number(url.searchParams.get('limit') || 50)));
1416
+ return json(res, 200, { source: 'cockpit-bridge-audit/v1', audit: await readAudit({ limit }) });
1417
+ }
1418
+ if (url.pathname === '/api/audit/intent' && req.method === 'POST') {
1419
+ const parsed = await readJsonBody(req);
1420
+ if (parsed.error) return json(res, 400, { error: parsed.error });
1421
+ const body = parsed.body || {};
1422
+ const event = typeof body.event === 'string' && body.event.trim() ? body.event.trim() : 'operator.intent';
1423
+ const entry = await appendAudit(event, { detail: body.detail ?? body });
1424
+ return json(res, 201, entry);
1425
+ }
1101
1426
  let m;
1102
1427
  if (url.pathname === '/api/instances' && req.method === 'POST') {
1103
- const chunks = [];
1104
- for await (const chunk of req) chunks.push(chunk);
1105
- const rawBody = Buffer.concat(chunks).toString('utf8') || '{}';
1106
- let payload;
1107
- try {
1108
- payload = JSON.parse(rawBody);
1109
- } catch {
1110
- return json(res, 400, { error: 'invalid_json' });
1111
- }
1428
+ const parsed = await readJsonBody(req);
1429
+ if (parsed.error) return json(res, 400, { error: parsed.error });
1430
+ const payload = parsed.body;
1112
1431
  if (payload.runtime === 'qemu') {
1113
1432
  const sshKey = expandHome(String(payload.ssh_key ?? payload.sshKey ?? '').trim()) || defaultSshPublicKey();
1114
1433
  if (!sshKey) {
@@ -1128,14 +1447,22 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1128
1447
  payload.ssh_key = sshKey;
1129
1448
  }
1130
1449
  const requestBody = JSON.stringify(payload);
1131
- return proxyFirst(res, [
1450
+ const before = await appendAudit('instance.launch.requested', {
1451
+ runtime: payload.runtime,
1452
+ name: payload.name,
1453
+ loadout: payload.loadout,
1454
+ start: payload.start,
1455
+ });
1456
+ const result = await fetchJsonFirst([
1132
1457
  {
1133
1458
  target: `${upstreamUrl}/api/v2/admin/instances`,
1134
1459
  method: 'POST',
1135
1460
  headers: { 'content-type': 'application/json' },
1136
1461
  body: requestBody,
1137
1462
  },
1138
- ]);
1463
+ ]).catch((err) => ({ status: 502, body: { error: 'bridge_upstream_error', message: String(err?.message ?? err) } }));
1464
+ await appendAudit('instance.launch.result', { request_ts: before.ts, status: result.status, result: result.body });
1465
+ return json(res, result.status, result.body);
1139
1466
  }
1140
1467
  if ((m = url.pathname.match(/^\/api\/operations\/([^/]+)$/)) && req.method === 'GET') {
1141
1468
  return proxyFirst(res, [
@@ -1170,7 +1497,10 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1170
1497
  args.push('--type', types.join(','));
1171
1498
  }
1172
1499
  const data = JSON.parse(await runAiwg(args));
1173
- data.results = (data.results || []).map((r) => ({ ...r, name: deriveName(r.path) }));
1500
+ data.results = (data.results || []).map((r) => ({
1501
+ ...r,
1502
+ name: r.name || (r.path ? deriveName(r.path) : ''),
1503
+ }));
1174
1504
  return json(res, 200, data);
1175
1505
  }
1176
1506
  if (url.pathname === '/api/show') {
@@ -1279,27 +1609,37 @@ export function createBridge({ executorUrl = EXECUTOR_URL, allowMockExecutor = A
1279
1609
  }
1280
1610
  // Same as the list path (#1671): the attach segment must be the instance
1281
1611
  // id the executor's pty-ws route accepts, not the resolved agent name.
1612
+ await appendAudit('session.start.requested', { instance_id: id, mode: mode || 'managed', backend: backend || 'tmux', loadout, status, session_id: sessionId });
1282
1613
  return json(res, status, { ...body, id: sessionId, attach_url: attachUrl ?? `${wsBase}/agents/${encodeURIComponent(id)}/sessions/${encodeURIComponent(sessionId)}/attach` });
1283
1614
  }
1284
1615
 
1285
1616
  // --- management surface (UC-012): lifecycle + task cancel ---
1286
- if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/(start|stop)$/)) && req.method === 'POST')
1287
- return proxyFirst(res, [
1617
+ if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)\/(start|stop)$/)) && req.method === 'POST') {
1618
+ const result = await fetchJsonFirst([
1288
1619
  `${upstreamUrl}/admin/instances/${encodeURIComponent(m[1])}/${m[2]}`,
1289
1620
  `${upstreamUrl}/api/v2/admin/instances/${encodeURIComponent(m[1])}/${m[2]}`,
1290
- ], { method: 'POST' });
1621
+ ], { method: 'POST' }).catch((err) => ({ status: 502, body: { error: 'bridge_upstream_error', message: String(err?.message ?? err) } }));
1622
+ await appendAudit('instance.lifecycle.requested', { instance_id: decodeURIComponent(m[1]), action: m[2], status: result.status, result: result.body });
1623
+ return json(res, result.status, result.body);
1624
+ }
1291
1625
  if ((m = url.pathname.match(/^\/api\/instances\/([^/]+)$/)) && req.method === 'DELETE') {
1292
1626
  const { status, body } = await destroyInstance(upstreamUrl, decodeURIComponent(m[1]));
1627
+ await appendAudit('instance.destroy.requested', { instance_id: decodeURIComponent(m[1]), status, result: body });
1293
1628
  return json(res, status, body);
1294
1629
  }
1295
- if ((m = url.pathname.match(/^\/api\/tasks\/([^/]+)\/([^/]+)\/cancel$/)) && req.method === 'POST')
1630
+ if ((m = url.pathname.match(/^\/api\/tasks\/([^/]+)\/([^/]+)\/cancel$/)) && req.method === 'POST') {
1631
+ await appendAudit('task.cancel.requested', { instance_id: decodeURIComponent(m[1]), task_id: decodeURIComponent(m[2]) });
1296
1632
  return proxy(res, 'POST', `${upstreamUrl}/agents/${encodeURIComponent(m[1])}/tasks/${encodeURIComponent(m[2])}:cancel`);
1633
+ }
1297
1634
 
1298
1635
  // --- approval inbox (UC-009) + cost (UC-010) ---
1299
1636
  if (url.pathname === '/api/approvals' && req.method === 'GET')
1300
1637
  return json(res, 200, await getApprovals(upstreamUrl, url.searchParams.get('status') || 'pending'));
1301
1638
  if ((m = url.pathname.match(/^\/api\/approvals\/([^/]+)$/)) && req.method === 'POST') {
1302
- const { status, body } = await respondApproval(upstreamUrl, decodeURIComponent(m[1]), url.searchParams.get('decision') || '');
1639
+ const approvalId = decodeURIComponent(m[1]);
1640
+ const decision = url.searchParams.get('decision') || '';
1641
+ const { status, body } = await respondApproval(upstreamUrl, approvalId, decision);
1642
+ await appendAudit('approval.response.submitted', { approval_id: approvalId, decision, status, result: body });
1303
1643
  return json(res, status, body);
1304
1644
  }
1305
1645
  if (url.pathname === '/api/cost' && req.method === 'GET')
@@ -1363,8 +1703,13 @@ if (import.meta.url === `file://${process.argv[1]}`) {
1363
1703
  await ensureExecutor(EXECUTOR_URL);
1364
1704
  const server = createBridge();
1365
1705
  server.listen(port, '127.0.0.1', async () => {
1366
- const file = await writeRuntimeToken({ token: server.cockpitToken, port, pid: process.pid });
1367
- console.log(`[cockpit-bridge] http://127.0.0.1:${port} (executor ${EXECUTOR_URL})`);
1368
- console.log(` token written ${file} (mode 600) — open the URL in a browser or attach a shell`);
1706
+ try {
1707
+ const file = await writeRuntimeToken({ token: server.cockpitToken, port, pid: process.pid });
1708
+ console.log(`[cockpit-bridge] http://127.0.0.1:${port} (executor ${EXECUTOR_URL})`);
1709
+ console.log(` token written ${file} (mode 600) — open the URL in a browser or attach a shell`);
1710
+ } catch (err) {
1711
+ console.error(`[cockpit-bridge] failed to persist runtime token: ${String(err?.message ?? err)}`);
1712
+ server.close(() => process.exit(1));
1713
+ }
1369
1714
  });
1370
1715
  }
@@ -1,6 +1,7 @@
1
1
  // End-to-end data-path smoke: executor fixture (admin) -> Bridge (/api/inventory) -> served screen.
2
2
  // Self-contained (own ports); no deps. Exits non-zero on failure.
3
3
  import assert from 'node:assert/strict';
4
+ import { fileURLToPath } from 'node:url';
4
5
  import { createExecutor } from '../../mock-executor/src/server.mjs';
5
6
  import { createBridge } from './server.mjs';
6
7
 
@@ -74,9 +75,13 @@ try {
74
75
  assert.match(shown.body, /name:\s*flow-deploy-to-production/, 'show returns the skill body');
75
76
  assert.equal((await f("/api/capabilities")).status, 400, 'capabilities requires q');
76
77
  // show by discovered PATH — deterministic, sidesteps ambiguous same-named artifacts (#1643)
77
- const shownByPath = await (await f(`/api/show?path=${encodeURIComponent(hit.path)}`)).json();
78
- assert.match(shownByPath.body, /name:\s*flow-deploy-to-production/, 'show-by-path returns the body');
79
- assert.equal(shownByPath.path, hit.path, 'show-by-path echoes the resolved path');
78
+ if (hit.path) {
79
+ const shownByPath = await (await f(`/api/show?path=${encodeURIComponent(hit.path)}`)).json();
80
+ assert.match(shownByPath.body, /name:\s*flow-deploy-to-production/, 'show-by-path returns the body');
81
+ assert.equal(shownByPath.path, hit.path, 'show-by-path echoes the resolved path');
82
+ } else {
83
+ assert.ok(hit.id, 'pathless discover result carries a stable id');
84
+ }
80
85
  // a missing artifact is a 4xx, never a 502 (ambiguous/not-found map to operator-correctable input)
81
86
  assert.equal((await f('/api/show?type=agent&name=__definitely_not_a_real_artifact__')).status, 404, 'unknown artifact -> 404 not 502');
82
87
  // a path outside the AIWG corpus is refused (no traversal)
@@ -123,7 +128,8 @@ try {
123
128
 
124
129
  // user asset library: clone a catalog asset into the library, list it, delete it.
125
130
  // (AIWG source is read-only — clone copies into ~/.aiwg/cockpit/library, never the reverse.)
126
- const cloneRes = await f(`/api/library/clone?type=${encodeURIComponent(hit.type)}&name=${encodeURIComponent(hit.name)}&path=${encodeURIComponent(hit.path)}`, { method: 'POST' });
131
+ const libraryPath = hit.path || fileURLToPath(new URL('../../../../agentic/code/frameworks/sdlc-complete/skills/flow-deploy-to-production/SKILL.md', import.meta.url));
132
+ const cloneRes = await f(`/api/library/clone?type=${encodeURIComponent(hit.type)}&name=${encodeURIComponent(hit.name)}&path=${encodeURIComponent(libraryPath)}`, { method: 'POST' });
127
133
  assert.ok([201, 400].includes(cloneRes.status), 'clone returns 201 (new) or 400 (already present)');
128
134
  const lib1 = await (await f('/api/library')).json();
129
135
  assert.ok(lib1.library.some((a) => a.name === hit.name), 'cloned asset appears in the user library');
@@ -2,14 +2,36 @@
2
2
  "$schema": "./contribution.schema.json",
3
3
  "id": "aiwg-core",
4
4
  "version": "1.0.0",
5
- "title": "AIWG Core Actions",
5
+ "title": "AIWG Core Contributions",
6
6
  "contributes": {
7
7
  "actions": [
8
8
  { "id": "audit-issues", "title": "Audit Issues", "icon": "🔍", "group": "issues", "inject": { "command": "/issue-audit", "target": "focused" } },
9
9
  { "id": "address-issues", "title": "Address Issues", "icon": "🛠️", "group": "issues", "inject": { "command": "/address-issues", "target": "focused", "needs_args": true, "args_hint": "issue numbers, space-separated" } },
10
10
  { "id": "doctor", "title": "Doctor", "icon": "🩺", "group": "maintenance", "inject": { "command": "/aiwg-doctor", "target": "focused" } }
11
11
  ],
12
- "screens": [],
12
+ "screens": [
13
+ { "id": "index-live", "title": "Live Index", "source": "cockpit://index/live" },
14
+ { "id": "issue-workbench", "title": "Issue Workbench", "source": "cockpit://issues/workbench" }
15
+ ],
16
+ "workflows": [
17
+ {
18
+ "id": "issue-resolution",
19
+ "title": "Issue Resolution",
20
+ "description": "Audit selected issues, then dispatch the address-issues loop.",
21
+ "steps": [
22
+ { "action": "audit-issues", "label": "Audit" },
23
+ { "action": "address-issues", "label": "Address" }
24
+ ]
25
+ },
26
+ {
27
+ "id": "maintenance-check",
28
+ "title": "Maintenance Check",
29
+ "description": "Run the AIWG doctor through an attached agentic session.",
30
+ "steps": [
31
+ { "action": "doctor", "label": "Doctor" }
32
+ ]
33
+ }
34
+ ],
13
35
  "hooks": []
14
36
  }
15
37
  }