taskplane 0.22.17 → 0.23.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.
@@ -352,6 +352,167 @@ function tailJsonlFile(filePath) {
352
352
  * @param {object|null} batchState - The batch state from batch-state.json
353
353
  * @returns {object} Map of tmuxPrefix → accumulated telemetry
354
354
  */
355
+
356
+ // ── Runtime V2 Data Loaders (TP-107) ─────────────────────────────
357
+
358
+ /**
359
+ * Load the Runtime V2 process registry for the current batch.
360
+ * Returns null if no registry exists (legacy batch).
361
+ */
362
+ function loadRuntimeRegistry(batchId) {
363
+ if (!batchId) return null;
364
+ const registryPath = path.join(REPO_ROOT, ".pi", "runtime", batchId, "registry.json");
365
+ try {
366
+ if (!fs.existsSync(registryPath)) return null;
367
+ return JSON.parse(fs.readFileSync(registryPath, "utf-8"));
368
+ } catch {
369
+ return null;
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Load Runtime V2 lane snapshots for the current batch.
375
+ * Returns a map of laneNumber → snapshot data.
376
+ */
377
+ function loadRuntimeLaneSnapshots(batchId) {
378
+ if (!batchId) return {};
379
+ const lanesDir = path.join(REPO_ROOT, ".pi", "runtime", batchId, "lanes");
380
+ const snapshots = {};
381
+ try {
382
+ if (!fs.existsSync(lanesDir)) return snapshots;
383
+ const files = fs.readdirSync(lanesDir).filter(f => f.startsWith("lane-") && f.endsWith(".json"));
384
+ for (const file of files) {
385
+ try {
386
+ const data = JSON.parse(fs.readFileSync(path.join(lanesDir, file), "utf-8"));
387
+ if (data.laneNumber != null) snapshots[data.laneNumber] = data;
388
+ } catch { continue; }
389
+ }
390
+ } catch { /* dir missing */ }
391
+ return snapshots;
392
+ }
393
+
394
+ /**
395
+ * Load Runtime V2 agent events for a specific agent.
396
+ * Returns the last N events from the agent's events.jsonl.
397
+ */
398
+ function loadRuntimeAgentEvents(batchId, agentId, maxEvents) {
399
+ if (!batchId || !agentId) return [];
400
+ maxEvents = maxEvents || 200;
401
+ const eventsPath = path.join(REPO_ROOT, ".pi", "runtime", batchId, "agents", agentId, "events.jsonl");
402
+ try {
403
+ if (!fs.existsSync(eventsPath)) return [];
404
+ const raw = fs.readFileSync(eventsPath, "utf-8");
405
+ const lines = raw.split("\n").filter(l => l.trim());
406
+ const events = [];
407
+ const start = Math.max(0, lines.length - maxEvents);
408
+ for (let i = start; i < lines.length; i++) {
409
+ try { events.push(JSON.parse(lines[i])); } catch { continue; }
410
+ }
411
+ return events;
412
+ } catch {
413
+ return [];
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Load mailbox message activity for the current batch.
419
+ *
420
+ * TP-093 hardening: event-authoritative model.
421
+ * Primary source: .pi/mailbox/{batchId}/events.jsonl (audit event stream).
422
+ * Fallback: directory scans (inbox/ack/outbox/outbox/processed) for
423
+ * compatibility when events.jsonl is absent.
424
+ *
425
+ * Includes:
426
+ * - Consumed replies (outbox/processed/) so they don't disappear after ack
427
+ * - Per-recipient broadcast delivery state from ack markers
428
+ * - Rate-limited events in the timeline
429
+ */
430
+ function loadMailboxData(batchId) {
431
+ if (!batchId) return { messages: [], agentIds: [], auditEvents: [] };
432
+ const mbRoot = path.join(REPO_ROOT, ".pi", "mailbox", batchId);
433
+ if (!fs.existsSync(mbRoot)) return { messages: [], agentIds: [], auditEvents: [] };
434
+
435
+ // ── Primary: events.jsonl audit trail ──
436
+ const auditEvents = loadMailboxAuditEvents(mbRoot);
437
+
438
+ // ── Fallback: directory scan ──
439
+ const messages = [];
440
+ const agentIds = [];
441
+
442
+ try {
443
+ const dirs = fs.readdirSync(mbRoot, { withFileTypes: true })
444
+ .filter(d => d.isDirectory())
445
+ .map(d => d.name);
446
+
447
+ for (const agentDir of dirs) {
448
+ if (agentDir === "_broadcast") continue;
449
+ agentIds.push(agentDir);
450
+
451
+ // Scan inbox (pending), ack (delivered), outbox (active replies), outbox/processed (consumed replies)
452
+ for (const subdir of ["inbox", "ack", "outbox", "outbox/processed"]) {
453
+ const dir = path.join(mbRoot, agentDir, subdir);
454
+ if (!fs.existsSync(dir)) continue;
455
+ try {
456
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".msg.json"));
457
+ for (const file of files) {
458
+ try {
459
+ const msg = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
460
+ let status;
461
+ if (subdir === "inbox") status = "pending";
462
+ else if (subdir === "ack") status = "delivered";
463
+ else if (subdir === "outbox") status = "reply";
464
+ else status = "reply-acked"; // outbox/processed
465
+ const isBroadcast = msg.to === "_broadcast";
466
+ messages.push({ ...msg, _status: status, _agentDir: agentDir, _isBroadcast: isBroadcast });
467
+ } catch { continue; }
468
+ }
469
+ } catch { continue; }
470
+ }
471
+ }
472
+
473
+ // _broadcast: per-recipient delivery state
474
+ const broadcastInbox = path.join(mbRoot, "_broadcast", "inbox");
475
+ const broadcastAck = path.join(mbRoot, "_broadcast", "ack");
476
+ for (const [dir, status] of [[broadcastInbox, "pending"], [broadcastAck, "delivered"]]) {
477
+ if (!fs.existsSync(dir)) continue;
478
+ try {
479
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".msg.json"));
480
+ for (const file of files) {
481
+ try {
482
+ const msg = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
483
+ messages.push({ ...msg, _status: status, _agentDir: "_broadcast", _isBroadcast: true });
484
+ } catch { continue; }
485
+ }
486
+ } catch { continue; }
487
+ }
488
+ } catch { /* mailbox dir issues */ }
489
+
490
+ messages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
491
+ return { messages, agentIds, auditEvents };
492
+ }
493
+
494
+ /**
495
+ * Load mailbox audit events from events.jsonl.
496
+ * Returns events sorted by timestamp. Includes: message_sent, message_delivered,
497
+ * message_replied, message_escalated, message_rate_limited.
498
+ */
499
+ function loadMailboxAuditEvents(mbRoot) {
500
+ const eventsPath = path.join(mbRoot, "events.jsonl");
501
+ if (!fs.existsSync(eventsPath)) return [];
502
+ try {
503
+ const raw = fs.readFileSync(eventsPath, "utf-8");
504
+ const events = [];
505
+ for (const line of raw.split("\n")) {
506
+ if (!line.trim()) continue;
507
+ try { events.push(JSON.parse(line)); } catch { continue; }
508
+ }
509
+ return events;
510
+ } catch {
511
+ return [];
512
+ }
513
+ }
514
+
515
+ // ─────────────────────────────────────────────────────────────────────
355
516
  function loadTelemetryData(batchState) {
356
517
  const telemetryDir = path.join(REPO_ROOT, ".pi", "telemetry");
357
518
  const result = {};
@@ -849,11 +1010,20 @@ function buildDashboardState() {
849
1010
  return { ...task, statusData };
850
1011
  });
851
1012
 
1013
+ // TP-107: Load Runtime V2 data when available
1014
+ const runtimeRegistry = loadRuntimeRegistry(state.batchId);
1015
+ const runtimeLaneSnapshots = loadRuntimeLaneSnapshots(state.batchId);
1016
+ const mailboxData = loadMailboxData(state.batchId);
1017
+
852
1018
  return {
853
1019
  laneStates,
854
1020
  telemetry,
855
1021
  batchTotalCost,
856
1022
  supervisor,
1023
+ // Runtime V2 data (null/empty for legacy batches)
1024
+ runtimeRegistry,
1025
+ runtimeLaneSnapshots,
1026
+ mailbox: mailboxData,
857
1027
  batch: {
858
1028
  batchId: state.batchId,
859
1029
  phase: state.phase,
@@ -1220,6 +1390,35 @@ function createServer() {
1220
1390
  } else if (pathname.startsWith("/api/conversation/") && req.method === "GET") {
1221
1391
  const prefix = pathname.slice("/api/conversation/".length);
1222
1392
  serveConversation(req, res, prefix);
1393
+ } else if (pathname.startsWith("/api/agent-events/") && req.method === "GET") {
1394
+ // TP-107: Serve Runtime V2 agent events (hardened)
1395
+ const agentId = decodeURIComponent(pathname.slice("/api/agent-events/".length));
1396
+ // Strict validation: same pattern as /api/conversation/:prefix
1397
+ if (!/^[\w-]+$/.test(agentId)) {
1398
+ res.writeHead(400, { "Content-Type": "text/plain" });
1399
+ res.end("Invalid agent ID");
1400
+ return;
1401
+ }
1402
+ const batchState = loadBatchState();
1403
+ // Path containment: verify resolved path stays inside runtime dir
1404
+ if (batchState?.batchId) {
1405
+ const runtimeBase = path.join(REPO_ROOT, ".pi", "runtime", batchState.batchId, "agents");
1406
+ const resolvedAgent = path.resolve(runtimeBase, agentId);
1407
+ if (!resolvedAgent.startsWith(path.resolve(runtimeBase))) {
1408
+ res.writeHead(403, { "Content-Type": "text/plain" });
1409
+ res.end("Forbidden");
1410
+ return;
1411
+ }
1412
+ }
1413
+ // Optional: ?sinceTs= to return only events after a timestamp
1414
+ const reqUrl = new URL(req.url, "http://localhost");
1415
+ const sinceTs = parseInt(reqUrl.searchParams.get("sinceTs") || "0", 10);
1416
+ let events = loadRuntimeAgentEvents(batchState?.batchId, agentId, 300);
1417
+ if (sinceTs > 0) {
1418
+ events = events.filter(e => (e.ts || 0) > sinceTs);
1419
+ }
1420
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
1421
+ res.end(JSON.stringify(events));
1223
1422
  } else if (pathname === "/api/state" && req.method === "GET") {
1224
1423
  const state = buildDashboardState();
1225
1424
  res.writeHead(200, {