@way_marks/server 0.7.0 → 0.8.0

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.
@@ -43,6 +43,52 @@ const path = __importStar(require("path"));
43
43
  const database_1 = require("../db/database");
44
44
  const engine_1 = require("../policies/engine");
45
45
  const handler_1 = require("../approvals/handler");
46
+ // Import registry for Phase 2 hub navigation
47
+ const registryPath = path.join(process.env.HOME || process.env.USERPROFILE || '', '.waymark', 'registry.json');
48
+ function getRegistryProjects() {
49
+ try {
50
+ if (!fs.existsSync(registryPath))
51
+ return [];
52
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
53
+ return registry.projects || [];
54
+ }
55
+ catch {
56
+ return [];
57
+ }
58
+ }
59
+ // Phase 4: Garbage collection for registry
60
+ function garbageCollectRegistryFile() {
61
+ try {
62
+ if (!fs.existsSync(registryPath))
63
+ return 0;
64
+ const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
65
+ const cutoffTime = new Date();
66
+ cutoffTime.setDate(cutoffTime.getDate() - 7);
67
+ let removed = 0;
68
+ const projects = registry.projects || {};
69
+ const idsToRemove = [];
70
+ for (const [id, entry] of Object.entries(projects)) {
71
+ if (entry.status === 'stopped' && entry.stoppedAt) {
72
+ const stoppedTime = new Date(entry.stoppedAt);
73
+ if (stoppedTime < cutoffTime) {
74
+ idsToRemove.push(id);
75
+ }
76
+ }
77
+ }
78
+ for (const id of idsToRemove) {
79
+ delete registry.projects[id];
80
+ removed++;
81
+ }
82
+ if (removed > 0) {
83
+ registry.lastUpdated = new Date().toISOString();
84
+ fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
85
+ }
86
+ return removed;
87
+ }
88
+ catch {
89
+ return 0;
90
+ }
91
+ }
46
92
  const app = (0, express_1.default)();
47
93
  const PORT = parseInt(process.env.WAYMARK_PORT || '3001', 10);
48
94
  app.use(express_1.default.json());
@@ -63,6 +109,78 @@ app.get('/api/actions', (req, res) => {
63
109
  res.status(500).json({ error: err.message });
64
110
  }
65
111
  });
