feinai 0.6.6 → 0.7.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.
package/src/cli.ts CHANGED
@@ -47,8 +47,15 @@ import {
47
47
  formatStatus,
48
48
  type OutputFormat,
49
49
  } from "./format";
50
+ import {
51
+ readServerState,
52
+ writeServerState,
53
+ clearServerState,
54
+ findFreePort,
55
+ repairServerState,
56
+ } from "./server-state";
50
57
 
51
- const VERSION = "0.6.4";
58
+ const VERSION = "0.6.7";
52
59
 
53
60
  interface ParsedArgs {
54
61
  positional: string[];
@@ -130,7 +137,7 @@ function detectFormat(args: ParsedArgs): OutputFormat {
130
137
  * 2. parent process name (on Linux via /proc/$PPID/comm) → "{parent_name}:{ppid}:{user}"
131
138
  * 3. fallback: "{user}@{hostname}"
132
139
  *
133
- * The parent process name lets us distinguish "claude:12345:m" from "opencode:23456:m"
140
+ * The parent process name lets us distinguish "harness-a:12345:m" from "harness-b:23456:m"
134
141
  * from "bash:9999:m" in the events audit log without manual configuration.
135
142
  */
136
143
  function getCurrentUser(): string {
@@ -261,13 +268,13 @@ GLOBAL FLAGS:
261
268
  ENV:
262
269
  FEINAI_USER Override owner/actor identity used in audit log
263
270
 
264
- \x1b[34m\x1b[1m── Claude Code Skills ─────────────────────────────────────────────\x1b[0m
265
- \x1b[36mActivate feinai skills for Claude Code (run once after install):\x1b[0m
271
+ \x1b[34m\x1b[1m── Agent Harness Skills ───────────────────────────────────────────\x1b[0m
272
+ \x1b[36mActivate feinai skills for your agent harness (run once after install):\x1b[0m
266
273
 
267
- \x1b[33mmkdir -p ~/.claude/skills\x1b[0m
274
+ \x1b[33mmkdir -p <your-harness-skills-dir>\x1b[0m
268
275
  \x1b[33mSKILLS=~/.bun/install/global/node_modules/feinai/skills\x1b[0m
269
276
  \x1b[33mfor skill in feinai-sdd feinai-write-spec feinai-write-tasks feinai-dispatch feinai-implement; do\x1b[0m
270
- \x1b[33m ln -sf "$SKILLS/$skill" ~/.claude/skills/$skill\x1b[0m
277
+ \x1b[33m ln -sf "$SKILLS/$skill" <your-harness-skills-dir>/$skill\x1b[0m
271
278
  \x1b[33mdone\x1b[0m
272
279
 
273
280
  \x1b[36mSkills included:\x1b[0m
@@ -429,10 +436,11 @@ function cmdStatus(format: OutputFormat): void {
429
436
  const specs = (db.prepare(`SELECT COUNT(*) AS n FROM specs`).get() as { n: number }).n;
430
437
  const plans = (db.prepare(`SELECT COUNT(*) AS n FROM plans`).get() as { n: number }).n;
431
438
 
432
- // Check if server is running by probing the port
433
- const port = 8272;
434
- const lsof = Bun.spawnSync(["lsof", "-ti", `tcp:${port}`]);
435
- const serverRunning = lsof.exitCode === 0 && new TextDecoder().decode(lsof.stdout).trim().length > 0;
439
+ // Check server state: validate and repair stale records first
440
+ const serverRecord = repairServerState(db);
441
+ const serverRunning = serverRecord !== null;
442
+ const serverPort = serverRecord?.port ?? 8272;
443
+ const serverUrl = serverRunning ? `http://127.0.0.1:${serverPort}` : undefined;
436
444
 
437
445
  console.log(
438
446
  formatStatus(
@@ -443,7 +451,8 @@ function cmdStatus(format: OutputFormat): void {
443
451
  specs,
444
452
  plans,
445
453
  serverRunning,
446
- serverPort: port,
454
+ serverPort,
455
+ serverUrl,
447
456
  },
448
457
  format,
449
458
  ),
@@ -770,19 +779,47 @@ function cmdSpec(rest: string[], args: ParsedArgs, format: OutputFormat): void {
770
779
  async function cmdServer(args: ParsedArgs): Promise<void> {
771
780
  const port = Number(args.options.port ?? "8272");
772
781
 
773
- // --down: kill whatever is listening on the feinai port
782
+ // --down: stop the project's recorded server, fallback to default port
774
783
  if (args.flags["down"]) {
775
- if (Number.isNaN(port) || port < 1 || port > 65535) {
784
+ const db = findDbPath() ? openDb() : null;
785
+ let targetPort = port;
786
+ let targetPids: number[] = [];
787
+
788
+ if (db) {
789
+ // Try recorded server first
790
+ repairServerState(db);
791
+ const record = readServerState(db);
792
+ if (record) {
793
+ targetPort = record.port;
794
+ targetPids = [record.pid];
795
+ }
796
+ }
797
+
798
+ if (Number.isNaN(targetPort) || targetPort < 1 || targetPort > 65535) {
776
799
  console.error("Error: --port must be a valid port number");
800
+ if (db) db.close();
777
801
  process.exit(1);
778
802
  }
779
- const result = Bun.spawnSync(["lsof", "-ti", `tcp:${port}`]);
780
- const pids = new TextDecoder().decode(result.stdout).trim().split("\n").filter(Boolean);
781
- if (pids.length === 0) {
782
- console.log(`No process found listening on port ${port}.`);
803
+
804
+ // If no recorded pids, fall back to lsof probe on target port
805
+ if (targetPids.length === 0) {
806
+ const result = Bun.spawnSync(["lsof", "-ti", `tcp:${targetPort}`]);
807
+ targetPids = new TextDecoder()
808
+ .decode(result.stdout)
809
+ .trim()
810
+ .split("\n")
811
+ .filter(Boolean)
812
+ .map((s) => Number(s))
813
+ .filter((n) => !isNaN(n));
814
+ }
815
+
816
+ if (targetPids.length === 0) {
817
+ console.log(`No process found listening on port ${targetPort}.`);
818
+ if (db) db.close();
783
819
  return;
784
820
  }
785
- for (const pid of pids) {
821
+
822
+ for (const pid of targetPids) {
786
823
  try {
787
824
  process.kill(Number(pid), "SIGTERM");
788
825
  console.log(`Stopped feinai server (PID ${pid}).`);
@@ -790,6 +827,12 @@ async function cmdServer(args: ParsedArgs): Promise<void> {
790
827
  console.error(`Failed to kill PID ${pid}.`);
791
828
  }
792
829
  }
830
+
831
+ // Clear the row after stopping
832
+ if (db) {
833
+ clearServerState(db);
834
+ db.close();
835
+ }
793
836
  return;
794
837
  }
795
838
 
@@ -799,30 +842,85 @@ async function cmdServer(args: ParsedArgs): Promise<void> {
799
842
  process.exit(2);
800
843
  }
801
844
 
845
+ const db = ensureDb();
802
846
  const host = args.options.host ?? "127.0.0.1";
803
847
 
848
+ // Pre-flight consistency check: repair stale records, reject if valid server exists
849
+ repairServerState(db);
850
+ const existing = readServerState(db);
851
+ if (existing) {
852
+ console.error(
853
+ `feinai server is already running for this project at http://127.0.0.1:${existing.port}`,
854
+ );
855
+ console.error(`Stop it first with: feinai server --down --port ${existing.port}`);
856
+ db.close();
857
+ process.exit(1);
858
+ }
859
+
860
+ // Determine port: if --port is given, use it; otherwise auto-increment from 8272
861
+ const requestedPort = args.options.port ? Number(args.options.port) : undefined;
862
+ let resolvedPort: number;
863
+
864
+ if (requestedPort !== undefined) {
865
+ if (isNaN(requestedPort) || requestedPort < 1 || requestedPort > 65535) {
866
+ console.error("Error: --port must be a valid port number (1-65535)");
867
+ db.close();
868
+ process.exit(1);
869
+ }
870
+ if (findFreePort(requestedPort) !== requestedPort) {
871
+ console.error(`Error: port ${requestedPort} is already in use.`);
872
+ db.close();
873
+ process.exit(1);
874
+ }
875
+ resolvedPort = requestedPort;
876
+ } else {
877
+ resolvedPort = findFreePort(8272);
878
+ }
879
+
804
880
  if (args.flags["daemon"]) {
805
881
  // Spawn a detached child WITHOUT starting the server in the parent first.
806
882
  const noDaemon = (a: string) => a !== "--daemon" && a !== "-d";
807
883
  // In compiled binary argv[1] is a virtual /$bunfs/ path — skip it; user args start at argv[2].
808
884
  // In dev mode (bun src/cli.ts) argv[1] is the script path — keep it.
809
885
  const isCompiled = process.argv[1]?.startsWith("/$bunfs/");
810
- const childArgs = isCompiled
811
- ? [process.execPath, ...process.argv.slice(2).filter(noDaemon)]
812
- : [process.execPath, ...process.argv.slice(1).filter(noDaemon)];
886
+ let childArgs: string[];
887
+ if (isCompiled) {
888
+ childArgs = [process.execPath, ...process.argv.slice(2).filter(noDaemon)];
889
+ } else {
890
+ childArgs = [process.execPath, ...process.argv.slice(1).filter(noDaemon)];
891
+ }
892
+ // Pass the resolved port so the child doesn't re-resolve (which could pick a different port)
893
+ if (!args.options.port) {
894
+ childArgs.push("--port", String(resolvedPort));
895
+ }
813
896
  const child = Bun.spawn(childArgs, { detached: true, stdio: ["ignore", "ignore", "ignore"] });
814
897
  child.unref();
815
- console.log(`feinai dashboard → http://${host}:${port}`);
816
- console.log(`Stop with: feinai server --down`);
898
+ console.log(`feinai dashboard → http://${host}:${resolvedPort}`);
899
+ console.log(`Stop with: feinai server --down --port ${resolvedPort}`);
900
+ db.close();
817
901
  return;
818
902
  }
819
903
 
820
904
  // Lazy import so server.ts and dashboard.ts aren't loaded in non-server CLI invocations.
821
905
  const { startServer } = await import("./server");
822
- const server = startServer({ port, host });
906
+ const server = startServer({ port: resolvedPort, host });
907
+
908
+ // Write the server state row after successful bind
909
+ writeServerState(db, resolvedPort, process.pid);
910
+ db.close();
823
911
 
824
912
  console.log(`feinai dashboard listening at ${server.url}`);
825
- console.log(`Stop with: feinai server --down`);
913
+ console.log(`Stop with: feinai server --down --port ${resolvedPort}`);
914
+
915
+ // Keep process alive until SIGINT
916
+ process.on("SIGINT", () => {
917
+ console.log("\nStopping server...");
918
+ const cleanupDb = ensureDb();
919
+ clearServerState(cleanupDb);
920
+ cleanupDb.close();
921
+ server.stop();
922
+ process.exit(0);
923
+ });
826
924
 
827
925
  // Keep process alive until SIGINT
828
926
  process.on("SIGINT", () => {
@@ -293,6 +293,11 @@
293
293
  z-index: 100;
294
294
  }
295
295
  .modal-bg.visible { display: flex; }
296
+ @keyframes modal-in {
297
+ from { opacity: 0; transform: scale(0.96); }
298
+ to { opacity: 1; transform: scale(1); }
299
+ }
300
+ .modal-bg.visible .modal { animation: modal-in 0.15s ease-out; }
296
301
  .modal {
297
302
  background: var(--bg-card);
298
303
  border: 1px solid var(--border-strong);
@@ -389,6 +394,13 @@
389
394
  .markdown em { color: var(--fg); font-style: italic; }
390
395
  .events-list .item { padding: 8px 16px; cursor: default; }
391
396
  .events-list .item:hover { background: transparent; }
397
+ .events-list .item.event-taken { border-left: 2px solid var(--blue); }
398
+ .events-list .item.event-completed { border-left: 2px solid var(--green); }
399
+ .events-list .item.event-failed { border-left: 2px solid var(--red); }
400
+ .events-list .item.event-created { border-left: 2px solid var(--accent); }
401
+ .events-list .item.event-released { border-left: 2px solid var(--gray); }
402
+ .events-list .item.event-started { border-left: 2px solid var(--yellow); }
403
+ .event-payload { font-size:11px;color:var(--fg-dim);background:var(--bg-input);border-radius:4px;padding:6px 8px;margin:4px 0 0;overflow:auto;max-height:120px; }
392
404
  .event-type {
393
405
  font-size: 11px;
394
406
  padding: 1px 6px;
@@ -506,10 +518,17 @@
506
518
  .worktree-card {
507
519
  background: var(--bg-card);
508
520
  border: 1px solid var(--border);
521
+ border-left: 3px solid transparent;
509
522
  border-radius: 8px;
510
523
  padding: 12px 14px;
511
524
  font-size: 12px;
512
525
  }
526
+ .worktree-card[data-card-state="active"] { border-left-color: var(--blue); }
527
+ .worktree-card[data-card-state="clean"] { border-left-color: var(--gray); }
528
+ .worktree-card[data-card-state="error"],
529
+ .worktree-card[data-card-state="removed"] { border-left-color: var(--red); }
530
+ .worktree-card[data-card-state="merged"] { border-left-color: var(--green); }
531
+ .worktree-card[data-card-state="not-created"] { border-left-color: var(--yellow); }
513
532
  .worktree-card-header {
514
533
  display: flex;
515
534
  align-items: center;
@@ -666,6 +685,15 @@
666
685
  font-size: 13px;
667
686
  padding: 12px 0;
668
687
  }
688
+ .modal-meta-grid { display: grid; grid-template-columns: 120px 1fr; gap: 4px 12px; font-size: 12px; margin: 8px 0; }
689
+ .modal-meta-label { color: var(--fg-faint); text-transform: uppercase; letter-spacing: 0.3px; font-size: 11px; padding-top: 2px; }
690
+ .modal-meta-value { color: var(--fg-dim); word-break: break-all; }
691
+ .modal-divider { border: none; border-top: 1px solid var(--border); margin: 12px 0; }
692
+ .confirm-overlay { position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9999;display:flex;align-items:center;justify-content:center; }
693
+ .confirm-box { background:var(--bg-card);border:1px solid var(--border-strong);border-radius:10px;padding:24px;min-width:280px;max-width:380px; }
694
+ .confirm-message { margin:0 0 16px;font-size:15px; }
695
+ .confirm-skip { display:flex;align-items:center;gap:8px;font-size:12px;color:var(--fg-dim);margin-bottom:16px;cursor:pointer; }
696
+ .confirm-actions { display:flex;gap:8px;justify-content:flex-end; }
669
697
  </style>
670
698
  </head>
671
699
  <body>
@@ -719,6 +747,11 @@
719
747
 
720
748
  <section class="section-wide">
721
749
  <h2>Recent events <span class="badge" id="event-count">0</span></h2>
750
+ <div class="filter-bar" id="event-filters">
751
+ <button class="filter-btn active" data-event-filter="">all</button>
752
+ <button class="filter-btn" data-event-filter="event">events</button>
753
+ <button class="filter-btn" data-event-filter="request">requests</button>
754
+ </div>
722
755
  <div class="list events-list" id="events-list"><div class="empty">loading...</div></div>
723
756
  </section>
724
757
  </main>
@@ -745,6 +778,17 @@
745
778
  const $$ = (sel) => Array.from(document.querySelectorAll(sel));
746
779
  let activeTaskStatuses = new Set();
747
780
  let activeSpecStatuses = new Set();
781
+ let activeEventFilter = '';
782
+ let visibleSpecIds = new Set();
783
+ let lastDbEvents = [];
784
+
785
+ function relativeTime(dateStr) {
786
+ const diff = Date.now() - new Date(dateStr).getTime();
787
+ if (diff < 60000) return 'just now';
788
+ if (diff < 3600000) return Math.floor(diff / 60000) + 'm ago';
789
+ if (diff < 86400000) return Math.floor(diff / 3600000) + 'h ago';
790
+ return new Date(dateStr).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
791
+ }
748
792
  let liveRequests = [];
749
793
 
750
794
  // -------- strings --------
@@ -786,14 +830,14 @@
786
830
  return new Promise((resolve) => {
787
831
  if (localStorage.getItem("feinai-skip-" + skipKey) === "1") { resolve(true); return; }
788
832
  const overlay = document.createElement("div");
789
- overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:9999;display:flex;align-items:center;justify-content:center;";
833
+ overlay.className = "confirm-overlay";
790
834
  overlay.innerHTML = `
791
- <div style="background:var(--bg-card);border:1px solid var(--border-strong);border-radius:10px;padding:24px;min-width:280px;max-width:380px;">
792
- <p style="margin:0 0 16px;font-size:15px;">${escapeHtml(message)}</p>
793
- <label style="display:flex;align-items:center;gap:8px;font-size:12px;color:var(--fg-dim);margin-bottom:16px;cursor:pointer;">
835
+ <div class="confirm-box">
836
+ <p class="confirm-message">${escapeHtml(message)}</p>
837
+ <label class="confirm-skip">
794
838
  <input type="checkbox" id="skip-cb"> ${t("skip_confirm")}
795
839
  </label>
796
- <div style="display:flex;gap:8px;justify-content:flex-end;">
840
+ <div class="confirm-actions">
797
841
  <button id="confirm-cancel" class="btn">${t("cancel_btn")}</button>
798
842
  <button id="confirm-ok" class="btn primary">${t("confirm_btn")}</button>
799
843
  </div>
@@ -961,6 +1005,7 @@
961
1005
 
962
1006
  function renderSpecs(specs) {
963
1007
  const filtered = activeSpecStatuses.size > 0 ? specs.filter((s) => activeSpecStatuses.has(s.status)) : specs;
1008
+ visibleSpecIds = new Set(filtered.map(s => s.id));
964
1009
  $("#specs-count").textContent = filtered.length;
965
1010
  if (!filtered.length) {
966
1011
  $("#specs-list").innerHTML = '<div class="empty">no specs match filter</div>';
@@ -997,7 +1042,14 @@
997
1042
  }
998
1043
 
999
1044
  function renderTasks(tasks) {
1000
- const filtered = activeTaskStatuses.size > 0 ? tasks.filter((t) => activeTaskStatuses.has(t.status)) : tasks;
1045
+ // Cross-filter: if specs are filtered, only show tasks belonging to visible specs
1046
+ let crossFiltered = tasks;
1047
+ if (activeSpecStatuses.size > 0) {
1048
+ crossFiltered = tasks.filter(t => !t.spec_id || visibleSpecIds.has(t.spec_id));
1049
+ }
1050
+ const filtered = activeTaskStatuses.size > 0
1051
+ ? crossFiltered.filter((t) => activeTaskStatuses.has(t.status))
1052
+ : crossFiltered;
1001
1053
  $("#tasks-count").textContent = filtered.length;
1002
1054
  if (!filtered.length) {
1003
1055
  $("#tasks-list").innerHTML = '<div class="empty">no tasks match filter</div>';
@@ -1030,7 +1082,10 @@
1030
1082
  ...liveRequests.map((r) => ({ ...r, _kind: "request" })),
1031
1083
  ];
1032
1084
  all.sort((a, b) => new Date(b.created_at || b.timestamp).getTime() - new Date(a.created_at || a.timestamp).getTime());
1033
- const combined = all.slice(0, 50);
1085
+ const filtered = activeEventFilter
1086
+ ? all.filter((e) => e._kind === activeEventFilter)
1087
+ : all;
1088
+ const combined = filtered.slice(0, 50);
1034
1089
 
1035
1090
  $("#event-count").textContent = combined.length;
1036
1091
  if (!combined.length) {
@@ -1046,7 +1101,7 @@
1046
1101
  <span class="method-badge ${escapeHtml(e.method.toLowerCase())}">${escapeHtml(e.method)}</span>
1047
1102
  <code>${escapeHtml(e.path)}</code>
1048
1103
  <span class="source-badge ${escapeHtml(e.source.toLowerCase())}">${escapeHtml(e.source)}</span>
1049
- <span style="color: var(--fg-dim); margin-left: auto;">${escapeHtml(e.timestamp)}</span>
1104
+ <span style="color: var(--fg-dim); margin-left: auto;">${relativeTime(e.created_at ?? e.ts ?? e.timestamp)}</span>
1050
1105
  </div>
1051
1106
  <div class="item-meta">
1052
1107
  <span>${escapeHtml(e.actor)}</span>
@@ -1054,16 +1109,17 @@
1054
1109
  </div>
1055
1110
  `;
1056
1111
  }
1112
+ const payload = (() => { try { return e.payload ? JSON.parse(e.payload) : null; } catch { return e.payload || null; } })();
1057
1113
  return `
1058
- <div class="item">
1114
+ <div class="item event-${escapeHtml(e.event_type)}">
1059
1115
  <div class="item-row1">
1060
1116
  <span class="event-type ${escapeHtml(e.event_type)}">${escapeHtml(e.event_type)}</span>
1061
1117
  <code>${escapeHtml(e.entity_type)}/${escapeHtml(e.entity_id)}</code>
1062
- <span style="color: var(--fg-dim); margin-left: auto;">${escapeHtml(e.created_at)}</span>
1118
+ <span style="color: var(--fg-dim); margin-left: auto;">${relativeTime(e.created_at ?? e.ts)}</span>
1063
1119
  </div>
1064
1120
  <div class="item-meta">
1065
1121
  ${e.actor ? `<span>${escapeHtml(e.actor)}</span>` : ""}
1066
- ${e.payload ? `<code style="color: var(--fg-faint);">${escapeHtml(e.payload)}</code>` : ""}
1122
+ ${payload ? `<details><summary style="cursor:pointer;color:var(--fg-faint);font-size:11px;list-style:none;">payload ▸</summary><pre class="event-payload">${escapeHtml(JSON.stringify(payload, null, 2))}</pre></details>` : ""}
1067
1123
  </div>
1068
1124
  </div>
1069
1125
  `;
@@ -1101,6 +1157,7 @@
1101
1157
  actions.push(`<button class="btn danger" data-action="delete-spec" data-id="${escapeHtml(spec.id)}">delete</button>`);
1102
1158
  setModalBody(`
1103
1159
  ${actions.length ? `<div class="modal-actions">${actions.join("")}</div>` : ""}
1160
+ <hr class="modal-divider">
1104
1161
  ${spec.content ? `
1105
1162
  <div class="modal-section">
1106
1163
  <h3>Spec content <span class="badge">${spec.content.length}B</span></h3>
@@ -1111,6 +1168,7 @@
1111
1168
  <h3>Plan v${latest_plan.version} <span class="badge">${latest_plan.content.length}B</span></h3>
1112
1169
  <div class="markdown">${renderMarkdown(latest_plan.content)}</div>
1113
1170
  </div>` : ""}
1171
+ <hr class="modal-divider">
1114
1172
  <div class="modal-section">
1115
1173
  <h3>Tasks <span class="badge">${tasks.length}</span></h3>
1116
1174
  <div>${tasks.length === 0 ? '<div class="empty">no tasks</div>' : tasks.map((task) => `
@@ -1161,20 +1219,23 @@
1161
1219
  if (taskData.spec_id) {
1162
1220
  actions.unshift(`<button class="btn" data-action="back-to-spec" data-spec="${escapeHtml(taskData.spec_id)}">← ${escapeHtml(taskData.spec_id)}</button>`);
1163
1221
  }
1222
+ const metaRows = [
1223
+ taskData.owner ? `<span class="modal-meta-label">Owner</span><span class="modal-meta-value">${escapeHtml(taskData.owner)}</span>` : "",
1224
+ taskData.taken_at ? `<span class="modal-meta-label">Taken</span><span class="modal-meta-value">${escapeHtml(taskData.taken_at)}</span>` : "",
1225
+ taskData.completed_at ? `<span class="modal-meta-label">Completed</span><span class="modal-meta-value">${escapeHtml(taskData.completed_at)}</span>` : "",
1226
+ taskData.worktree ? `<span class="modal-meta-label">Worktree</span><span class="modal-meta-value"><code>${escapeHtml(taskData.worktree)}</code></span>` : "",
1227
+ taskData.blocked_by?.length ? `<span class="modal-meta-label">Blocked by</span><span class="modal-meta-value">${taskData.blocked_by.map((id) => `<code>${escapeHtml(id)}</code>`).join(", ")}</span>` : "",
1228
+ taskData.packages?.length ? `<span class="modal-meta-label">Packages</span><span class="modal-meta-value">${taskData.packages.map((p) => `<code>${escapeHtml(p)}</code>`).join(", ")}</span>` : "",
1229
+ taskData.quality_gates?.length ? `<span class="modal-meta-label">Quality gates</span><span class="modal-meta-value">${taskData.quality_gates.map((g) => `<code>${escapeHtml(g)}</code>`).join("<br>")}</span>` : "",
1230
+ ].filter(Boolean).join("");
1164
1231
  setModalBody(`
1165
1232
  ${actions.length ? `<div class="modal-actions">${actions.join("")}</div>` : ""}
1233
+ <hr class="modal-divider">
1166
1234
  <div class="modal-section">
1167
1235
  <h3>Metadata</h3>
1168
- <div class="item-meta">
1169
- ${taskData.owner ? `<span><strong>owner:</strong> ${escapeHtml(taskData.owner)}</span>` : ""}
1170
- ${taskData.taken_at ? `<span><strong>taken:</strong> ${escapeHtml(taskData.taken_at)}</span>` : ""}
1171
- ${taskData.completed_at ? `<span><strong>completed:</strong> ${escapeHtml(taskData.completed_at)}</span>` : ""}
1172
- </div>
1173
- ${taskData.worktree ? `<div style="margin-top:8px;"><strong>worktree:</strong> <code>${escapeHtml(taskData.worktree)}</code></div>` : ""}
1174
- ${taskData.blocked_by?.length ? `<div style="margin-top:8px;"><strong>blocked by:</strong> ${taskData.blocked_by.map((id) => `<code>${escapeHtml(id)}</code>`).join(", ")}</div>` : ""}
1175
- ${taskData.packages?.length ? `<div style="margin-top:8px;"><strong>packages:</strong> ${taskData.packages.map((p) => `<code>${escapeHtml(p)}</code>`).join(", ")}</div>` : ""}
1176
- ${taskData.quality_gates?.length ? `<div style="margin-top:8px;"><strong>quality gates:</strong><ul style="margin: 4px 0 0 16px;">${taskData.quality_gates.map((g) => `<li><code>${escapeHtml(g)}</code></li>`).join("")}</ul></div>` : ""}
1236
+ <div class="modal-meta-grid">${metaRows}</div>
1177
1237
  </div>
1238
+ <hr class="modal-divider">
1178
1239
  ${taskData.description ? `
1179
1240
  <div class="modal-section">
1180
1241
  <h3>Description / workplan</h3>
@@ -1495,13 +1556,29 @@
1495
1556
  worktreeElapsedTimer = setInterval(() => {
1496
1557
  document.querySelectorAll('[data-elapsed]').forEach((el) => {
1497
1558
  const iso = el.getAttribute('data-elapsed');
1498
- el.textContent = iso ? elapsedSince(iso) : '';
1559
+ if (iso) {
1560
+ el.textContent = elapsedSince(iso);
1561
+ const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
1562
+ if (minutes >= 60) el.style.color = 'var(--red)';
1563
+ else if (minutes >= 30) el.style.color = 'var(--yellow)';
1564
+ else el.style.color = 'var(--fg-dim)';
1565
+ } else {
1566
+ el.textContent = '';
1567
+ }
1499
1568
  });
1500
1569
  }, 1000);
1501
1570
  // Immediate first update
1502
1571
  document.querySelectorAll('[data-elapsed]').forEach((el) => {
1503
1572
  const iso = el.getAttribute('data-elapsed');
1504
- el.textContent = iso ? elapsedSince(iso) : '';
1573
+ if (iso) {
1574
+ el.textContent = elapsedSince(iso);
1575
+ const minutes = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
1576
+ if (minutes >= 60) el.style.color = 'var(--red)';
1577
+ else if (minutes >= 30) el.style.color = 'var(--yellow)';
1578
+ else el.style.color = 'var(--fg-dim)';
1579
+ } else {
1580
+ el.textContent = '';
1581
+ }
1505
1582
  });
1506
1583
  }
1507
1584
  }
@@ -1525,6 +1602,14 @@
1525
1602
  }
1526
1603
  }
1527
1604
 
1605
+ function gitFileColor(line) {
1606
+ if (/^(\?\?|!!)\s/.test(line)) return 'var(--fg-faint)';
1607
+ if (/^A[\s]/.test(line) || /^[A-Z]A/.test(line)) return 'var(--green)';
1608
+ if (/^D[\s]/.test(line) || /^[A-Z]D/.test(line)) return 'var(--red)';
1609
+ if (/M/.test(line.slice(0,2))) return 'var(--yellow)';
1610
+ return 'var(--fg-dim)';
1611
+ }
1612
+
1528
1613
  function updateWorktreeCard(id, data) {
1529
1614
  const badgeEl = $(`[data-state-badge="${CSS.escape(id)}"]`);
1530
1615
  const filesEl = $(`[data-files="${CSS.escape(id)}"]`);
@@ -1534,6 +1619,8 @@
1534
1619
  const state = worktreeStateBadge(data);
1535
1620
  badgeEl.className = `worktree-state ${state.cls}`;
1536
1621
  badgeEl.textContent = state.text;
1622
+ const card = badgeEl.closest('.worktree-card');
1623
+ if (card) card.dataset.cardState = state.cls;
1537
1624
  const agent = agentProcesses.find((a) => a.taskId === id);
1538
1625
  if (processEl) {
1539
1626
  const ownerEl = badgeEl.closest('.worktree-card')?.querySelector('.owner');
@@ -1548,7 +1635,7 @@
1548
1635
  }
1549
1636
  if (filesEl) {
1550
1637
  filesEl.innerHTML = data.files?.length
1551
- ? data.files.map((f) => `<li>${escapeHtml(f)}</li>`).join("")
1638
+ ? data.files.map((f) => `<li style="color: ${gitFileColor(f)}">${escapeHtml(f)}</li>`).join("")
1552
1639
  : "";
1553
1640
  }
1554
1641
  if (commitEl) {
@@ -1572,6 +1659,7 @@
1572
1659
  renderSpecs(specs);
1573
1660
  renderTasks(tasks);
1574
1661
  renderWorktreeCards(tasks);
1662
+ lastDbEvents = dbEvents;
1575
1663
  renderCombinedEvents(dbEvents);
1576
1664
  } catch (err) {
1577
1665
  console.error("refresh failed:", err);
@@ -1652,7 +1740,9 @@
1652
1740
  }
1653
1741
 
1654
1742
  // -------- Boot --------
1655
- $("#modal-bg").addEventListener("click", (e) => { if (e.target === $("#modal-bg")) closeModal(); });
1743
+ $('#modal-bg').addEventListener('click', (e) => {
1744
+ if (e.target === e.currentTarget) closeModal();
1745
+ });
1656
1746
  $("#modal-close").addEventListener("click", closeModal);
1657
1747
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeModal(); });
1658
1748
  $$("#task-filters .filter-btn").forEach((btn) => {
@@ -1687,6 +1777,14 @@
1687
1777
  refresh();
1688
1778
  });
1689
1779
  });
1780
+ $$("#event-filters .filter-btn").forEach((btn) => {
1781
+ btn.addEventListener("click", () => {
1782
+ activeEventFilter = btn.dataset.eventFilter;
1783
+ $$("#event-filters .filter-btn").forEach((b) => b.classList.remove("active"));
1784
+ btn.classList.add("active");
1785
+ renderCombinedEvents(lastDbEvents);
1786
+ });
1787
+ });
1690
1788
  $("#new-spec-btn").addEventListener("click", openNewSpecModal);
1691
1789
  $("#new-task-btn").addEventListener("click", openNewTaskModal);
1692
1790
 
package/src/db.ts CHANGED
@@ -61,6 +61,14 @@ const SCHEMA_STATEMENTS: string[] = [
61
61
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
62
62
  )`,
63
63
 
64
+ `CREATE TABLE IF NOT EXISTS server_state (
65
+ id INTEGER PRIMARY KEY CHECK (id = 1),
66
+ port INTEGER NOT NULL,
67
+ pid INTEGER NOT NULL,
68
+ started_at TEXT NOT NULL,
69
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
70
+ )`,
71
+
64
72
  `CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)`,
65
73
  `CREATE INDEX IF NOT EXISTS idx_tasks_spec_id ON tasks(spec_id)`,
66
74
  `CREATE INDEX IF NOT EXISTS idx_tasks_owner ON tasks(owner)`,
package/src/format.ts CHANGED
@@ -148,12 +148,12 @@ export function formatPlanList(plans: Plan[], format: OutputFormat): string {
148
148
  }
149
149
 
150
150
  export function formatStatus(
151
- stats: { pending: number; in_progress: number; completed: number; specs: number; plans: number; serverRunning?: boolean; serverPort?: number },
151
+ stats: { pending: number; in_progress: number; completed: number; specs: number; plans: number; serverRunning?: boolean; serverPort?: number; serverUrl?: string },
152
152
  format: OutputFormat,
153
153
  ): string {
154
154
  if (format === "json") return JSON.stringify(stats, null, 2);
155
155
  const serverLine = stats.serverRunning
156
- ? c(format, "green", `server: running → http://127.0.0.1:${stats.serverPort}`)
156
+ ? c(format, "green", `server: running → ${stats.serverUrl ?? `http://127.0.0.1:${stats.serverPort}`}`)
157
157
  : c(format, "dim", `server: stopped (feinai server -d to start)`);
158
158
  return [
159
159
  `${c(format, "yellow", `pending: ${stats.pending}`)}`,