Sign In

fleetview

Package Overview
Dependencies
Maintainers
1
Versions
7
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

fleetview - npm Package Compare versions

Comparing version
0.5.1
to
0.5.2
+1
-1
dist/backends/claude/index.js

@@ -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 @@ }

@@ -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;

@@ -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

// 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);
}

@@ -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 @@ }

@@ -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();

{
"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": {

@@ -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