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/CHANGELOG.md +16 -0
- package/README.md +12 -14
- package/package.json +1 -2
- package/skills/feinai-dispatch/SKILL.md +9 -9
- package/skills/feinai-implement/SKILL.md +57 -57
- package/skills/feinai-write-spec/SKILL.md +132 -27
- package/skills/feinai-write-tasks/SKILL.md +1 -1
- package/src/cli.ts +123 -25
- package/src/dashboard.html +122 -24
- package/src/db.ts +8 -0
- package/src/format.ts +2 -2
- package/src/server-state.ts +180 -0
- package/src/server.ts +13 -1
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.
|
|
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 "
|
|
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──
|
|
265
|
-
\x1b[36mActivate feinai skills for
|
|
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
|
|
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"
|
|
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
|
|
433
|
-
const
|
|
434
|
-
const
|
|
435
|
-
const
|
|
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
|
|
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:
|
|
782
|
+
// --down: stop the project's recorded server, fallback to default port
|
|
774
783
|
if (args.flags["down"]) {
|
|
775
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
if (
|
|
782
|
-
|
|
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
|
-
|
|
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
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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}:${
|
|
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", () => {
|
package/src/dashboard.html
CHANGED
|
@@ -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.
|
|
833
|
+
overlay.className = "confirm-overlay";
|
|
790
834
|
overlay.innerHTML = `
|
|
791
|
-
<div
|
|
792
|
-
<p
|
|
793
|
-
<label
|
|
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
|
|
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
|
-
|
|
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
|
|
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;">${
|
|
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;">${
|
|
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
|
-
${
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
$(
|
|
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}`)}`,
|