feinai 0.6.7 → 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 CHANGED
@@ -3,6 +3,22 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
5
5
 
6
+ ## [0.7.0] - 2026-06-22
7
+
8
+ ### Added
9
+ - **Project-scoped server port tracking** (`server_state` table in `feinai.db`)
10
+ - `feinai status` now validates & repairs the server record before printing the exact project URL
11
+ - `feinai server` auto-increments from port 8272 when the default is busy
12
+ - `feinai server --down` targets the recorded pid/port for this project
13
+ - Graceful shutdown clears the server record from the DB
14
+ - 6 unit tests for server-state DB operations
15
+
16
+ ### Changed
17
+ - **Normalized language**: removed all "Claude Code" and provider-specific references across CLI, README, and skill files
18
+ - `feinai-implement` skill fully translated from Spanish to English
19
+ - `CLAUDE.md` references → `AGENTS.md`, `.claude/ARCHITECTURE.md` → `ARCHITECTURE.md`
20
+ - `formatStatus` accepts optional `serverUrl` parameter
21
+
6
22
  ## [0.6.3] - 2026-06-11
7
23
 
8
24
  ### Fixed
package/README.md CHANGED
@@ -109,9 +109,9 @@ Agents don't parse `QUEUE.md`. They talk to a small local coordination service i
109
109
 
110
110
  ---
111
111
 
112
- ## Claude Code skills
112
+ ## Agent harness skills
113
113
 
114
- feinai ships three Claude Code skills covering the full development loop. They activate automatically when `.feinai/feinai.db` is present:
114
+ feinai ships three skills covering the full development loop. They activate automatically when `.feinai/feinai.db` is present:
115
115
 
116
116
  | Skill | Purpose |
117
117
  |---------------------|----------------------------------------------------------------------------------|
@@ -121,14 +121,14 @@ feinai ships three Claude Code skills covering the full development loop. They a
121
121
 
122
122
  Together they cover: design → spec → plan → tasks → parallel execution → merge.
123
123
 
124
- ### Activating skills in Claude Code
124
+ ### Activating skills
125
125
 
126
126
  ```sh
127
- mkdir -p ~/.claude/skills
127
+ mkdir -p <your-harness-skills-dir>
128
128
  SKILLS=~/.bun/install/global/node_modules/feinai/skills
129
129
 
130
130
  for skill in feinai-write-spec feinai-dispatch feinai-implement; do
131
- ln -sf "$SKILLS/$skill" ~/.claude/skills/$skill
131
+ ln -sf "$SKILLS/$skill" <your-harness-skills-dir>/$skill
132
132
  done
133
133
  ```
134
134
 
@@ -149,7 +149,7 @@ This installs:
149
149
 
150
150
  ### PATH setup (non‑interactive SSH)
151
151
 
152
- In interactive shells and for local agents (Claude Code, opencode) it should work out of the box.
152
+ In interactive shells and for local coding agents it should work out of the box.
153
153
 
154
154
  For non‑interactive SSH sessions (e.g. `ssh host 'feinai status'`), you may need a one‑time setup:
155
155
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feinai",
3
- "version": "0.6.7",
3
+ "version": "0.7.0",
4
4
  "description": "Task & spec manager for AI agents — parallel worktrees, live dashboard, SDD skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,6 @@
