ftown-bridge 0.18.2 → 0.19.1

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.
@@ -3,7 +3,10 @@ export declare class SessionStore {
3
3
  private readonly sessionsDir;
4
4
  private readonly archivePath;
5
5
  private readonly writeLocks;
6
- constructor(dataDir: string);
6
+ private readonly maxTerminalLogBytes;
7
+ constructor(dataDir: string, options?: {
8
+ maxTerminalLogBytes?: number;
9
+ });
7
10
  /** Per-session data dir (session.json, terminal.log, inbox.jsonl). */
8
11
  sessionDir(sessionId: string): string;
9
12
  private sessionFilePath;
@@ -12,6 +15,17 @@ export declare class SessionStore {
12
15
  loadSession(sessionId: string): Promise<Session | null>;
13
16
  listSessions(): Promise<Session[]>;
14
17
  appendTerminalData(sessionId: string, data: string): Promise<void>;
18
+ /**
19
+ * Cap a terminal.log's on-disk size. Runs inside the per-session write lock,
20
+ * so it never races an append. The file is allowed to grow to 2x the retained
21
+ * size before being rewritten down to the tail — this amortises the rewrite
22
+ * cost while bounding disk use to at most 2x `maxTerminalLogBytes` per session.
23
+ * The partial first line of the retained tail is dropped so consumers never
24
+ * start mid-line (or mid-escape-sequence), and a one-line marker records that
25
+ * older output was discarded. Best-effort: any failure is swallowed so it can
26
+ * never poison the write-lock chain and stall future appends.
27
+ */
28
+ private trimTerminalLogIfNeeded;
15
29
  deleteSession(sessionId: string): Promise<void>;
16
30
  /** Append a tombstone for a removed session to <dataDir>/archive.jsonl. */
17
31
  archiveSession(session: Session): Promise<void>;
@@ -1,13 +1,29 @@
1
- import { readFile, writeFile, mkdir, readdir, appendFile, rm, truncate } from 'node:fs/promises';
1
+ import { readFile, writeFile, mkdir, readdir, appendFile, rm, truncate, stat, open, rename } from 'node:fs/promises';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { existsSync } from 'node:fs';
4
+ /**
5
+ * Retained tail size for a session's terminal.log. A misbehaving child (e.g. a
6
+ * TUI stuck in a runaway repaint loop) can otherwise stream unbounded output
7
+ * and grow this file without limit — observed in the wild at 2.5 GB and still
8
+ * climbing, which also makes the full-file `loadTerminalLog` read exceed V8's
9
+ * max string length and throw. The on-disk file is only ever consumed as
10
+ * "recent output" (download/search); the live screen is driven by a separate
11
+ * in-memory emulator, so keeping just the tail is lossless for viewers.
12
+ */
13
+ const DEFAULT_MAX_TERMINAL_LOG_BYTES = 64 * 1024 * 1024;
14
+ const NEWLINE = 0x0a;
4
15
  export class SessionStore {
5
16
  sessionsDir;
6
17
  archivePath;
7
18
  writeLocks = new Map();
8
- constructor(dataDir) {
19
+ maxTerminalLogBytes;
20
+ constructor(dataDir, options = {}) {
9
21
  this.sessionsDir = join(dataDir, 'sessions');
10
22
  this.archivePath = join(dataDir, 'archive.jsonl');
23
+ const envCap = Number(process.env.FTOWN_MAX_TERMINAL_LOG_BYTES);
24
+ this.maxTerminalLogBytes =
25
+ options.maxTerminalLogBytes ??
26
+ (Number.isFinite(envCap) && envCap > 0 ? envCap : DEFAULT_MAX_TERMINAL_LOG_BYTES);
11
27
  }
12
28
  /** Per-session data dir (session.json, terminal.log, inbox.jsonl). */
13
29
  sessionDir(sessionId) {
@@ -53,10 +69,54 @@ export class SessionStore {
53
69
  await mkdir(dir, { recursive: true });
54
70
  const filePath = this.terminalLogPath(sessionId);
55
71
  const prevLock = this.writeLocks.get(sessionId) ?? Promise.resolve();
56
- const newLock = prevLock.then(() => appendFile(filePath, data, 'utf-8'));
72
+ const newLock = prevLock
73
+ .then(() => appendFile(filePath, data, 'utf-8'))
74
+ .then(() => this.trimTerminalLogIfNeeded(filePath));
57
75
  this.writeLocks.set(sessionId, newLock);
58
76
  await newLock;
59
77
  }
78
+ /**
79
+ * Cap a terminal.log's on-disk size. Runs inside the per-session write lock,
80
+ * so it never races an append. The file is allowed to grow to 2x the retained
81
+ * size before being rewritten down to the tail — this amortises the rewrite
82
+ * cost while bounding disk use to at most 2x `maxTerminalLogBytes` per session.
83
+ * The partial first line of the retained tail is dropped so consumers never
84
+ * start mid-line (or mid-escape-sequence), and a one-line marker records that
85
+ * older output was discarded. Best-effort: any failure is swallowed so it can
86
+ * never poison the write-lock chain and stall future appends.
87
+ */
88
+ async trimTerminalLogIfNeeded(filePath) {
89
+ try {
90
+ const { size } = await stat(filePath);
91
+ if (size <= this.maxTerminalLogBytes * 2) {
92
+ return;
93
+ }
94
+ const keep = this.maxTerminalLogBytes;
95
+ const fh = await open(filePath, 'r');
96
+ let tail;
97
+ try {
98
+ const buf = Buffer.alloc(keep);
99
+ const { bytesRead } = await fh.read(buf, 0, keep, size - keep);
100
+ tail = buf.subarray(0, bytesRead);
101
+ }
102
+ finally {
103
+ await fh.close();
104
+ }
105
+ // Drop the (almost certainly partial) first line so the retained log
106
+ // begins on a clean line boundary.
107
+ const nl = tail.indexOf(NEWLINE);
108
+ const body = nl >= 0 ? tail.subarray(nl + 1) : tail;
109
+ const marker = Buffer.from(`[ftown] terminal.log truncated — retaining last ${keep} bytes\r\n`, 'utf-8');
110
+ // Rewrite via a temp file + atomic rename so a concurrent full-file reader
111
+ // (loadTerminalLog runs outside the lock) never observes a partial file.
112
+ const tmpPath = `${filePath}.trim-${process.pid}.tmp`;
113
+ await writeFile(tmpPath, Buffer.concat([marker, body]));
114
+ await rename(tmpPath, filePath);
115
+ }
116
+ catch (err) {
117
+ console.error(`[SessionStore] Failed to trim terminal log ${filePath}:`, err);
118
+ }
119
+ }
60
120
  async deleteSession(sessionId) {
61
121
  const dir = this.sessionDir(sessionId);
62
122
  if (existsSync(dir)) {
@@ -1 +1 @@
1
- {"version":3,"file":"session-store.js","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"session-store.js","sourceRoot":"","sources":["../src/session-store.ts"],"names":[],"mappings":"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"}
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- export type ShellType = 'claude' | 'cursor' | 'codex' | 'shell' | 'zai' | 'kimi' | 'opencode' | 'deepseek' | 'fireworks';
1
+ export type ShellType = 'claude' | 'cursor' | 'codex' | 'shell' | 'zai' | 'kimi' | 'opencode' | 'deepseek' | 'fireworks' | 'grok';
2
2
  export type SessionRuntime = 'tmux' | 'direct';
3
3
  export interface Session {
4
4
  id: string;
@@ -26,7 +26,7 @@ export type SessionStatus = 'pending' | 'running' | 'completed' | 'error';
26
26
  export interface ArchivedSession extends Session {
27
27
  removedAt: string;
28
28
  }
29
- export type LoopHarness = 'claude' | 'cursor' | 'codex' | 'opencode' | 'shell';
29
+ export type LoopHarness = 'claude' | 'cursor' | 'codex' | 'grok' | 'opencode' | 'shell';
30
30
  export type LoopRunStatus = 'ok' | 'error' | 'running' | 'skipped';
31
31
  export type LoopSchedule = {
32
32
  kind: 'interval';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ftown-bridge",
3
- "version": "0.18.2",
3
+ "version": "0.19.1",
4
4
  "description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -0,0 +1,167 @@
1
+ ---
2
+ name: factory
3
+ description: deploy and manage a per-project AI software factory (init/up/down/status/teardown) — FTS ticket pipeline + ftown loops. Trigger on "factory init", "deploy a factory", "factory status", "pause the factory".
4
+ ---
5
+
6
+ <!-- Vendored from the ffactory repo (skills/factory + factory-template); sync changes there first. -->
7
+
8
+ # Factory
9
+
10
+ You are a capable model helping a human deploy and operate a per-project AI software
11
+ factory: a `fticket` (`fts`) ticket pipeline driven by three ftown loops (a dispatcher
12
+ that spawns stage workers, a preflight-guarded interval triage sweep, and a daily digest
13
+ report). This skill is procedures, not narrative — follow the exact commands. It runs
14
+ inside the target project repo (`$REPO` = repo root, resolved via
15
+ `git rev-parse --show-toplevel`).
16
+
17
+ ## Layout
18
+
19
+ - `factory/` — checked into the project repo, the factory's *definition*:
20
+ `factory.yaml` (stages, limits, triage config), `skills/*.md` (stage worker briefings +
21
+ `_protocol.md`), `bin/dispatch.py`.
22
+ - `.ffactory/` — gitignored, the factory's *runtime state*: `factory.db` (fts sqlite),
23
+ `tickets/<n>/` (per-ticket artifact folders), `worktrees/` (implement-stage checkouts),
24
+ `dispatch.cursor` (dispatcher bookmark). Disposable — deleting it loses history but not
25
+ the factory's definition.
26
+
27
+ ## init
28
+
29
+ Idempotent. If `factory/` already exists in `$REPO`, stop and tell the user the factory is
30
+ already initialized (point them at `status`/`up`/`down` instead) — do not overwrite.
31
+
32
+ 1. **Preconditions.** Confirm `$REPO` is a git repo. Run `~/.ftown/ftown-sessions list` —
33
+ if it errors, the bridge is down; stop and tell the user to start it first. Confirm
34
+ `uv` is on PATH.
35
+ 2. **Install fticket.** `uv tool install fticket` (or `uv tool upgrade fticket` if already
36
+ installed). Verify with `fts --help`.
37
+ 3. **Copy the template.**
38
+ ```bash
39
+ cp -r ~/.ftown/skills/factory/factory-template "$REPO/factory"
40
+ ```
41
+ `factory/bin` moves in as-is (no edits). Ask the user for: project name (default: the
42
+ repo directory's basename), an operator ftown session id (optional — default `-`, no
43
+ operator mail), and any stage routing tweaks (harness/model/max_workers per stage).
44
+ Edit `factory/factory.yaml` with their answers.
45
+ 4. **Gitignore + state dirs.** Append `.ffactory/` to `$REPO/.gitignore` (create the file
46
+ if absent). `mkdir -p "$REPO/.ffactory/tickets"`.
47
+ 5. **Init the ticket db**, reading stage order and limits back out of the edited
48
+ `factory.yaml`:
49
+ ```bash
50
+ fts init --db .ffactory/factory.db \
51
+ --stages groom,design,implement,review,qa,pr \
52
+ --bounce-limit 3 --claim-ttl-ms 1800000
53
+ ```
54
+ (substitute the actual stage names in yaml order, `limits.bounce_limit`, and
55
+ `limits.claim_ttl_ms`).
56
+ 6. **Register the loops, grouped under one factory label.** The `--group` flag folds all
57
+ loops together in the ftown dashboard and requires ftown bridge PR #29. If
58
+ `loop create` rejects `--group` as an unknown flag, tell the user their bridge is out
59
+ of date and needs updating — do not silently drop the flag and register ungrouped.
60
+
61
+ Dispatcher (interval loop, runs the Python dispatcher every tick):
62
+ ```bash
63
+ ~/.ftown/ftown-sessions loop create \
64
+ --name "<project>-dispatch" --every 30s --shell shell --workdir "$REPO" \
65
+ --group "Factory: <project>" --max-runtime 10m --retention 10 \
66
+ --preflight "~/.local/bin/fts queues --db $REPO/.ffactory/factory.db --json | python3 -c 'import json,sys; qs=json.load(sys.stdin); sys.exit(0 if any(q.get(\"queued\",0)+q.get(\"claimed\",0)+q.get(\"in_progress\",0)+q.get(\"blocked\",0) for q in qs) else 1)'" \
67
+ --task "uv run --python 3.13 --with fticket,pyyaml python factory/bin/dispatch.py"
68
+ ```
69
+ (`--every` from `limits.dispatch_every`. The preflight skips the tick — no shell
70
+ session — while no ticket is queued/claimed/in_progress/blocked, so an idle factory
71
+ costs nothing. A resting `rejected`/`dead_letter` ticket doesn't wake it: those belong
72
+ to explicit chaining and triage respectively.)
73
+
74
+ Triage (interval loop with a preflight skip guard — only spawns a session when there
75
+ is dead-letter/orphan work; requires ftown bridge >= 0.18.0 for cheap preflight skips —
76
+ on 0.17 the preflight still works but bloats run history since a skip still logs a
77
+ run):
78
+ ```bash
79
+ ~/.ftown/ftown-sessions loop create \
80
+ --name "<project>-triage" --every 10m --shell claude --model sonnet \
81
+ --workdir "$REPO" --group "Factory: <project>" --retention 10 \
82
+ --preflight "~/.local/bin/fts triage --db $REPO/.ffactory/factory.db --json | python3 -c 'import json,sys; d=json.load(sys.stdin); sys.exit(0 if d[\"dead_letter\"] or d[\"orphans\"] else 1)'" \
83
+ --task "FTS_DB=<abs path to .ffactory/factory.db> REPO_ROOT=<abs $REPO> OPERATOR_SESSION=<value or -> — Read and follow factory/skills/triage.md."
84
+ ```
85
+ (`--every`/`--shell`/`--model` from `triage.every`/`triage.harness`/`triage.model`.)
86
+ The preflight exits 0 (work exists → run the session) when `fts triage --json` reports
87
+ any dead_letter or orphan tickets, and exits 1 (healthy → skip, no session spawned)
88
+ otherwise. The triage briefing must set the three task variables literally;
89
+ `triage.md` treats a missing variable as fatal.
90
+
91
+ Digest (daily cron loop, shift report mailed to the operator) — register this loop
92
+ ONLY when `operator_session` in `factory.yaml` is not `-`; skip it entirely otherwise,
93
+ since there is nowhere to mail the digest:
94
+ ```bash
95
+ ~/.ftown/ftown-sessions loop create \
96
+ --name "<project>-digest" --cron "0 9 * * *" --shell claude --model sonnet \
97
+ --workdir "$REPO" --group "Factory: <project>" --retention 5 \
98
+ --task "FTS_DB=<abs path to .ffactory/factory.db> REPO_ROOT=<abs $REPO> OPERATOR_SESSION=<operator> — Read and follow factory/skills/digest.md."
99
+ ```
100
+ (`--cron`/`--shell`/`--model` from `digest.cron`/`digest.harness`/`digest.model`.)
101
+ 7. **Wrap up.** Suggest the user commit `factory/` (not `.ffactory/`). Show them how to
102
+ create the first ticket:
103
+ ```bash
104
+ mkdir -p .ffactory/tickets/1
105
+ # write .ffactory/tickets/1/request.md with the ask
106
+ fts create --db .ffactory/factory.db --title "<title>" --stage groom \
107
+ --folder .ffactory/tickets/1
108
+ ```
109
+ and how to watch it: `fts serve --db .ffactory/factory.db --port 8377` (dashboard).
110
+
111
+ ## up / down
112
+
113
+ Resume or pause every registered loop by name. Loops don't expose lookup-by-name
114
+ directly, so filter `loops` output for the project's `--group` label (`Factory:
115
+ <project>`) to get ids first — this naturally covers whichever loops are actually
116
+ registered (dispatch + triage always; digest only if an operator is configured):
117
+
118
+ ```bash
119
+ ~/.ftown/ftown-sessions loops # find ids for all loops in group "Factory: <project>"
120
+ ~/.ftown/ftown-sessions loop resume <loop-id> # up — repeat for each loop id found
121
+ ~/.ftown/ftown-sessions loop pause <loop-id> # down — repeat for each loop id found
122
+ ```
123
+
124
+ ## status
125
+
126
+ Report all of:
127
+
128
+ 1. `fts board --db .ffactory/factory.db` — ticket counts by stage.
129
+ 2. `fts queues --db .ffactory/factory.db` — per-stage claim queues.
130
+ 3. `fts doctor --db .ffactory/factory.db` — health checks (flag any failure first).
131
+ 4. `~/.ftown/ftown-sessions loop runs <loop-id>` for every loop id registered under
132
+ `Factory: <project>` (dispatch, triage, and digest if present) — last few runs of
133
+ each, success/error. For triage specifically, also check `loop get <triage-id>` for
134
+ `lastSkipAt`/`lastSkipReason` — frequent skips are healthy (the preflight guard found
135
+ no dead-letter/orphan work).
136
+ 5. Active workers: `~/.ftown/ftown-sessions list` filtered to names starting with
137
+ `<project>-t` (worker sessions the dispatcher spawned, distinct from the loop names
138
+ themselves which start with `<project>-dispatch`/`<project>-triage`/`<project>-digest`).
139
+
140
+ ## run-now
141
+
142
+ Force an immediate dispatcher tick without waiting for the interval:
143
+ ```bash
144
+ ~/.ftown/ftown-sessions loop run <dispatch-loop-id>
145
+ ```
146
+
147
+ ## teardown
148
+
149
+ Confirm with the user first — this is destructive to scheduling, distinct from deleting
150
+ data.
151
+
152
+ 1. Pause then delete every loop registered under `Factory: <project>`: `loop pause <id>`
153
+ then `loop delete <id>` for dispatch, triage, and digest (if it was registered).
154
+ 2. Remind the user: `.ffactory/` is disposable runtime state (db, tickets, worktrees) —
155
+ only delete it if they explicitly confirm, since it's the ticket history. `factory/`
156
+ is the checked-in definition — keep it (removing it is a separate, deliberate repo
157
+ change, not part of teardown).
158
+
159
+ ## Troubleshooting
160
+
161
+ | Symptom | Cause / fix |
162
+ | --- | --- |
163
+ | `ftown-sessions list` errors at any step | Bridge is down — start the ftown bridge before retrying. |
164
+ | `loop create --group` rejected as unknown flag | Bridge predates ftown PR #29 — tell the user to update the bridge; do not drop `--group` and register loops ungrouped. |
165
+ | Workers never spawn even though tickets are queued | Check `loop runs <dispatch-id>` for run status first, then the dispatcher's own stderr (surfaced in the run's session output) for `dispatch.py` errors — e.g. `max_sessions` cap reached, or a claim that keeps expiring. |
166
+ | A ticket looks stuck in one stage | `fts why --db .ffactory/factory.db <id>` — gives a stuck diagnosis (expired claim, bounce-limit hit, missing resource lease, etc.) without needing to read the raw db. |
167
+ | Triage never fires | Check `loop get <triage-id>` for `lastSkipAt`/`lastSkipReason` — preflight guard skipping is healthy when there are no dead-letter/orphan tickets; only worry if `fts triage --json` shows work but the loop still skips. |