@@ -160,3 +160,3 @@ // Claude Code as a Backend (see types.ts). The process-backed family: there is no server, so a | ||
| // The text rides behind `--` for the same leading-dash reason as dispatch. | ||
| prompt: async (id, text, directory) => run(['--resume', id, '-p', '--output-format=stream-json', '--verbose', '--', text], id, directory), | ||
| prompt: async (id, text, directory) => run(['--resume', assertSessionId(id), '-p', '--output-format=stream-json', '--verbose', '--', text], id, directory), | ||
| // Same subscription contract as opencode's, satisfied by polling rather than by a socket: | ||
@@ -163,0 +163,0 @@ // stop() ends the loop, done resolves when the loop has actually finished. |
@@ -127,3 +127,3 @@ // GitHub Copilot CLI as a Backend (see types.ts). Process-backed: there is no server, so a session | ||
| async prompt(id, text, directory) { | ||
| return { id, pid: run(id, [`--resume=${id}`], text, directory, []) }; | ||
| return { id, pid: run(id, [`--resume=${assertSessionId(id)}`], text, directory, []) }; | ||
| }, | ||
@@ -130,0 +130,0 @@ // Polling wearing a subscription's clothes: there is no stream to subscribe to. The |
| import { authHeader } from "./client.js"; | ||
| export function parseSseChunk(buffer) { | ||
| const events = []; | ||
| const blocks = buffer.split('\n\n'); | ||
| // SSE permits CRLF line endings, not just LF — a CRLF/proxied server would otherwise never frame. | ||
| const blocks = buffer.split(/\r?\n\r?\n/); | ||
| const rest = blocks.pop() ?? ''; // partial block stays buffered | ||
| for (const block of blocks) { | ||
| for (const line of block.split('\n')) { | ||
| for (const line of block.split(/\r?\n/)) { | ||
| if (!line.startsWith('data:')) | ||
@@ -9,0 +10,0 @@ continue; |
@@ -39,3 +39,10 @@ // Reaping the captured run logs both process-backed backends keep under the config dir. The two | ||
| } | ||
| rmSync(join(dir, name), { force: true }); | ||
| try { | ||
| // Best effort: an EACCES/EPERM on one log must skip it, not throw out of reapRunLogs into | ||
| // dispatch() after the child already spawned (which would lose the run ref). | ||
| rmSync(join(dir, name), { force: true }); | ||
| } | ||
| catch { | ||
| continue; | ||
| } | ||
| reaped.push(name.slice(0, -'.jsonl'.length)); | ||
@@ -42,0 +49,0 @@ } |
+30
-0
@@ -16,2 +16,6 @@ // Agent view has a shell surface next to the view itself — `claude agents --cwd/--json`, and | ||
| fleetview --cwd <path> open it scoped to sessions under <path> | ||
| The shell commands below (ls/--json/attach/logs/stop/rm/bg) act on opencode | ||
| sessions only; the roster TUI shows sessions from every backend. | ||
| fleetview --json [--all] print sessions as JSON instead of opening the roster | ||
@@ -24,2 +28,3 @@ fleetview ls [--all] the same list, one line per session | ||
| fleetview bg "<prompt>" dispatch a background session without opening the roster | ||
| --cwd <path> dispatch in <path> instead of the current directory | ||
| --name <title> name the session instead of waiting for opencode's title | ||
@@ -31,2 +36,3 @@ --agent <name> run as that subagent | ||
| fleetview server stop stop that server (all sessions stop streaming until restart) | ||
| fleetview --version print the version (-v) | ||
| fleetview --help this text`; | ||
@@ -85,2 +91,20 @@ const SUBCOMMANDS = new Set(['attach', 'logs', 'stop', 'rm', 'ls', 'server', 'bg']); | ||
| } | ||
| // #112.1: the value flags below are parsed for every command but only a few commands act on any | ||
| // given one — `fleetview ls --exec` used to parse clean and silently drop the flag. A flag a | ||
| // command ignores is a usage mistake, so it errors like an unknown option rather than vanishing. | ||
| // Only these five are command-scoped; --all/--cwd/--json are handled inline above and are broadly | ||
| // meaningful. | ||
| const ALLOWED_FLAGS = { | ||
| ui: new Set(['model', 'agent', 'backend']), | ||
| bg: new Set(['name', 'agent', 'model', 'exec']), | ||
| }; | ||
| const flagProblem = (command) => { | ||
| const allowed = ALLOWED_FLAGS[command] ?? new Set(); | ||
| for (const f of ['exec', 'name', 'model', 'agent', 'backend']) { | ||
| const given = f === 'exec' ? out.exec === true : out[f] !== undefined; | ||
| if (given && !allowed.has(f)) | ||
| return `--${f} is not valid for ${command === 'ui' ? 'the roster' : command}`; | ||
| } | ||
| return null; | ||
| }; | ||
| if (rest.length === 0) { | ||
@@ -90,2 +114,5 @@ // `--json` on its own is the listing, which is what agent view's `claude agents --json` means. | ||
| out.command = 'ls'; | ||
| const problem = flagProblem(out.command); | ||
| if (problem) | ||
| return { error: problem }; | ||
| return out; | ||
@@ -97,2 +124,5 @@ } | ||
| out.command = name; | ||
| const problem = flagProblem(name); | ||
| if (problem) | ||
| return { error: problem }; | ||
| if (name === 'ls') | ||
@@ -99,0 +129,0 @@ return out; |
+25
-1
@@ -269,2 +269,10 @@ #!/usr/bin/env node | ||
| // dispatch runs where you pointed it, like the `!` form. | ||
| // #108: `bg` dispatches on opencode only — there is no multi-backend bg dispatch yet, and the | ||
| // parser accepts `--backend` for every command. Reject it loudly rather than silently running the | ||
| // prompt on opencode regardless, the way the roster path rejects a bad `--backend`. | ||
| if (args.backend !== undefined && args.backend !== DEFAULT_BACKEND) { | ||
| error(`bg dispatches on opencode only — --backend ${args.backend} is not supported here`); | ||
| setExitCode(1); | ||
| return; | ||
| } | ||
| const r = await ensureServer(loadServerImpl(serverFile) ?? DEFAULT_SERVER); | ||
@@ -442,3 +450,3 @@ if (!r.ok) { | ||
| // asserting on the printed rows is the only way to know they all ran. | ||
| export async function listSessions({ all, json, cwd }, ensureServer, serverFile, { createClient = (url) => new OpencodeClient(url), loadSeenImpl = loadSeen, seenFile = defaultSeenFile, loadRosterImpl = loadRoster, rosterFile = defaultRosterFile, log = console.log, error = console.error, setExitCode = (code) => { | ||
| export async function listSessions({ all, json, cwd: rawCwd }, ensureServer, serverFile, { createClient = (url) => new OpencodeClient(url), loadSeenImpl = loadSeen, seenFile = defaultSeenFile, loadRosterImpl = loadRoster, rosterFile = defaultRosterFile, log = console.log, error = console.error, setExitCode = (code) => { | ||
| process.exitCode = code; | ||
@@ -452,2 +460,6 @@ }, } = {}) { | ||
| } | ||
| // #107: resolve `--cwd` the same way the roster (resolve at cli.ts) and `bg` (realpathSync) do, | ||
| // so `ls`/`--json` scope against the absolute project paths the roster stores rather than a raw | ||
| // relative string that matches nothing. Resolved once, here, rather than at each underCwd call. | ||
| const cwd = rawCwd === undefined ? undefined : resolve(rawCwd); | ||
| const client = createClient(`http://${r.server.host}:${r.server.port}`); | ||
@@ -593,2 +605,14 @@ const projects = allProjectDirectories(await client.listProjects()); | ||
| await found.client.deleteSession(found.session.id, found.worktree); | ||
| // #105: the session is gone server-side, but the roster still lists it — the next TUI shows a | ||
| // ghost completed row for a session that no longer exists. Drop the member (matched by the same | ||
| // worktree+id key the store uses) from roster.json. A missing/corrupt roster is nothing to | ||
| // prune, so failures here are swallowed — the delete already succeeded. | ||
| try { | ||
| const rosterFile = defaultRosterFile(); | ||
| const roster = loadRoster(rosterFile); | ||
| const kept = roster.sessions.filter((m) => !(m.worktree === found.worktree && m.id === found.session.id)); | ||
| if (kept.length !== roster.sessions.length) | ||
| saveRoster(rosterFile, { ...roster, sessions: kept }); | ||
| } | ||
| catch { } | ||
| // "`claude rm <id>` keeps the worktree if it has uncommitted changes" — and fleetview keeps it for | ||
@@ -595,0 +619,0 @@ // commits that exist nowhere else, which is the case that actually loses work. Reported either |
+12
-2
| // The on-disk mechanics every state store shares (registry, roster-store, seen-store). Extracted | ||
| // verbatim from the three copies each carried — a fix to how fleetview writes a file must land once. | ||
| import { writeFileSync, mkdirSync, chmodSync, renameSync, existsSync } from 'node:fs'; | ||
| import { openSync, writeSync, fsyncSync, closeSync, mkdirSync, chmodSync, renameSync, existsSync } from 'node:fs'; | ||
| import { dirname, join } from 'node:path'; | ||
@@ -29,4 +29,14 @@ // Prefer the fleetview dir under `parent`, but keep reading an existing pre-rename roost one until | ||
| const tmp = `${file}.${process.pid}.tmp`; | ||
| writeFileSync(tmp, data, { mode: 0o600 }); | ||
| // fsync the tmp file's data before the rename, so a crash can't leave a truncated roster.json that | ||
| // loadRoster then throws on. writeFileSync alone returns once the data is in the page cache, not on | ||
| // disk. mode 0o600 for the same perms-on-every-write reason as the rename below. | ||
| const fd = openSync(tmp, 'w', 0o600); | ||
| try { | ||
| writeSync(fd, data); | ||
| fsyncSync(fd); | ||
| } | ||
| finally { | ||
| closeSync(fd); | ||
| } | ||
| renameSync(tmp, file); | ||
| } |
+5
-3
@@ -39,8 +39,10 @@ // Runs `opencode attach` as a child PTY while fleetview stays alive behind it. | ||
| for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) { | ||
| process.on(signal, () => { | ||
| const handler = () => { | ||
| restoreAll(); | ||
| // Re-raise with the default disposition so the exit code and any parent's wait() are honest. | ||
| process.removeAllListeners(signal); | ||
| // Remove only our own handler, not every subsystem's listener for this signal. | ||
| process.removeListener(signal, handler); | ||
| process.kill(process.pid, signal); | ||
| }); | ||
| }; | ||
| process.on(signal, handler); | ||
| } | ||
@@ -47,0 +49,0 @@ } |
+50
-3
@@ -1,2 +0,2 @@ | ||
| import { readFileSync, statSync } from 'node:fs'; | ||
| import { readFileSync, statSync, openSync, closeSync, unlinkSync } from 'node:fs'; | ||
| import { join } from 'node:path'; | ||
@@ -49,2 +49,49 @@ import { atomicWrite } from "./paths.js"; | ||
| const memberKey = (m) => `${m.worktree}:${m.id}`; | ||
| // #106: makePersistRoster's read-merge-write is not atomic — two processes (a TUI and a | ||
| // `fleetview bg`) that both loadRoster before either saves each write back a merge missing the | ||
| // other's append, so one is lost. An exclusive lockfile serialises the read-merge-write across | ||
| // processes: whoever creates `${file}.lock` (open 'wx' — fails if it exists) holds it for one | ||
| // persist. A lock older than STALE_LOCK_MS is a crashed holder's leftover and is broken. | ||
| // ponytail: busy-wait spin, since persist is fully synchronous anyway; best-effort — if locking | ||
| // can't be had (foreign error, or the spin times out) the write falls through unlocked rather than | ||
| // throwing, keeping the pre-#106 behaviour as the floor. | ||
| const STALE_LOCK_MS = 5000; | ||
| function withRosterLock(file, fn) { | ||
| const lock = `${file}.lock`; | ||
| let fd = null; | ||
| for (let i = 0; i < 100; i++) { | ||
| try { | ||
| fd = openSync(lock, 'wx'); | ||
| break; | ||
| } | ||
| catch (e) { | ||
| if (e.code !== 'EEXIST') | ||
| break; // undirectory/permission — give up, write unlocked | ||
| try { | ||
| if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) { | ||
| unlinkSync(lock); // crashed holder — break it and retry immediately | ||
| continue; | ||
| } | ||
| } | ||
| catch { } // lock vanished between open and stat — retry | ||
| const until = Date.now() + 10; // brief spin before the next attempt | ||
| while (Date.now() < until) { } | ||
| } | ||
| } | ||
| try { | ||
| return fn(); | ||
| } | ||
| finally { | ||
| if (fd !== null) { | ||
| try { | ||
| closeSync(fd); | ||
| } | ||
| catch { } | ||
| try { | ||
| unlinkSync(lock); | ||
| } | ||
| catch { } | ||
| } | ||
| } | ||
| } | ||
| export function makePersistRoster({ roster, file }) { | ||
@@ -62,3 +109,3 @@ let prev = roster; | ||
| let lastStamp = stamp(); | ||
| const persist = (snap) => { | ||
| const persist = (snap) => withRosterLock(file, () => { | ||
| let disk; | ||
@@ -99,3 +146,3 @@ try { | ||
| lastStamp = stamp(); | ||
| }; | ||
| }); | ||
| persist.reload = () => { | ||
@@ -102,0 +149,0 @@ const now = stamp(); |
+1
-1
| { | ||
| "name": "fleetview", | ||
| "version": "0.5.1", | ||
| "version": "0.5.2", | ||
| "description": "Claude Code's agent view for opencode: a roster TUI for backgrounded sessions — dispatch, watch, answer, attach", | ||
@@ -5,0 +5,0 @@ "scripts": { |
+3
-1
@@ -55,3 +55,5 @@ # fleetview | ||
| reports states in the same words as `claude agents --json`, so scripts written against agent | ||
| view read fleetview too. ([CLI reference](docs/reference.md#from-the-shell)) | ||
| view read fleetview too. The shell commands (`ls`, `--json`, `attach`, `logs`, `stop`, `rm`, | ||
| `bg`) act on opencode sessions only; the roster TUI is the view that spans every backend. | ||
| ([CLI reference](docs/reference.md#from-the-shell)) | ||
@@ -58,0 +60,0 @@ **Locked down by default.** The opencode server fleetview spawns exposes shell execution, so |
Sorry, the diff of this file is too big to display
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
566969
1.34%9002
1.52%87
2.35%