112
+ // Phase 3: GET /api/actions/paginated — paginated actions with filtering
113
+ app.get('/api/actions/paginated', (req, res) => {
114
+ try {
115
+ const filter = {
116
+ status: req.query.status,
117
+ tool_name: req.query.tool_name,
118
+ search: req.query.search,
119
+ page: parseInt(req.query.page) || 1,
120
+ limit: parseInt(req.query.limit) || 50,
121
+ };
122
+ const result = (0, database_1.getActionsWithFiltering)(filter);
123
+ res.json(result);
124
+ }
125
+ catch (err) {
126
+ res.status(500).json({ error: err.message });
127
+ }
128
+ });
129
+ // Phase 3: GET /api/stats — summary statistics
130
+ app.get('/api/stats', (req, res) => {
131
+ try {
132
+ const stats = (0, database_1.getSummaryStats)();
133
+ res.json(stats);
134
+ }
135
+ catch (err) {
136
+ res.status(500).json({ error: err.message });
137
+ }
138
+ });
139
+ // Phase 5B: POST /api/cli-action — log GitHub Copilot CLI command
140
+ // Called by copilot-cli-wrapper.sh when user runs: copilot [command]
141
+ app.post('/api/cli-action', (req, res) => {
142
+ try {
143
+ const { command, args, cwd, timestamp, shell, user } = req.body;
144
+ if (!command) {
145
+ return res.status(400).json({ error: 'Missing command field' });
146
+ }
147
+ // Generate action ID and session ID
148
+ const action_id = `cli-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
149
+ const session_id = 'cli-session'; // All CLI actions share same session
150
+ // Log CLI action using insertAction function
151
+ (0, database_1.insertAction)({
152
+ action_id,
153
+ session_id,
154
+ tool_name: 'copilot',
155
+ input_payload: JSON.stringify({ command, args, cwd, shell, user }),
156
+ status: 'executed', // CLI always executes (no approval flow)
157
+ event_type: 'execution',
158
+ observation_context: `CLI: ${command} ${args}`,
159
+ request_source: 'cli',
160
+ source: 'cli', // Distinguish from MCP
161
+ });
162
+ res.json({
163
+ success: true,
164
+ action_id,
165
+ message: `Logged Copilot CLI: ${command} ${args}`
166
+ });
167
+ }
168
+ catch (err) {
169
+ console.error('Error logging CLI action:', err);
170
+ res.status(500).json({ error: err.message });
171
+ }
172
+ });
173
+ // Phase 3: POST /api/maintenance/archive — archive old actions
174
+ app.post('/api/maintenance/archive', (req, res) => {
175
+ try {
176
+ const daysOld = parseInt(req.body?.daysOld) || 30;
177
+ const archived = (0, database_1.archiveOldActions)(daysOld);
178
+ res.json({ success: true, archived, message: `Archived ${archived} actions older than ${daysOld} days` });
179
+ }
180
+ catch (err) {
181
+ res.status(500).json({ error: err.message });
182
+ }
183
+ });
66
184
  // POST /api/actions/:action_id/approve
67
185
  app.post('/api/actions/:action_id/approve', async (req, res) => {
68
186
  try {
@@ -222,6 +340,26 @@ app.get('/api/project', (req, res) => {
222
340
  res.status(500).json({ error: err.message });
223
341
  }
224
342
  });
343
+ // GET /api/hub/projects — Phase 2: returns all registered projects (optional hub feature)
344
+ app.get('/api/hub/projects', (req, res) => {
345
+ try {
346
+ const projects = getRegistryProjects();
347
+ res.json(projects);
348
+ }
349
+ catch (err) {
350
+ res.status(500).json({ error: err.message });
351
+ }
352
+ });
353
+ // Phase 4: POST /api/registry/cleanup — garbage collect stale entries
354
+ app.post('/api/registry/cleanup', (req, res) => {
355
+ try {
356
+ const removed = garbageCollectRegistryFile();
357
+ res.json({ success: true, removed, message: `Garbage collected ${removed} stale entries` });
358
+ }
359
+ catch (err) {
360
+ res.status(500).json({ error: err.message });
361
+ }
362
+ });
225
363
  // Fallback: serve UI for any unmatched route
226
364
  app.get('*', (req, res) => {
227
365
  res.sendFile(path.join(UI_DIR, 'index.html'));
@@ -45,6 +45,10 @@ exports.getSessions = getSessions;
45
45
  exports.approveAction = approveAction;
46
46
  exports.rejectAction = rejectAction;
47
47
  exports.getPendingCount = getPendingCount;
48
+ exports.getActionsWithFiltering = getActionsWithFiltering;
49
+ exports.archiveOldActions = archiveOldActions;
50
+ exports.getArchivedActionsWithFiltering = getArchivedActionsWithFiltering;
51
+ exports.getSummaryStats = getSummaryStats;
48
52
  const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
49
53
  const fs = __importStar(require("fs"));
50
54
  const path = __importStar(require("path"));
@@ -116,6 +120,24 @@ try {
116
120
  db.exec('ALTER TABLE action_log ADD COLUMN rejected_reason TEXT');
117
121
  }
118
122
  catch { }
123
+ // Migrate v4: Phase 1 — plan mode logging visibility
124
+ try {
125
+ db.exec("ALTER TABLE action_log ADD COLUMN event_type TEXT NOT NULL DEFAULT 'execution'");
126
+ }
127
+ catch { }
128
+ try {
129
+ db.exec('ALTER TABLE action_log ADD COLUMN observation_context TEXT');
130
+ }
131
+ catch { }
132
+ try {
133
+ db.exec('ALTER TABLE action_log ADD COLUMN request_source TEXT DEFAULT \'direct\'');
134
+ }
135
+ catch { }
136
+ // Migrate v5: Phase 5B — CLI action logging (GitHub Copilot CLI wrapper)
137
+ try {
138
+ db.exec("ALTER TABLE action_log ADD COLUMN source TEXT DEFAULT 'mcp'");
139
+ }
140
+ catch { }
119
141
  // Indexes for query performance
120
142
  try {
121
143
  db.exec('CREATE INDEX IF NOT EXISTS idx_action_id ON action_log(action_id)');
@@ -129,9 +151,62 @@ try {
129
151
  db.exec('CREATE INDEX IF NOT EXISTS idx_session_id ON action_log(session_id)');
130
152
  }
131
153
  catch { }
154
+ // Phase 3: Add indexes for pagination and filtering
155
+ try {
156
+ db.exec('CREATE INDEX IF NOT EXISTS idx_tool_name ON action_log(tool_name)');
157
+ }
158
+ catch { }
159
+ try {
160
+ db.exec('CREATE INDEX IF NOT EXISTS idx_created_at ON action_log(created_at DESC)');
161
+ }
162
+ catch { }
163
+ try {
164
+ db.exec('CREATE INDEX IF NOT EXISTS idx_status_created ON action_log(status, created_at DESC)');
165
+ }
166
+ catch { }
167
+ // Phase 3: Create archive table (same schema as action_log)
168
+ db.exec(`
169
+ CREATE TABLE IF NOT EXISTS action_archive (
170
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
171
+ action_id TEXT UNIQUE NOT NULL,
172
+ session_id TEXT NOT NULL,
173
+ tool_name TEXT NOT NULL,
174
+ target_path TEXT,
175
+ input_payload TEXT NOT NULL,
176
+ before_snapshot TEXT,
177
+ after_snapshot TEXT,
178
+ status TEXT NOT NULL DEFAULT 'pending',
179
+ error_message TEXT,
180
+ stdout TEXT,
181
+ stderr TEXT,
182
+ rolled_back INTEGER NOT NULL DEFAULT 0,
183
+ rolled_back_at TEXT,
184
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
185
+ decision TEXT NOT NULL DEFAULT 'allow',
186
+ policy_reason TEXT,
187
+ matched_rule TEXT,
188
+ approved_at TEXT,
189
+ approved_by TEXT,
190
+ rejected_at TEXT,
191
+ rejected_reason TEXT,
192
+ event_type TEXT NOT NULL DEFAULT 'execution',
193
+ observation_context TEXT,
194
+ request_source TEXT DEFAULT 'direct',
195
+ source TEXT DEFAULT 'mcp'
196
+ )
197
+ `);
198
+ // Phase 3: Add indexes on archive table
199
+ try {
200
+ db.exec('CREATE INDEX IF NOT EXISTS idx_archive_action_id ON action_archive(action_id)');
201
+ }
202
+ catch { }
203
+ try {
204
+ db.exec('CREATE INDEX IF NOT EXISTS idx_archive_created_at ON action_archive(created_at DESC)');
205
+ }
206
+ catch { }
132
207
  const insertStmt = db.prepare(`
133
- INSERT INTO action_log (action_id, session_id, tool_name, target_path, input_payload, before_snapshot, status, decision, policy_reason, matched_rule)
134
- VALUES (@action_id, @session_id, @tool_name, @target_path, @input_payload, @before_snapshot, @status, @decision, @policy_reason, @matched_rule)
208
+ INSERT INTO action_log (action_id, session_id, tool_name, target_path, input_payload, before_snapshot, status, decision, policy_reason, matched_rule, event_type, observation_context, request_source, source)
209
+ VALUES (@action_id, @session_id, @tool_name, @target_path, @input_payload, @before_snapshot, @status, @decision, @policy_reason, @matched_rule, @event_type, @observation_context, @request_source, @source)
135
210
  `);
136
211
  const updateStmt = db.prepare(`
137
212
  UPDATE action_log
@@ -154,6 +229,10 @@ function insertAction(params) {
154
229
  decision: params.decision ?? 'pending',
155
230
  policy_reason: params.policy_reason ?? null,
156
231
  matched_rule: params.matched_rule ?? null,
232
+ event_type: params.event_type ?? 'execution',
233
+ observation_context: params.observation_context ?? null,
234
+ request_source: params.request_source ?? 'direct',
235
+ source: params.source ?? 'mcp',
157
236
  });
158
237
  }
159
238
  function updateAction(action_id, params) {
@@ -220,4 +299,146 @@ function getPendingCount() {
220
299
  `).get();
221
300
  return row.count;
222
301
  }
302
+ function getActionsWithFiltering(filter = {}) {
303
+ const page = Math.max(1, filter.page ?? 1);
304
+ const limit = Math.min(filter.limit ?? 50, 200); // max 200 per page
305
+ const offset = (page - 1) * limit;
306
+ let where = '1=1';
307
+ const params = {};
308
+ if (filter.status) {
309
+ where += ' AND status = @status';
310
+ params.status = filter.status;
311
+ }
312
+ if (filter.tool_name) {
313
+ where += ' AND tool_name = @tool_name';
314
+ params.tool_name = filter.tool_name;
315
+ }
316
+ if (filter.search) {
317
+ where += ' AND (action_id LIKE @search OR target_path LIKE @search)';
318
+ params.search = `%${filter.search}%`;
319
+ }
320
+ // Get total count
321
+ const countRow = db.prepare(`
322
+ SELECT COUNT(*) as count FROM action_log WHERE ${where}
323
+ `).get(params);
324
+ const totalCount = countRow.count;
325
+ // Get paginated results
326
+ const actions = db.prepare(`
327
+ SELECT * FROM action_log WHERE ${where}
328
+ ORDER BY created_at DESC
329
+ LIMIT @limit OFFSET @offset
330
+ `).all({ ...params, limit, offset });
331
+ return {
332
+ actions,
333
+ totalCount,
334
+ page,
335
+ limit,
336
+ hasMore: offset + actions.length < totalCount,
337
+ };
338
+ }
339
+ function archiveOldActions(daysOld = 30) {
340
+ const cutoffDate = new Date();
341
+ cutoffDate.setDate(cutoffDate.getDate() - daysOld);
342
+ const cutoffStr = cutoffDate.toISOString();
343
+ // Copy old actions to archive
344
+ const result = db.prepare(`
345
+ INSERT OR IGNORE INTO action_archive
346
+ SELECT * FROM action_log WHERE created_at < @cutoff
347
+ `).run({ cutoff: cutoffStr });
348
+ // Delete from main table (but keep recent entries)
349
+ const deleteStmt = db.prepare(`
350
+ DELETE FROM action_log WHERE created_at < @cutoff
351
+ AND id NOT IN (
352
+ SELECT id FROM action_log ORDER BY created_at DESC LIMIT 1000
353
+ )
354
+ `);
355
+ deleteStmt.run({ cutoff: cutoffStr });
356
+ return result.changes;
357
+ }
358
+ function getArchivedActionsWithFiltering(filter = {}) {
359
+ const page = Math.max(1, filter.page ?? 1);
360
+ const limit = Math.min(filter.limit ?? 50, 200);
361
+ const offset = (page - 1) * limit;
362
+ let where = '1=1';
363
+ const params = {};
364
+ if (filter.status) {
365
+ where += ' AND status = @status';
366
+ params.status = filter.status;
367
+ }
368
+ if (filter.tool_name) {
369
+ where += ' AND tool_name = @tool_name';
370
+ params.tool_name = filter.tool_name;
371
+ }
372
+ if (filter.search) {
373
+ where += ' AND (action_id LIKE @search OR target_path LIKE @search)';
374
+ params.search = `%${filter.search}%`;
375
+ }
376
+ const countRow = db.prepare(`
377
+ SELECT COUNT(*) as count FROM action_archive WHERE ${where}
378
+ `).get(params);
379
+ const totalCount = countRow.count;
380
+ const actions = db.prepare(`
381
+ SELECT * FROM action_archive WHERE ${where}
382
+ ORDER BY created_at DESC
383
+ LIMIT @limit OFFSET @offset
384
+ `).all({ ...params, limit, offset });
385
+ return {
386
+ actions,
387
+ totalCount,
388
+ page,
389
+ limit,
390
+ hasMore: offset + actions.length < totalCount,
391
+ };
392
+ }
393
+ function getSummaryStats() {
394
+ const now = new Date();
395
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
396
+ const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
397
+ const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString();
398
+ const total = db.prepare(`
399
+ SELECT COUNT(*) as count FROM action_log
400
+ `).get();
401
+ const pending = db.prepare(`
402
+ SELECT COUNT(*) as count FROM action_log WHERE status = 'pending'
403
+ `).get();
404
+ const approved = db.prepare(`
405
+ SELECT COUNT(*) as count FROM action_log WHERE status = 'success' OR decision = 'allow'
406
+ `).get();
407
+ const rejected = db.prepare(`
408
+ SELECT COUNT(*) as count FROM action_log WHERE status = 'rejected' OR decision = 'rejected'
409
+ `).get();
410
+ const todayCount = db.prepare(`
411
+ SELECT COUNT(*) as count FROM action_log WHERE created_at >= @today
412
+ `).get({ today });
413
+ const weekCount = db.prepare(`
414
+ SELECT COUNT(*) as count FROM action_log WHERE created_at >= @weekAgo
415
+ `).get({ weekAgo });
416
+ const monthCount = db.prepare(`
417
+ SELECT COUNT(*) as count FROM action_log WHERE created_at >= @monthAgo
418
+ `).get({ monthAgo });
419
+ const topTools = db.prepare(`
420
+ SELECT tool_name as tool, COUNT(*) as count FROM action_log
421
+ GROUP BY tool_name
422
+ ORDER BY count DESC
423
+ LIMIT 5
424
+ `).all();
425
+ const topPaths = db.prepare(`
426
+ SELECT target_path as path, COUNT(*) as count FROM action_log
427
+ WHERE target_path IS NOT NULL
428
+ GROUP BY target_path
429
+ ORDER BY count DESC
430
+ LIMIT 5
431
+ `).all();
432
+ return {
433
+ totalActions: total.count,
434
+ pendingCount: pending.count,
435
+ approvedCount: approved.count,
436
+ rejectedCount: rejected.count,
437
+ todayCount: todayCount.count,
438
+ thisWeekCount: weekCount.count,
439
+ thisMonthCount: monthCount.count,
440
+ topTools,
441
+ topPaths,
442
+ };
443
+ }
223
444
  exports.default = db;
@@ -125,6 +125,8 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
125
125
  if (name === 'write_file') {
126
126
  const { path: filePath, content } = args;
127
127
  const resolvedPath = path.resolve(filePath);
128
+ const requestSource = 'direct';
129
+ const observationContext = null;
128
130
  // Policy check before execution
129
131
  const config = (0, engine_1.loadConfig)();
130
132
  const policyResult = (0, engine_1.checkFileAction)(resolvedPath, 'write', config);
@@ -133,6 +135,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
133
135
  action_id, session_id: SESSION_ID, tool_name: 'write_file',
134
136
  target_path: resolvedPath, input_payload, status: 'blocked',
135
137
  decision: 'block', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
138
+ event_type: 'execution', request_source: requestSource, observation_context: observationContext,
136
139
  });
137
140
  throw new Error(`Waymark blocked: ${policyResult.reason} [rule: ${policyResult.matchedRule}]`);
138
141
  }
@@ -141,6 +144,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
141
144
  action_id, session_id: SESSION_ID, tool_name: 'write_file',
142
145
  target_path: resolvedPath, input_payload, status: 'pending',
143
146
  decision: 'pending', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
147
+ event_type: 'execution', request_source: requestSource, observation_context: observationContext,
144
148
  });
145
149
  // Fire-and-forget Slack notification (no console.log — MCP uses stdio)
146
150
  (0, slack_1.notifyPendingAction)({
@@ -152,6 +156,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
152
156
  created_at: new Date().toISOString(),
153
157
  decision: 'pending', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
154
158
  approved_at: null, approved_by: null, rejected_at: null, rejected_reason: null,
159
+ event_type: 'execution', observation_context: null, request_source: 'direct',
155
160
  id: 0,
156
161
  }).catch(() => { });
157
162
  return {
@@ -180,6 +185,9 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
180
185
  decision: policyResult.decision,
181
186
  policy_reason: policyResult.reason,
182
187
  matched_rule: policyResult.matchedRule ?? null,
188
+ event_type: 'execution',
189
+ request_source: requestSource,
190
+ observation_context: observationContext,
183
191
  });
184
192
  try {
185
193
  // Ensure directory exists
@@ -204,6 +212,10 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
204
212
  else if (name === 'read_file') {
205
213
  const { path: filePath } = args;
206
214
  const resolvedPath = path.resolve(filePath);
215
+ // Determine request source for observability (plan mode vs direct execution)
216
+ // We track "observation" for planning-phase reads if patterns suggest it
217
+ const requestSource = 'direct';
218
+ const observationContext = null;
207
219
  // Policy check before execution
208
220
  const config = (0, engine_1.loadConfig)();
209
221
  const policyResult = (0, engine_1.checkFileAction)(resolvedPath, 'read', config);
@@ -212,6 +224,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
212
224
  action_id, session_id: SESSION_ID, tool_name: 'read_file',
213
225
  target_path: resolvedPath, input_payload, status: 'blocked',
214
226
  decision: 'block', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
227
+ event_type: 'execution', request_source: requestSource, observation_context: observationContext,
215
228
  });
216
229
  throw new Error(`Waymark blocked: ${policyResult.reason} [rule: ${policyResult.matchedRule}]`);
217
230
  }
@@ -220,6 +233,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
220
233
  action_id, session_id: SESSION_ID, tool_name: 'read_file',
221
234
  target_path: resolvedPath, input_payload, status: 'pending',
222
235
  decision: 'pending', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
236
+ event_type: 'execution', request_source: requestSource, observation_context: observationContext,
223
237
  });
224
238
  (0, slack_1.notifyPendingAction)({
225
239
  action_id, session_id: SESSION_ID, tool_name: 'read_file',
@@ -230,6 +244,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
230
244
  created_at: new Date().toISOString(),
231
245
  decision: 'pending', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
232
246
  approved_at: null, approved_by: null, rejected_at: null, rejected_reason: null,
247
+ event_type: 'execution', observation_context: null, request_source: 'direct',
233
248
  id: 0,
234
249
  }).catch(() => { });
235
250
  return {
@@ -249,6 +264,9 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
249
264
  decision: policyResult.decision,
250
265
  policy_reason: policyResult.reason,
251
266
  matched_rule: policyResult.matchedRule ?? null,
267
+ event_type: 'execution',
268
+ request_source: requestSource,
269
+ observation_context: observationContext,
252
270
  });
253
271
  try {
254
272
  const content = fs.readFileSync(resolvedPath, 'utf8');
@@ -270,6 +288,8 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
270
288
  }
271
289
  else if (name === 'bash') {
272
290
  const { command } = args;
291
+ const requestSource = 'direct';
292
+ const observationContext = null;
273
293
  // Policy check before execution
274
294
  const config = (0, engine_1.loadConfig)();
275
295
  const policyResult = (0, engine_1.checkBashAction)(command, config);
@@ -278,6 +298,7 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
278
298
  action_id, session_id: SESSION_ID, tool_name: 'bash',
279
299
  target_path: null, input_payload, status: 'blocked',
280
300
  decision: 'block', policy_reason: policyResult.reason, matched_rule: policyResult.matchedRule,
301
+ event_type: 'execution', request_source: requestSource, observation_context: observationContext,
281
302
  });
282
303
  throw new Error(`Waymark blocked command: ${policyResult.reason} [rule: ${policyResult.matchedRule}]`);
283
304
  }
@@ -291,6 +312,9 @@ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request) => {
291
312
  decision: policyResult.decision,
292
313
  policy_reason: policyResult.reason,
293
314
  matched_rule: policyResult.matchedRule ?? null,
315
+ event_type: 'execution',
316
+ request_source: requestSource,
317
+ observation_context: observationContext,
294
318
  });
295
319
  // Bug 1 + Bug 2: use spawnSync for clean stdout/stderr separation and USER_PATH
296
320
  const result = (0, child_process_1.spawnSync)('sh', ['-c', command], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@way_marks/server",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Waymark MCP server and dashboard",
5
5
  "author": "Waymark <hello@waymarks.dev>",
6
6
  "license": "MIT",
package/src/ui/index.html CHANGED
@@ -17,6 +17,9 @@
17
17
  .badge-write_file { background: #7c3a00; color: #ffb347; }
18
18
  .badge-read_file { background: #002f5c; color: #5ab4ff; }
19
19
  .badge-bash { background: #222; color: #aaa; border: 1px solid #444; }
20
+ .event-execution { opacity: 1; }
21
+ .event-observation { opacity: 0.6; background: #0a0a0a; }
22
+ .observation-label { display: inline-block; padding: 1px 4px; background: #333; color: #999; font-size: 10px; border-radius: 2px; margin-left: 4px; }
20
23
  .decision-block { background: #5c0000; color: #ff6b6b; }
21
24
  .decision-pending { background: #4a3800; color: #ffd166; }
22
25
  .decision-rejected { background: #3a0000; color: #ff9999; }
@@ -81,6 +84,25 @@
81
84
  background: #7f0000; color: #ef9a9a; border-radius: 3px;
82
85
  padding: 1px 6px; font-size: 13px; margin-left: 8px;
83
86
  }
87
+
88
+ /* Phase 2: Hub navigation sidebar */
89
+ #hub-nav { display: none; position: fixed; left: 0; top: 0; width: 200px; height: 100vh; background: #0a0a0a; border-right: 1px solid #222; padding: 12px; overflow-y: auto; }
90
+ #hub-nav.open { display: block; }
91
+ #main-content { margin-left: 0; }
92
+ #main-content.with-nav { margin-left: 200px; }
93
+ #hub-toggle { position: fixed; left: 8px; top: 8px; background: none; border: 1px solid #333; color: #888; padding: 4px 6px; cursor: pointer; font-size: 12px; border-radius: 3px; z-index: 1000; }
94
+ #hub-nav h3 { font-size: 11px; color: #666; margin: 12px 0 8px; text-transform: uppercase; letter-spacing: 0.05em; }
95
+ .project-list { list-style: none; padding: 0; }
96
+ .project-item { padding: 6px 4px; margin: 2px 0; border-radius: 2px; cursor: pointer; font-size: 12px; border-left: 2px solid transparent; }
97
+ .project-item.running { color: #81c784; border-left-color: #4caf50; }
98
+ .project-item.running:hover { background: #1a1a1a; }
99
+ .project-item.paused { color: #ffd166; border-left-color: #ffa000; }
100
+ .project-item.paused:hover { background: #1a1a1a; }
101
+ .project-item.stopped { color: #888; border-left-color: #555; }
102
+ .project-item.stopped:hover { background: #1a1a1a; }
103
+ .project-item.current { background: #1a1a1a; border-left-color: #5ab4ff; }
104
+ .project-port { font-size: 10px; color: #555; margin-left: 4px; }
105
+
84
106
  </style>
85
107
  </head>
86
108
  <body>
@@ -90,6 +112,47 @@
90
112
  <span id="count-display">loading...</span>
91
113
  <span id="refresh-display"></span>
92
114
  </div>
115
+ <div style="margin-bottom:16px;font-size:12px">
116
+ <!-- Phase 1: Event type filters -->
117
+ <div style="margin-bottom:12px">
118
+ <span style="color:#888">Event:</span>
119
+ <label style="margin-left:8px"><input type="checkbox" id="filter-execution" checked> Execution</label>
120
+ <label style="margin-left:12px"><input type="checkbox" id="filter-observation" checked> Observation</label>
121
+ </div>
122
+
123
+ <!-- Phase 3: Status and tool filters -->
124
+ <div style="margin-bottom:12px">
125
+ <span style="color:#888">Status:</span>
126
+ <select id="filter-status" style="margin-left:8px;padding:2px;font-family:monospace;font-size:11px;background:#1a1a1a;color:#d0d0d0;border:1px solid #333">
127
+ <option value="">All Statuses</option>
128
+ <option value="pending">Pending</option>
129
+ <option value="success">Success</option>
130
+ <option value="rejected">Rejected</option>
131
+ </select>
132
+ </div>
133
+
134
+ <div style="margin-bottom:12px">
135
+ <span style="color:#888">Tool:</span>
136
+ <select id="filter-tool" style="margin-left:8px;padding:2px;font-family:monospace;font-size:11px;background:#1a1a1a;color:#d0d0d0;border:1px solid #333">
137
+ <option value="">All Tools</option>
138
+ <option value="write_file">write_file</option>
139
+ <option value="read_file">read_file</option>
140
+ <option value="bash">bash</option>
141
+ </select>
142
+ </div>
143
+
144
+ <div style="margin-bottom:12px">
145
+ <span style="color:#888">Search:</span>
146
+ <input type="text" id="filter-search" placeholder="action ID or path..." style="margin-left:8px;padding:2px;font-family:monospace;font-size:11px;background:#1a1a1a;color:#d0d0d0;border:1px solid #333;width:200px">
147
+ </div>
148
+ </div>
149
+
150
+ <button id="hub-toggle">📋</button>
151
+ <nav id="hub-nav">
152
+ <h3>Projects</h3>
153
+ <ul id="project-list" class="project-list"></ul>
154
+ </nav>
155
+ <div id="main-content">
93
156
  <table>
94
157
  <thead>
95
158
  <tr>
@@ -117,6 +180,7 @@
117
180
 
118
181
  <div id="status-bar">auto-refresh every 3s</div>
119
182
 
183
+ </div>
120
184
  <script>
121
185
  function timeAgo(dateStr) {
122
186
  const now = new Date();
@@ -270,7 +334,17 @@
270
334
  return;
271
335
  }
272
336
 
273
- const rows = actions.map(row => {
337
+ // Get filter state
338
+ const showExecution = document.getElementById('filter-execution').checked;
339
+ const showObservation = document.getElementById('filter-observation').checked;
340
+
341
+ const filteredActions = actions.filter(row => {
342
+ const eventType = row.event_type || 'execution';
343
+ if (eventType === 'observation') return showObservation;
344
+ return showExecution;
345
+ });
346
+
347
+ const rows = filteredActions.map(row => {
274
348
  const isWriteFile = row.tool_name === 'write_file';
275
349
  const isNewFile = isWriteFile && !row.before_snapshot;
276
350
  const isPending = row.decision === 'pending' && row.status === 'pending';
@@ -278,6 +352,8 @@
278
352
  const isRejected = row.status === 'rejected';
279
353
  const isBlocked = row.decision === 'block';
280
354
  const canRollback = isWriteFile && !row.rolled_back && !isBlocked && !isPending && !isApproved && row.status === 'success' && !row.approved_by;
355
+ const eventType = row.event_type || 'execution';
356
+ const isObservation = eventType === 'observation';
281
357
 
282
358
  let actionCell;
283
359
  if (isPending) {
@@ -301,9 +377,12 @@
301
377
  ? `<span style="color:#666;font-size:11px">${row.stdout.slice(0, 100).replace(/\n/g, '↵')}</span>`
302
378
  : `<span style="color:#333">—</span>`;
303
379
 
304
- return `<tr>
380
+ const rowClass = `event-${eventType}`;
381
+ const obsLabel = isObservation ? '<span class="observation-label">plan mode</span>' : '';
382
+
383
+ return `<tr class="${rowClass}">
305
384
  <td style="white-space:nowrap;color:#555">${timeAgo(row.created_at)}</td>
306
- <td>${toolBadge(row.tool_name)}</td>
385
+ <td>${toolBadge(row.tool_name)}${obsLabel}</td>
307
386
  <td>${decisionBadge(row)}</td>
308
387
  <td>${getTargetDisplay(row)}</td>
309
388
  <td>${statusLabel(row.status)}${row.error_message ? `<br><span style="color:#666;font-size:11px">${row.error_message.slice(0,80)}</span>` : ''}</td>
@@ -312,6 +391,11 @@
312
391
  </tr>`;
313
392
  });
314
393
 
394
+ if (!rows.length) {
395
+ tbody.innerHTML = '<tr><td colspan="7" class="empty">No actions match current filter.</td></tr>';
396
+ return;
397
+ }
398
+
315
399
  tbody.innerHTML = rows.join('');
316
400
 
317
401
  tbody.querySelectorAll('button.rollback').forEach(btn => {
@@ -340,15 +424,34 @@
340
424
 
341
425
  async function refresh() {
342
426
  try {
343
- const [actionsRes, countRes] = await Promise.all([
344
- fetch('/api/actions'),
427
+ // Build query string from filter values
428
+ const status = document.getElementById('filter-status').value;
429
+ const tool = document.getElementById('filter-tool').value;
430
+ const search = document.getElementById('filter-search').value;
431
+
432
+ const params = new URLSearchParams();
433
+ if (status) params.append('status', status);
434
+ if (tool) params.append('tool_name', tool);
435
+ if (search) params.append('search', search);
436
+ params.append('page', '1');
437
+ params.append('limit', '100');
438
+
439
+ // Fetch paginated data
440
+ const [paginationRes, countRes] = await Promise.all([
441
+ fetch(`/api/actions/paginated?${params}`),
345
442
  fetch('/api/actions?count=true'),
346
443
  ]);
347
- const actions = await actionsRes.json();
444
+
445
+ const pagination = await paginationRes.json();
348
446
  const { count } = await countRes.json();
447
+
448
+ const actions = pagination.actions || [];
349
449
  renderActions(Array.isArray(actions) ? actions : []);
350
- document.getElementById('count-display').textContent = `${actions.length} action(s)`;
450
+
451
+ const display = status || tool ? `${actions.length} filtered` : `${actions.length} action(s)`;
452
+ document.getElementById('count-display').textContent = display;
351
453
  document.getElementById('refresh-display').textContent = 'last refresh: ' + new Date().toLocaleTimeString();
454
+
352
455
  const badge = document.getElementById('pending-badge');
353
456
  badge.style.display = count > 0 ? 'inline' : 'none';
354
457
  badge.textContent = `[${count} pending]`;
@@ -424,6 +527,64 @@
424
527
  refresh();
425
528
  loadProjectName();
426
529
  setInterval(refresh, 3000);
530
+
531
+ // Filter event listeners
532
+ document.getElementById('filter-execution').addEventListener('change', refresh);
533
+ document.getElementById('filter-observation').addEventListener('change', refresh);
534
+
535
+ // Phase 3: New filter listeners
536
+ document.getElementById('filter-status').addEventListener('change', refresh);
537
+ document.getElementById('filter-tool').addEventListener('change', refresh);
538
+ document.getElementById('filter-search').addEventListener('input', () => {
539
+ clearTimeout(window.filterSearchTimeout);
540
+ window.filterSearchTimeout = setTimeout(refresh, 500);
541
+ });
542
+
543
+ // Phase 2: Hub navigation sidebar
544
+ function loadHubNav() {
545
+ fetch('/api/hub/projects')
546
+ .then(r => r.json())
547
+ .then(projects => {
548
+ const list = document.getElementById('project-list');
549
+ if (!projects || projects.length === 0) {
550
+ list.innerHTML = '<li style="color:#555;padding:6px 4px">no projects</li>';
551
+ return;
552
+ }
553
+
554
+ const current = document.getElementById('project-name').textContent.split('—')[1]?.trim();
555
+ list.innerHTML = projects.map(p => {
556
+ const isCurrent = current && p.projectName.includes(current);
557
+ const statusClass = p.status === 'running' ? 'running' : p.status === 'paused' ? 'paused' : 'stopped';
558
+ const activeClass = isCurrent ? ' current' : '';
559
+ const icon = p.status === 'running' ? '🟢' : p.status === 'paused' ? '⏸️ ' : '🔴';
560
+ return `<li class="project-item ${statusClass}${activeClass}" data-port="${p.port}">${icon} ${p.id}<span class="project-port">${p.port}</span></li>`;
561
+ }).join('');
562
+
563
+ // Add click handlers
564
+ list.querySelectorAll('.project-item').forEach(item => {
565
+ item.addEventListener('click', () => {
566
+ const port = item.dataset.port;
567
+ window.location.href = `http://localhost:${port}`;
568
+ });
569
+ });
570
+ })
571
+ .catch(() => {
572
+ // Hub projects not available — this is Phase 2 feature (optional)
573
+ document.getElementById('hub-toggle').style.display = 'none';
574
+ });
575
+ }
576
+
577
+ // Hub toggle button
578
+ document.getElementById('hub-toggle').addEventListener('click', () => {
579
+ const nav = document.getElementById('hub-nav');
580
+ const content = document.getElementById('main-content');
581
+ nav.classList.toggle('open');
582
+ content.classList.toggle('with-nav');
583
+ loadHubNav();
584
+ });
585
+
586
+ // Load hub projects on startup (lazy)
587
+ setTimeout(loadHubNav, 2000);
427
588
  </script>
428
589
  </body>
429
590
  </html>
@@ -0,0 +1,429 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>waymark — agent action viewer</title>
6
+ <style>
7
+ * { box-sizing: border-box; margin: 0; padding: 0; }
8
+ body { font-family: monospace; font-size: 13px; background: #0f0f0f; color: #d0d0d0; padding: 16px; }
9
+ h1 { font-size: 18px; margin-bottom: 4px; color: #fff; }
10
+ .subtitle { color: #555; margin-bottom: 16px; font-size: 12px; }
11
+ .meta { display: flex; gap: 16px; margin-bottom: 12px; color: #666; font-size: 11px; }
12
+ table { width: 100%; border-collapse: collapse; }
13
+ th { text-align: left; padding: 6px 8px; background: #1a1a1a; color: #888; font-weight: normal; border-bottom: 1px solid #333; }
14
+ td { padding: 6px 8px; border-bottom: 1px solid #1e1e1e; vertical-align: top; }
15
+ tr:hover td { background: #161616; }
16
+ .badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: bold; }
17
+ .badge-write_file { background: #7c3a00; color: #ffb347; }
18
+ .badge-read_file { background: #002f5c; color: #5ab4ff; }
19
+ .badge-bash { background: #222; color: #aaa; border: 1px solid #444; }
20
+ .decision-block { background: #5c0000; color: #ff6b6b; }
21
+ .decision-pending { background: #4a3800; color: #ffd166; }
22
+ .decision-rejected { background: #3a0000; color: #ff9999; }
23
+ .status-success { color: #4caf50; }
24
+ .status-error { color: #f44336; }
25
+ .status-pending { color: #ffb347; }
26
+ .status-blocked { color: #ff6b6b; }
27
+ .path { color: #888; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
28
+ button.rollback {
29
+ background: #3a1f00; border: 1px solid #7c3a00; color: #ffb347;
30
+ padding: 2px 8px; cursor: pointer; font-size: 11px; font-family: monospace;
31
+ border-radius: 3px;
32
+ }
33
+ button.rollback:hover { background: #5c3000; }
34
+ button.rollback:disabled { opacity: 0.4; cursor: default; }
35
+ .msg-ok { color: #4caf50; margin-left: 6px; }
36
+ .msg-err { color: #f44336; margin-left: 6px; }
37
+ .rolled-back { color: #555; font-size: 11px; }
38
+ #status-bar { position: fixed; bottom: 8px; right: 16px; color: #333; font-size: 11px; }
39
+ .empty { padding: 32px; text-align: center; color: #444; }
40
+
41
+ /* Config viewer */
42
+ #config-section { margin-top: 24px; }
43
+ #config-toggle {
44
+ background: none; border: 1px solid #333; color: #888; padding: 4px 10px;
45
+ cursor: pointer; font-family: monospace; font-size: 12px; border-radius: 3px;
46
+ }
47
+ #config-toggle:hover { border-color: #555; color: #aaa; }
48
+ #config-content { display: none; margin-top: 12px; padding: 12px; background: #141414; border: 1px solid #222; border-radius: 4px; }
49
+ #config-content h3 { color: #666; font-size: 11px; font-weight: normal; margin-bottom: 10px; }
50
+ .policy-group { margin-bottom: 12px; }
51
+ .policy-group h4 { font-size: 11px; color: #555; margin-bottom: 4px; font-weight: normal; text-transform: uppercase; letter-spacing: 0.05em; }
52
+ .policy-group ul { list-style: none; padding: 0; }
53
+ .policy-group li { padding: 2px 0; font-size: 12px; }
54
+ .policy-allow { color: #4caf50; }
55
+ .policy-block { color: #ff6b6b; }
56
+ .policy-pending { color: #ffd166; }
57
+
58
+ /* v3: approval flow */
59
+ .status-rejected { color: #e57373; }
60
+ .status-approved { color: #4caf50; }
61
+ .btn-approve {
62
+ background: #003a1a; border: 1px solid #2e7d32; color: #81c784;
63
+ padding: 2px 8px; cursor: pointer; font-size: 11px; font-family: monospace;
64
+ border-radius: 3px; margin-right: 4px;
65
+ }
66
+ .btn-approve:hover { background: #1b5e20; }
67
+ .btn-approve:disabled { opacity: 0.4; cursor: default; }
68
+ .btn-reject {
69
+ background: #3a0000; border: 1px solid #7f0000; color: #ef9a9a;
70
+ padding: 2px 8px; cursor: pointer; font-size: 11px; font-family: monospace;
71
+ border-radius: 3px;
72
+ }
73
+ .btn-reject:hover { background: #5c0000; }
74
+ .btn-reject:disabled { opacity: 0.4; cursor: default; }
75
+ .reject-input {
76
+ background: #1a1a1a; border: 1px solid #444; color: #ccc;
77
+ padding: 2px 6px; font-family: monospace; font-size: 11px;
78
+ border-radius: 3px; width: 120px; margin-right: 4px;
79
+ }
80
+ #pending-badge {
81
+ background: #7f0000; color: #ef9a9a; border-radius: 3px;
82
+ padding: 1px 6px; font-size: 13px; margin-left: 8px;
83
+ }
84
+ </style>
85
+ </head>
86
+ <body>
87
+ <h1>waymark <span id="project-name" style="color:#555"></span><span id="pending-badge" style="display:none"></span></h1>
88
+ <p class="subtitle">uglybugly agent action viewer — intercepts and logs MCP tool calls</p>
89
+ <div class="meta">
90
+ <span id="count-display">loading...</span>
91
+ <span id="refresh-display"></span>
92
+ </div>
93
+ <table>
94
+ <thead>
95
+ <tr>
96
+ <th>Time</th>
97
+ <th>Tool</th>
98
+ <th>Decision</th>
99
+ <th>Target / Command</th>
100
+ <th>Status</th>
101
+ <th>Stdout</th>
102
+ <th>Action</th>
103
+ </tr>
104
+ </thead>
105
+ <tbody id="action-tbody">
106
+ <tr><td colspan="7" class="empty">loading...</td></tr>
107
+ </tbody>
108
+ </table>
109
+
110
+ <div id="config-section">
111
+ <button id="config-toggle">▶ Current Policy</button>
112
+ <div id="config-content">
113
+ <h3>waymark.config.json</h3>
114
+ <div id="config-body">loading...</div>
115
+ </div>
116
+ </div>
117
+
118
+ <div id="status-bar">auto-refresh every 3s</div>
119
+
120
+ <script>
121
+ function timeAgo(dateStr) {
122
+ const now = new Date();
123
+ const then = new Date(dateStr + (dateStr.includes('Z') ? '' : 'Z'));
124
+ const diff = Math.floor((now - then) / 1000);
125
+ if (diff < 5) return 'just now';
126
+ if (diff < 60) return diff + 's ago';
127
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
128
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
129
+ return Math.floor(diff / 86400) + 'd ago';
130
+ }
131
+
132
+ function toolBadge(name) {
133
+ return `<span class="badge badge-${name}">${name}</span>`;
134
+ }
135
+
136
+ function statusLabel(status) {
137
+ return `<span class="status-${status}">${status}</span>`;
138
+ }
139
+
140
+ function decisionBadge(row) {
141
+ const d = row.decision || 'allow';
142
+ if (d === 'block') {
143
+ const tip = row.policy_reason ? row.policy_reason.replace(/"/g, '&quot;') : '';
144
+ return `<span class="badge decision-block" title="${tip}">blocked</span>`;
145
+ }
146
+ if (d === 'pending') {
147
+ const tip = row.policy_reason ? row.policy_reason.replace(/"/g, '&quot;') : '';
148
+ return `<span class="badge decision-pending" title="${tip}">pending</span>`;
149
+ }
150
+ if (d === 'rejected') {
151
+ const tip = row.rejected_reason ? row.rejected_reason.replace(/"/g, '&quot;') : '';
152
+ return `<span class="badge decision-rejected" title="${tip}">rejected</span>`;
153
+ }
154
+ return `<span style="color:#333">—</span>`;
155
+ }
156
+
157
+ function truncatePath(p) {
158
+ if (!p) return '<span style="color:#444">—</span>';
159
+ const parts = p.replace(/\\/g, '/').split('/');
160
+ const display = parts.length > 3 ? '…/' + parts.slice(-3).join('/') : p;
161
+ return `<span class="path" title="${p}">${display}</span>`;
162
+ }
163
+
164
+ function getTargetDisplay(row) {
165
+ if (row.tool_name === 'bash') {
166
+ const cmd = JSON.parse(row.input_payload).command || '';
167
+ const short = cmd.length > 60 ? cmd.slice(0, 57) + '...' : cmd;
168
+ return `<span class="path" title="${cmd.replace(/"/g, '&quot;')}">${short}</span>`;
169
+ }
170
+ return truncatePath(row.target_path);
171
+ }
172
+
173
+ async function doRollback(actionId, btn, msgSpan, isNewFile) {
174
+ btn.disabled = true;
175
+ btn.textContent = isNewFile ? 'deleting...' : 'rolling back...';
176
+ try {
177
+ const res = await fetch(`/api/actions/${actionId}/rollback`, { method: 'POST' });
178
+ const data = await res.json();
179
+ if (res.ok) {
180
+ msgSpan.className = 'msg-ok';
181
+ msgSpan.textContent = data.action === 'deleted' ? '✓ deleted' : '✓ restored';
182
+ btn.textContent = data.action === 'deleted' ? 'deleted' : 'rolled back';
183
+ } else {
184
+ msgSpan.className = 'msg-err';
185
+ msgSpan.textContent = '✗ ' + (data.error || 'failed');
186
+ btn.disabled = false;
187
+ btn.textContent = isNewFile ? 'delete (new file)' : 'rollback';
188
+ }
189
+ } catch (e) {
190
+ msgSpan.className = 'msg-err';
191
+ msgSpan.textContent = '✗ network error';
192
+ btn.disabled = false;
193
+ btn.textContent = isNewFile ? 'delete (new file)' : 'rollback';
194
+ }
195
+ }
196
+
197
+ async function doApprove(actionId, btn, msgSpan) {
198
+ btn.disabled = true;
199
+ btn.textContent = 'approving...';
200
+ try {
201
+ const res = await fetch(`/api/actions/${actionId}/approve`, { method: 'POST' });
202
+ const data = await res.json();
203
+ if (res.ok) {
204
+ msgSpan.className = 'msg-ok';
205
+ msgSpan.textContent = '✓ approved';
206
+ // Full refresh to get updated row
207
+ setTimeout(refresh, 300);
208
+ } else {
209
+ msgSpan.className = 'msg-err';
210
+ msgSpan.textContent = '✗ ' + (data.error || 'failed');
211
+ btn.disabled = false;
212
+ btn.textContent = '✅ Approve';
213
+ }
214
+ } catch (e) {
215
+ msgSpan.className = 'msg-err';
216
+ msgSpan.textContent = '✗ network error';
217
+ btn.disabled = false;
218
+ btn.textContent = '✅ Approve';
219
+ }
220
+ }
221
+
222
+ async function doReject(actionId, btn, rejectBtn, msgSpan) {
223
+ // Show inline input for reason
224
+ rejectBtn.disabled = true;
225
+ const inputEl = document.createElement('input');
226
+ inputEl.className = 'reject-input';
227
+ inputEl.type = 'text';
228
+ inputEl.value = 'Not approved';
229
+ inputEl.placeholder = 'reason...';
230
+ const confirmBtn = document.createElement('button');
231
+ confirmBtn.className = 'btn-reject';
232
+ confirmBtn.textContent = 'Confirm';
233
+ rejectBtn.replaceWith(inputEl);
234
+ msgSpan.parentNode.insertBefore(confirmBtn, msgSpan);
235
+
236
+ confirmBtn.addEventListener('click', async () => {
237
+ confirmBtn.disabled = true;
238
+ inputEl.disabled = true;
239
+ confirmBtn.textContent = 'rejecting...';
240
+ try {
241
+ const res = await fetch(`/api/actions/${actionId}/reject`, {
242
+ method: 'POST',
243
+ headers: { 'Content-Type': 'application/json' },
244
+ body: JSON.stringify({ reason: inputEl.value || 'Not approved' }),
245
+ });
246
+ const data = await res.json();
247
+ if (res.ok) {
248
+ msgSpan.className = 'msg-ok';
249
+ msgSpan.textContent = '✓ rejected';
250
+ setTimeout(refresh, 300);
251
+ } else {
252
+ msgSpan.className = 'msg-err';
253
+ msgSpan.textContent = '✗ ' + (data.error || 'failed');
254
+ confirmBtn.disabled = false;
255
+ confirmBtn.textContent = 'Confirm';
256
+ }
257
+ } catch (e) {
258
+ msgSpan.className = 'msg-err';
259
+ msgSpan.textContent = '✗ network error';
260
+ confirmBtn.disabled = false;
261
+ confirmBtn.textContent = 'Confirm';
262
+ }
263
+ });
264
+ }
265
+
266
+ function renderActions(actions) {
267
+ const tbody = document.getElementById('action-tbody');
268
+ if (!actions.length) {
269
+ tbody.innerHTML = '<tr><td colspan="7" class="empty">No actions yet. Connect Claude Code to the waymark MCP server and run some tools.</td></tr>';
270
+ return;
271
+ }
272
+
273
+ const rows = actions.map(row => {
274
+ const isWriteFile = row.tool_name === 'write_file';
275
+ const isNewFile = isWriteFile && !row.before_snapshot;
276
+ const isPending = row.decision === 'pending' && row.status === 'pending';
277
+ const isApproved = row.status === 'success' && row.approved_by;
278
+ const isRejected = row.status === 'rejected';
279
+ const isBlocked = row.decision === 'block';
280
+ const canRollback = isWriteFile && !row.rolled_back && !isBlocked && !isPending && !isApproved && row.status === 'success' && !row.approved_by;
281
+
282
+ let actionCell;
283
+ if (isPending) {
284
+ actionCell = `<button class="btn-approve" data-id="${row.action_id}">✅ Approve</button><button class="btn-reject" data-id="${row.action_id}">❌ Reject</button><span class="action-msg"></span>`;
285
+ } else if (isApproved) {
286
+ const tip = `by ${row.approved_by}${row.approved_at ? ' at ' + row.approved_at : ''}`.replace(/"/g, '&quot;');
287
+ actionCell = `<span class="status-approved" title="${tip}">✅ Approved</span>`;
288
+ } else if (isRejected) {
289
+ const tip = (row.rejected_reason || '').replace(/"/g, '&quot;');
290
+ actionCell = `<span class="status-rejected" title="${tip}">❌ Rejected</span>`;
291
+ } else if (row.rolled_back) {
292
+ actionCell = `<span class="rolled-back">↩ ${isNewFile ? 'deleted' : 'rolled back'}</span>`;
293
+ } else if (canRollback) {
294
+ const btnLabel = isNewFile ? 'delete (new file)' : 'rollback';
295
+ actionCell = `<button class="rollback" data-id="${row.action_id}" data-newfile="${isNewFile}">${btnLabel}</button><span class="rollback-msg"></span>`;
296
+ } else {
297
+ actionCell = `<span style="color:#333">—</span>`;
298
+ }
299
+
300
+ const stdoutPreview = row.tool_name === 'bash' && row.stdout
301
+ ? `<span style="color:#666;font-size:11px">${row.stdout.slice(0, 100).replace(/\n/g, '↵')}</span>`
302
+ : `<span style="color:#333">—</span>`;
303
+
304
+ return `<tr>
305
+ <td style="white-space:nowrap;color:#555">${timeAgo(row.created_at)}</td>
306
+ <td>${toolBadge(row.tool_name)}</td>
307
+ <td>${decisionBadge(row)}</td>
308
+ <td>${getTargetDisplay(row)}</td>
309
+ <td>${statusLabel(row.status)}${row.error_message ? `<br><span style="color:#666;font-size:11px">${row.error_message.slice(0,80)}</span>` : ''}</td>
310
+ <td>${stdoutPreview}</td>
311
+ <td style="white-space:nowrap">${actionCell}</td>
312
+ </tr>`;
313
+ });
314
+
315
+ tbody.innerHTML = rows.join('');
316
+
317
+ tbody.querySelectorAll('button.rollback').forEach(btn => {
318
+ btn.addEventListener('click', () => {
319
+ const msgSpan = btn.nextElementSibling;
320
+ doRollback(btn.dataset.id, btn, msgSpan, btn.dataset.newfile === 'true');
321
+ });
322
+ });
323
+
324
+ tbody.querySelectorAll('button.btn-approve').forEach(btn => {
325
+ btn.addEventListener('click', () => {
326
+ const msgSpan = btn.parentElement.querySelector('.action-msg');
327
+ doApprove(btn.dataset.id, btn, msgSpan);
328
+ });
329
+ });
330
+
331
+ tbody.querySelectorAll('button.btn-reject').forEach(btn => {
332
+ btn.addEventListener('click', () => {
333
+ const approveBtn = btn.previousElementSibling;
334
+ const msgSpan = btn.nextElementSibling;
335
+ approveBtn.disabled = true;
336
+ doReject(btn.dataset.id, approveBtn, btn, msgSpan);
337
+ });
338
+ });
339
+ }
340
+
341
+ async function refresh() {
342
+ try {
343
+ const [actionsRes, countRes] = await Promise.all([
344
+ fetch('/api/actions'),
345
+ fetch('/api/actions?count=true'),
346
+ ]);
347
+ const actions = await actionsRes.json();
348
+ const { count } = await countRes.json();
349
+ renderActions(Array.isArray(actions) ? actions : []);
350
+ document.getElementById('count-display').textContent = `${actions.length} action(s)`;
351
+ document.getElementById('refresh-display').textContent = 'last refresh: ' + new Date().toLocaleTimeString();
352
+ const badge = document.getElementById('pending-badge');
353
+ badge.style.display = count > 0 ? 'inline' : 'none';
354
+ badge.textContent = `[${count} pending]`;
355
+ } catch (e) {
356
+ document.getElementById('count-display').textContent = 'error fetching actions';
357
+ }
358
+ }
359
+
360
+ // Config viewer toggle
361
+ const configToggle = document.getElementById('config-toggle');
362
+ const configContent = document.getElementById('config-content');
363
+ let configOpen = false;
364
+
365
+ configToggle.addEventListener('click', async () => {
366
+ configOpen = !configOpen;
367
+ configContent.style.display = configOpen ? 'block' : 'none';
368
+ configToggle.textContent = (configOpen ? '▼' : '▶') + ' Current Policy';
369
+ if (configOpen) await loadConfigView();
370
+ });
371
+
372
+ async function loadConfigView() {
373
+ const body = document.getElementById('config-body');
374
+ try {
375
+ const res = await fetch('/api/config');
376
+ const cfg = await res.json();
377
+ const p = cfg.policies || {};
378
+
379
+ function renderList(items, cls) {
380
+ if (!items || !items.length) return '<li style="color:#444">none</li>';
381
+ return items.map(i => {
382
+ if (i.startsWith('regex:')) {
383
+ const pat = i.slice(6);
384
+ return `<li class="${cls}">${pat} <em style="color:#555;font-style:italic">[pattern]</em></li>`;
385
+ }
386
+ return `<li class="${cls}">${i}</li>`;
387
+ }).join('');
388
+ }
389
+
390
+ body.innerHTML = `
391
+ <div class="policy-group">
392
+ <h4>Allowed Paths</h4>
393
+ <ul>${renderList(p.allowedPaths, 'policy-allow')}</ul>
394
+ </div>
395
+ <div class="policy-group">
396
+ <h4>Blocked Paths</h4>
397
+ <ul>${renderList(p.blockedPaths, 'policy-block')}</ul>
398
+ </div>
399
+ <div class="policy-group">
400
+ <h4>Blocked Commands</h4>
401
+ <ul>${renderList(p.blockedCommands, 'policy-block')}</ul>
402
+ </div>
403
+ <div class="policy-group">
404
+ <h4>Require Approval</h4>
405
+ <ul>${renderList(p.requireApproval, 'policy-pending')}</ul>
406
+ </div>
407
+ `;
408
+ } catch (e) {
409
+ body.textContent = 'Error loading config';
410
+ }
411
+ }
412
+
413
+ async function loadProjectName() {
414
+ try {
415
+ const res = await fetch('/api/project');
416
+ const data = await res.json();
417
+ if (data.projectName) {
418
+ document.getElementById('project-name').textContent = '— ' + data.projectName;
419
+ }
420
+ } catch (e) { /* silent — project name is cosmetic */ }
421
+ }
422
+
423
+ // Initial load + interval
424
+ refresh();
425
+ loadProjectName();
426
+ setInterval(refresh, 3000);
427
+ </script>
428
+ </body>
429
+ </html>