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 +16 -0
- package/README.md +6 -6
- 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 +2 -2
- package/src/cli.ts +122 -24
- package/src/dashboard.html +10 -1
- 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/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
|
-
##
|
|
112
|
+
## Agent harness skills
|
|
113
113
|
|
|
114
|
-
feinai ships three
|
|
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
|
|
124
|
+
### Activating skills
|
|
125
125
|
|
|
126
126
|
```sh
|
|
127
|
-
mkdir -p
|
|
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"
|
|
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
|
|
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.
|
|
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
|
|
79
|
+
### Failure: skills not activating in your agent harness
|
|
80
80
|
|
|
81
|
-
If
|
|
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
|
|
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"
|
|
88
|
+
> ln -sf "$SKILLS/$skill" <your-harness-skills-dir>/$skill
|
|
89
89
|
> done
|
|
90
90
|
> ```
|
|
91
|
-
> Then restart
|
|
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 .
|
|
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 .
|
|
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 .
|
|
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 `.
|
|
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
|
-
##
|
|
20
|
+
## On startup
|
|
21
21
|
|
|
22
|
-
1. `feinai list --pending --json` —
|
|
23
|
-
2.
|
|
24
|
-
3. `feinai take <TASK-ID> --owner implement-agent` —
|
|
25
|
-
4.
|
|
26
|
-
5.
|
|
22
|
+
1. `feinai list --pending --json` — find the first available task (no pending blockers)
|
|
23
|
+
2. If none exists → respond "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 tasks → release with `feinai release <TASK-ID>` and stop
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Read `AGENTS.md` of the project for architecture and project-specific conventions.
|
|
29
29
|
|
|
30
30
|
---
|
|
31
31
|
|
|
32
|
-
##
|
|
32
|
+
## Execute the task
|
|
33
33
|
|
|
34
|
-
**
|
|
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
|
-
**
|
|
41
|
-
|
|
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
|
-
**
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
43
|
+
**Step 3 — Read before writing:**
|
|
44
|
+
- The full task description (`feinai show <TASK-ID>`)
|
|
45
|
+
- The files you will touch — read them before editing
|
|
46
|
+
- If there is a **Workplan** in the description → follow those steps in exact order
|
|
47
47
|
|
|
48
|
-
**
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
-
|
|
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 exists → extend it instead of overwriting if it already contains valid content
|
|
52
52
|
|
|
53
|
-
**
|
|
54
|
-
|
|
53
|
+
**Step 5 — Commit:**
|
|
54
|
+
One commit per task. Conventional commits:
|
|
55
55
|
```
|
|
56
|
-
feat(scope):
|
|
56
|
+
feat(scope): concise description
|
|
57
57
|
```
|
|
58
|
-
|
|
58
|
+
Types: `feat`, `fix`, `refactor`, `test`, `chore`.
|
|
59
59
|
|
|
60
|
-
**
|
|
61
|
-
|
|
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
|
-
**
|
|
63
|
+
**Step 7 — Close:**
|
|
64
64
|
|
|
65
|
-
Gates
|
|
65
|
+
Gates pass:
|
|
66
66
|
```bash
|
|
67
|
-
#
|
|
67
|
+
# From the worktree:
|
|
68
68
|
feinai git push origin HEAD:main
|
|
69
69
|
|
|
70
|
-
#
|
|
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
|
|
77
|
+
Gates fail → follow "If something fails".
|
|
78
78
|
|
|
79
|
-
**Done = 3
|
|
80
|
-
1. Quality gates
|
|
81
|
-
2.
|
|
82
|
-
3.
|
|
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
|
-
##
|
|
86
|
+
## If something fails
|
|
87
87
|
|
|
88
|
-
Gates
|
|
88
|
+
Gates fail, push fails, or error at any step:
|
|
89
89
|
|
|
90
|
-
1. **
|
|
91
|
-
2. Push a
|
|
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.
|
|
95
|
+
3. Mark the task as failed:
|
|
96
96
|
```bash
|
|
97
|
-
feinai fail <TASK-ID> --error "<
|
|
97
|
+
feinai fail <TASK-ID> --error "<exact command + relevant output>"
|
|
98
98
|
```
|
|
99
|
-
4.
|
|
99
|
+
4. Leave the worktree intact for manual recovery
|
|
100
100
|
|
|
101
101
|
---
|
|
102
102
|
|
|
103
|
-
## Git — `feinai git`
|
|
103
|
+
## Git — `feinai git` exclusively
|
|
104
104
|
|
|
105
|
-
`git`
|
|
105
|
+
`git` and `gh` are blocked. Use `feinai git` for everything — it is opengit bundled with feinai.
|
|
106
106
|
|
|
107
|
-
**
|
|
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` —
|
|
110
|
+
- `feinai git complete` — syncs local main after push (only from repo root)
|
|
111
111
|
|
|
112
|
-
**
|
|
113
|
-
- `feinai git branch`, `checkout`, `switch` —
|
|
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` —
|
|
117
|
+
- `feinai git worktree remove` — only after successful push
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
If `feinai git` fails → **STOP**. Do not retry, do not use `git`. Report to the user.
|
|
120
120
|
|
|
121
121
|
---
|
|
122
122
|
|
|
123
|
-
##
|
|
123
|
+
## Absolute rules
|
|
124
124
|
|
|
125
|
-
**
|
|
125
|
+
**Do not modify:**
|
|
126
126
|
- `AGENTS.md`, `CLAUDE.md`
|
|
127
|
-
-
|
|
128
|
-
-
|
|
127
|
+
- CI/CD, infrastructure, or secret configuration files (`.env`, `.env.*`)
|
|
128
|
+
- The feinai DB directly
|
|
129
129
|
|
|
130
|
-
**
|
|
131
|
-
-
|
|
132
|
-
-
|
|
133
|
-
-
|
|
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. `
|
|
52
|
-
2.
|
|
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 "
|
|
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
|
@@ -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
|
-
|
|
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: () =>
|
|
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
|
|