21
21
  "keywords": [
22
22
  "task-manager",
23
23
  "sdd",
24
- "claude-code",
25
24
  "ai-agents",
26
25
  "sqlite",
27
26
  "cli",
@@ -76,19 +76,19 @@ feinai is installed but no DB in this project. Ask the user:
76
76
 
77
77
  ---
78
78
 
79
- ### Failure: skills not activating in Claude Code
79
+ ### Failure: skills not activating in your agent harness
80
80
 
81
- If Claude Code doesn't recognize `feinai-dispatch` or other skills after install:
81
+ If your agent harness doesn't recognize `feinai-dispatch` or other skills after install:
82
82
 
83
83
  > Run this to activate the skills:
84
84
  > ```bash
85
- > mkdir -p ~/.claude/skills
85
+ > mkdir -p <your-harness-skills-dir>
86
86
  > SKILLS=~/.bun/install/global/node_modules/feinai/skills
87
87
  > for skill in feinai-sdd feinai-write-spec feinai-write-tasks feinai-dispatch feinai-implement; do
88
- > ln -sf "$SKILLS/$skill" ~/.claude/skills/$skill
88
+ > ln -sf "$SKILLS/$skill" <your-harness-skills-dir>/$skill
89
89
  > done
90
90
  > ```
91
- > Then restart Claude Code.
91
+ > Then restart your agent harness.
92
92
 
93
93
  **Offer to run it.**
94
94
 
@@ -162,7 +162,7 @@ For each iteration:
162
162
  For each task to dispatch:
163
163
 
164
164
  ```bash
165
- git worktree add .claude/worktrees/TASK-X-id <branch>
165
+ git worktree add .worktrees/TASK-X-id <branch>
166
166
  ```
167
167
 
168
168
  Branch naming: `feature/TASK-X-id-slug` derived from the subject.
@@ -171,7 +171,7 @@ Branch naming: `feature/TASK-X-id-slug` derived from the subject.
171
171
 
172
172
  ```bash
173
173
  feinai take TASK-X --json # atomic claim, sin worktree aún
174
- feinai task edit TASK-X --worktree .claude/worktrees/TASK-X-id
174
+ feinai task edit TASK-X --worktree .worktrees/TASK-X-id
175
175
  ```
176
176
 
177
177
  Order matters: take first (atomic reservation), then edit to record the worktree path.
@@ -218,7 +218,7 @@ For each `completed` task:
218
218
 
219
219
  1. Run the quality gates **again** in the worktree as a final check
220
220
  2. Merge worktree branch into the working branch
221
- 3. Remove the worktree: `git worktree remove .claude/worktrees/TASK-X-id`
221
+ 3. Remove the worktree: `git worktree remove .worktrees/TASK-X-id`
222
222
  4. Clear the worktree field: `feinai task edit TASK-X --worktree ""`
223
223
 
224
224
  If the merge has conflicts → treat as a failure. Go to Phase 3.
@@ -270,7 +270,7 @@ When `feinai list --spec SPEC-NNN --pending --json` returns empty:
270
270
 
271
271
  ### Worktree rules (non-negotiable)
272
272
 
273
- - ✅ Each task gets its own worktree under `.claude/worktrees/`
273
+ - ✅ Each task gets its own worktree under `.worktrees/`
274
274
  - ✅ Subagent never switches branches, never works outside its worktree
275
275
  - ✅ Worktree path is recorded in feinai (`worktree` field) immediately after `take`
276
276
  - ❌ Never run `git checkout` on the main working tree during dispatch
@@ -17,117 +17,117 @@ If any fails: stop and report. Do not improvise.
17
17
 
18
18
  ---
19
19
 
20
- ## AL ARRANCAR
20
+ ## On startup
21
21
 
22
- 1. `feinai list --pending --json` — encontrá la primera tarea disponible (sin blockers pendientes)
23
- 2. Si no hay ninguna respondé "No hay tareas pendientes" y pará
24
- 3. `feinai take <TASK-ID> --owner implement-agent` — tomala atómicamente
25
- 4. Si la tarea tiene `spec_id` → `feinai spec content <SPEC-ID>` para contexto
26
- 5. Si la tarea tiene `blocked_by` con tareas no completadas soltá con `feinai release <TASK-ID>` y pará
22
+ 1. `feinai list --pending --json` — find the first available task (no pending blockers)
23
+ 2. If none existsrespond "No pending tasks" and stop
24
+ 3. `feinai take <TASK-ID> --owner implement-agent` — claim it atomically
25
+ 4. If the task has `spec_id` → `feinai spec content <SPEC-ID>` for context
26
+ 5. If the task has `blocked_by` with uncompleted tasksrelease with `feinai release <TASK-ID>` and stop
27
27
 
28
- Leé `AGENTS.md` del proyecto para arquitectura y convenciones del proyecto específico.
28
+ Read `AGENTS.md` of the project for architecture and project-specific conventions.
29
29
 
30
30
  ---
31
31
 
32
- ## Ejecutar la tarea
32
+ ## Execute the task
33
33
 
34
- **Paso 1 — Worktree aislado:**
34
+ **Step 1 — Isolated worktree:**
35
35
  ```bash
36
36
  feinai git worktree add .worktrees/<TASK-ID> origin/main
37
37
  cd .worktrees/<TASK-ID>
38
38
  ```
39
39
 
40
- **Paso 2 — Setup del worktree:**
41
- Instalá dependencias si el proyecto las requiere. Consultá `AGENTS.md` del proyecto para el comando exacto.
40
+ **Step 2 — Worktree setup:**
41
+ Install dependencies if the project requires them. Check `AGENTS.md` of the project for the exact command.
42
42
 
43
- **Paso 3 — Leer antes de escribir:**
44
- - La descripción completa de la tarea (`feinai show <TASK-ID>`)
45
- - Los archivos que vas a tocar léelos antes de editarlos
46
- - Si hay un **Workplan** en la descripciónejecutá esos pasos en ese orden exacto
43
+ **Step 3 — Read before writing:**
44
+ - The full task description (`feinai show <TASK-ID>`)
45
+ - The files you will touchread them before editing
46
+ - If there is a **Workplan** in the descriptionfollow those steps in exact order
47
47
 
48
- **Paso 4 — Implementar:**
49
- Exactamente lo que dice la tarea. Ni más ni menos.
50
- - No toques archivos fuera del scope de la tarea
51
- - Si un archivo "a crear" ya existeextendelo en lugar de sobrescribirlo si ya tiene contenido válido
48
+ **Step 4 — Implement:**
49
+ Exactly what the task says. No more, no less.
50
+ - Do not touch files outside the task scope
51
+ - If a file "to create" already existsextend it instead of overwriting if it already contains valid content
52
52
 
53
- **Paso 5 — Commit:**
54
- Un commit por tarea. Conventional commits:
53
+ **Step 5 — Commit:**
54
+ One commit per task. Conventional commits:
55
55
  ```
56
- feat(scope): descripción concisa
56
+ feat(scope): concise description
57
57
  ```
58
- Tipos: `feat`, `fix`, `refactor`, `test`, `chore`.
58
+ Types: `feat`, `fix`, `refactor`, `test`, `chore`.
59
59
 
60
- **Paso 6 — Quality gates:**
61
- Corré los gates definidos en la tarea (`quality_gates`). Si la tarea no los especifica, consultá `AGENTS.md` del proyecto para los gates por defecto.
60
+ **Step 6 — Quality gates:**
61
+ Run the gates defined in the task (`quality_gates`). If the task does not specify them, check `AGENTS.md` of the project for default gates.
62
62
 
63
- **Paso 7 — Cerrar:**
63
+ **Step 7 — Close:**
64
64
 
65
- Gates pasan:
65
+ Gates pass:
66
66
  ```bash
67
- # Desde el worktree:
67
+ # From the worktree:
68
68
  feinai git push origin HEAD:main
69
69
 
70
- # Desde la raíz del repo:
70
+ # From the repo root:
71
71
  feinai git worktree remove .worktrees/<TASK-ID>
72
72
  feinai git complete
73
73
 
74
74
  feinai done <TASK-ID> --result "gates ✓"
75
75
  ```
76
76
 
77
- Gates fallanseguí "Si algo falla".
77
+ Gates failfollow "If something fails".
78
78
 
79
- **Done = 3 hechos observables:**
80
- 1. Quality gates pasan sin errores
81
- 2. Los archivos de la tarea existen con contenido correcto
82
- 3. Commit limpio en `main` y tarea en estado `completed` en feinai
79
+ **Done = 3 observable facts:**
80
+ 1. Quality gates pass without errors
81
+ 2. The task files exist with correct content
82
+ 3. Clean commit on `main` and task status `completed` in feinai
83
83
 
84
84
  ---
85
85
 
86
- ## Si algo falla
86
+ ## If something fails
87
87
 
88
- Gates fallan, push falla, o error en cualquier paso:
88
+ Gates fail, push fails, or error at any step:
89
89
 
90
- 1. **No limpies el worktree**
91
- 2. Push a rama backup:
90
+ 1. **Do not clean the worktree**
91
+ 2. Push to a backup branch:
92
92
  ```bash
93
93
  feinai git push origin HEAD:backup/<TASK-ID>
94
94
  ```
95
- 3. Marcá la tarea como fallida:
95
+ 3. Mark the task as failed:
96
96
  ```bash
97
- feinai fail <TASK-ID> --error "<comando exacto + output relevante>"
97
+ feinai fail <TASK-ID> --error "<exact command + relevant output>"
98
98
  ```
99
- 4. Dejá el worktree intacto para recuperación manual
99
+ 4. Leave the worktree intact for manual recovery
100
100
 
101
101
  ---
102
102
 
103
- ## Git — `feinai git` exclusivamente
103
+ ## Git — `feinai git` exclusively
104
104
 
105
- `git` y `gh` están bloqueados. Usá `feinai git` para todoes opengit bundleado con feinai.
105
+ `git` and `gh` are blocked. Use `feinai git` for everythingit is opengit bundled with feinai.
106
106
 
107
- **Permitido:**
107
+ **Allowed:**
108
108
  - `feinai git worktree add/list/lock/unlock`
109
109
  - `feinai git add`, `commit`, `push`, `status`, `diff`, `log`, `show`
110
- - `feinai git complete` — sincroniza main local tras push (solo desde raíz del repo)
110
+ - `feinai git complete` — syncs local main after push (only from repo root)
111
111
 
112
- **Prohibido:**
113
- - `feinai git branch`, `checkout`, `switch` — nunca cambiar branches
112
+ **Prohibited:**
113
+ - `feinai git branch`, `checkout`, `switch` — never switch branches
114
114
  - `feinai git merge`, `rebase`, `reset`, `cherry-pick`
115
115
  - `feinai git fetch`, `pull`, `remote`, `clone`
116
116
  - `feinai git stash`, `tag`
117
- - `feinai git worktree remove` — solo tras push exitoso
117
+ - `feinai git worktree remove` — only after successful push
118
118
 
119
- Si `feinai git` falla → **STOP**. No reintentes, no uses `git`. Reportá al usuario.
119
+ If `feinai git` fails → **STOP**. Do not retry, do not use `git`. Report to the user.
120
120
 
121
121
  ---
122
122
 
123
- ## Reglas absolutas
123
+ ## Absolute rules
124
124
 
125
- **No modifiques:**
125
+ **Do not modify:**
126
126
  - `AGENTS.md`, `CLAUDE.md`
127
- - Archivos de configuración de CI/CD, infra, o secretos (`.env`, `.env.*`)
128
- - La DB de feinai directamente
127
+ - CI/CD, infrastructure, or secret configuration files (`.env`, `.env.*`)
128
+ - The feinai DB directly
129
129
 
130
- **Código:**
131
- - Sin `any` sin comentario justificado en la misma línea
132
- - Si un test falla: corregí el test O la implementación. Nunca silencies, skipees, ni agregues workarounds para que el gate "pase"
133
- - Si no es obvio cuál es la causa → **STOP**, reportá al usuario con el comando exacto y el output completo
130
+ **Code:**
131
+ - No `any` without a justified comment on the same line
132
+ - If a test fails: fix either the test or the implementation. Never silence, skip, or add workarounds to make the gate "pass"
133
+ - If the cause is not obvious → **STOP**, report to the user with the exact command and the full output
@@ -48,8 +48,8 @@ as a minimal change (just the input + endpoint call) or shall I think wider
48
48
 
49
49
  Read **in this order, stop early if enough**:
50
50
 
51
- 1. `CLAUDE.md` (root) — already in your context normally
52
- 2. `.claude/ARCHITECTURE.md` or `ARCHITECTURE.md` (root)
51
+ 1. `AGENTS.md` (root) — already in your context normally
52
+ 2. `ARCHITECTURE.md` (root)
53
53
  3. `README.md`
54
54
  4. `decisions/` directory (just file names, read only if a name matches the topic)
55
55
 
package/src/cli.ts CHANGED
@@ -47,6 +47,13 @@ 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
58
  const VERSION = "0.6.7";
52
59
 
@@ -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", () => {
@@ -779,6 +779,7 @@
779
779
  let activeTaskStatuses = new Set();
780
780
  let activeSpecStatuses = new Set();
781
781
  let activeEventFilter = '';
782
+ let visibleSpecIds = new Set();
782
783
  let lastDbEvents = [];
783
784
 
784
785
  function relativeTime(dateStr) {
@@ -1004,6 +1005,7 @@
1004
1005
 
1005
1006
  function renderSpecs(specs) {
1006
1007
  const filtered = activeSpecStatuses.size > 0 ? specs.filter((s) => activeSpecStatuses.has(s.status)) : specs;
1008
+ visibleSpecIds = new Set(filtered.map(s => s.id));
1007
1009
  $("#specs-count").textContent = filtered.length;
1008
1010
  if (!filtered.length) {
1009
1011
  $("#specs-list").innerHTML = '<div class="empty">no specs match filter</div>';
@@ -1040,7 +1042,14 @@
1040
1042
  }
1041
1043
 
1042
1044
  function renderTasks(tasks) {
1043
- 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;
1044
1053
  $("#tasks-count").textContent = filtered.length;
1045
1054
  if (!filtered.length) {
1046
1055
  $("#tasks-list").innerHTML = '<div class="empty">no tasks match filter</div>';
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}`)}`,
@@ -0,0 +1,180 @@
1
+ import { existsSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+ import type { DbInstance } from "./db";
4
+
5
+ export interface ServerState {
6
+ port: number;
7
+ pid: number;
8
+ started_at: string;
9
+ }
10
+
11
+ /**
12
+ * Read the single server_state row from the DB.
13
+ * Returns null if no row exists.
14
+ */
15
+ export function readServerState(db: DbInstance): ServerState | null {
16
+ const row = db
17
+ .prepare("SELECT port, pid, started_at FROM server_state WHERE id = 1")
18
+ .get() as { port: number; pid: number; started_at: string } | undefined;
19
+ return row ?? null;
20
+ }
21
+
22
+ /**
23
+ * Write (insert or replace) the server_state row.
24
+ */
25
+ export function writeServerState(db: DbInstance, port: number, pid: number): void {
26
+ db.run(
27
+ `INSERT OR REPLACE INTO server_state (id, port, pid, started_at, updated_at)
28
+ VALUES (1, ?, ?, datetime('now'), datetime('now'))`,
29
+ port,
30
+ pid,
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Delete the server_state row.
36
+ */
37
+ export function clearServerState(db: DbInstance): void {
38
+ db.run("DELETE FROM server_state WHERE id = 1");
39
+ }
40
+
41
+ /**
42
+ * Check whether the given port is in use by any process.
43
+ * Uses lsof on Linux/macOS.
44
+ */
45
+ export function isPortInUse(port: number): boolean {
46
+ try {
47
+ const result = spawnSync("lsof", ["-ti", `tcp:${port}`]);
48
+ return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Scan upward from startPort until a free port is found.
56
+ * Returns the first free port.
57
+ */
58
+ export function findFreePort(startPort: number): number {
59
+ let port = startPort;
60
+ while (isPortInUse(port)) {
61
+ port++;
62
+ if (port > startPort + 100) {
63
+ throw new Error(
64
+ `No free port found after scanning from ${startPort} to ${port}`,
65
+ );
66
+ }
67
+ }
68
+ return port;
69
+ }
70
+
71
+ /**
72
+ * Return the pid(s) listening on the given port.
73
+ * Uses lsof -ti tcp:${port} and parses the output.
74
+ * Returns an array of pids, empty if none.
75
+ */
76
+ function pidsOnPort(port: number): number[] {
77
+ try {
78
+ const result = spawnSync("lsof", ["-ti", `tcp:${port}`]);
79
+ if (result.exitCode !== 0) return [];
80
+ const out = result.stdout.toString().trim();
81
+ if (!out) return [];
82
+ return out
83
+ .split("\n")
84
+ .map((s) => parseInt(s.trim(), 10))
85
+ .filter((n) => !isNaN(n));
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Check if a specific pid is running.
93
+ * Uses kill(pid, 0) via /proc on Linux or kill command.
94
+ */
95
+ function isPidRunning(pid: number): boolean {
96
+ try {
97
+ // kill(pid, 0) checks if the process exists without sending a signal
98
+ return process.kill(pid, 0);
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Get the port that a specific pid is listening on.
106
+ * Returns the port number or null if the pid has no tcp listeners.
107
+ */
108
+ function portOfPid(pid: number): number | null {
109
+ try {
110
+ const result = spawnSync("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-p", String(pid)]);
111
+ if (result.exitCode !== 0) return null;
112
+ // Parse lsof output: find "TCP *:{port}" pattern
113
+ const out = result.stdout.toString();
114
+ const match = out.match(/TCP\s+\*:(\d+)/);
115
+ if (match) return parseInt(match[1]!, 10);
116
+ // Alternate format: "LISTEN" followed by port
117
+ const altMatch = out.match(/:(\d+)\s.*LISTEN/);
118
+ if (altMatch) return parseInt(altMatch[1]!, 10);
119
+ return null;
120
+ } catch {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Validate the server_state row for consistency.
127
+ *
128
+ * A row is considered valid only when:
129
+ * - The stored pid is running AND
130
+ * - That pid is listening on the stored port
131
+ *
132
+ * If the row is stale (in any way), it is deleted and false is returned.
133
+ *
134
+ * Returns { valid: boolean, record: ServerState | null }
135
+ */
136
+ export function validateServerState(db: DbInstance): {
137
+ valid: boolean;
138
+ record: ServerState | null;
139
+ } {
140
+ const record = readServerState(db);
141
+ if (!record) return { valid: false, record: null };
142
+
143
+ // Check if stored pid is running
144
+ if (!isPidRunning(record.pid)) {
145
+ clearServerState(db);
146
+ return { valid: false, record: null };
147
+ }
148
+
149
+ // Check what pids are listening on the stored port
150
+ const pids = pidsOnPort(record.port);
151
+ if (pids.length === 0) {
152
+ // Port has no listener at all
153
+ clearServerState(db);
154
+ return { valid: false, record: null };
155
+ }
156
+ if (!pids.includes(record.pid)) {
157
+ // Port is busy but by a different pid
158
+ clearServerState(db);
159
+ return { valid: false, record: null };
160
+ }
161
+
162
+ // Check that the stored pid is listening on the stored port specifically
163
+ const actualPort = portOfPid(record.pid);
164
+ if (actualPort !== null && actualPort !== record.port) {
165
+ // The pid is running but listening on a different port
166
+ clearServerState(db);
167
+ return { valid: false, record: null };
168
+ }
169
+
170
+ return { valid: true, record };
171
+ }
172
+
173
+ /**
174
+ * Repair the server_state row: validate consistency, delete if stale.
175
+ * Returns the valid record or null if stale/missing.
176
+ */
177
+ export function repairServerState(db: DbInstance): ServerState | null {
178
+ const { valid, record } = validateServerState(db);
179
+ return valid ? record : null;
180
+ }
package/src/server.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import { dirname, basename, resolve, join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { openDb, findDbPath, type DbInstance } from "./db";
5
+ import { clearServerState } from "./server-state";
5
6
  import {
6
7
  listTasks,
7
8
  getTask,
@@ -630,7 +631,18 @@ export function startServer(opts: ServerOptions): { url: string; stop: () => voi
630
631
 
631
632
  return {
632
633
  url: `http://${server.hostname}:${server.port}`,
633
- stop: () => server.stop(),
634
+ stop: () => {
635
+ // Clear the server_state row on graceful shutdown
636
+ try {
637
+ const db = openDb();
638
+ clearServerState(db);
639
+ db.close();
640
+ } catch {
641
+ // DB might not exist or be inaccessible; ignore to avoid masking
642
+ // the actual server stop.
643
+ }
644
+ server.stop();
645
+ },
634
646
  };
635
647
  }
636
648