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.3.0
to
0.4.0
+13
-4
dist/backend-normalise.js

@@ -21,6 +21,15 @@ // Per-backend normalisation: turning what a backend reports into the two shapes session-store

if (backend === 'claude') {
// A transcript has one timestamp (its mtime) and no creation record, so `created` is the same
// value rather than 0: a row with no creation time renders no age at all, and "as old as its
// last activity" is a better answer for a discovered session than a blank.
return list.map((s) => ({ id: s.id, title: s.title || s.id, time: { created: s.updatedAt ?? 0, updated: s.updatedAt ?? 0 } }));
// `created` is the transcript's first record timestamp (projects.ts reads it out during the
// scan it already runs), not its mtime: the age column means time-since-creation on every other
// row, and using the mtime made a nine-day-old claude session that had just replied render
// `now`. 0 when no record carried a parseable timestamp — ageLabel's `createdAt || updatedAt`
// then falls back to the mtime, which is the behaviour this row had before.
// #46: the title passes through empty rather than falling back to the id. Claude Code does not
// name a session on creation, so `|| s.id` put a UUID in the title of every session it had not
// titled yet — and a UUID is not `isPlaceholderTitle`, so the first relist after a dispatch
// overwrote the provisional prompt the store was showing and the row read as its own id while
// peek showed the prompt. An empty title is the honest report of "this backend has not named it",
// which is exactly what the store's placeholder test is looking for. What a never-dispatched
// session shows instead is projects.ts's job: it falls back to the transcript's opening prompt.
return list.map((s) => ({ id: s.id, title: s.title ?? '', time: { created: s.createdAt ?? 0, updated: s.updatedAt ?? 0 } }));
}

@@ -27,0 +36,0 @@ if (backend === 'copilot') {

@@ -11,3 +11,3 @@ // Claude Code as a Backend (see types.ts). The process-backed family: there is no server, so a

import { join } from 'node:path';
import { configDir, envWithoutServerPassword } from "../../registry.js";
import { configDir, childEnv } from "../../registry.js";
import { encodeProjectDir, listTranscripts, projectsDir } from "./projects.js";

@@ -102,5 +102,7 @@ import { parseStreamChunk } from "./stream.js";

const fd = openSync(logPath(id), 'a', 0o600);
// env without the opencode server password: a dispatched agent runs attacker-influenced prompts,
// and that credential would hand it ungated shell on the server (see envWithoutServerPassword).
const child = spawnImpl('claude', argv, { cwd: directory, detached: true, stdio: ['ignore', fd, fd], env: envWithoutServerPassword() });
// env without the opencode server password, and without this process's Claude Code session
// markers: a dispatched agent runs attacker-influenced prompts, and that credential would hand
// it ungated shell on the server; the markers make the child think it is a nested run of
// fleetview's own session (see childEnv).
const child = spawnImpl('claude', argv, { cwd: directory, detached: true, stdio: ['ignore', fd, fd], env: childEnv() });
// An async spawn failure (ENOENT for a missing claude) would otherwise be an unhandled error

@@ -112,3 +114,9 @@ // event that crashes out-of-band. Writing a synthetic failed `result` into the log instead means

// the normaliser can attribute it.
child.on('error', () => {
// Written at most once per spawn: 'error' and 'exit' can both fire for the same child, and two
// terminal lines in one log is a contradiction the fold would have to arbitrate.
let wroteSynthetic = false;
const appendSyntheticResult = () => {
if (wroteSynthetic)
return;
wroteSynthetic = true;
try {

@@ -120,2 +128,12 @@ appendFileSync(logPath(id), `${JSON.stringify({ type: 'result', is_error: true, subtype: 'error_during_execution', session_id: id })}\n`);

}
};
child.on('error', appendSyntheticResult);
// 'error' covers only a *spawn* failure (ENOENT/EACCES on the exec). A spawn that succeeds and
// then exits non-zero — an unknown flag, an older CLI refusing --agent/--model/--session-id, an
// auth or config error — writes plain text that parseStreamChunk drops, so no `result` is ever
// folded and the row renders `idle`: indistinguishable from a session created and never used.
// The child is detached and unref'd, so this listener costs nothing.
child.on('exit', (code) => {
if (code)
appendSyntheticResult();
});

@@ -122,0 +140,0 @@ child.unref();

// Discovery: reading ~/.claude/projects/ so the roster can show claude sessions fleetview never
// started, the way it shows opencode's. Claude Code owns this directory; nothing here writes to it.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { closeSync, openSync, readSync, readdirSync, statSync } from 'node:fs';
import { homedir } from 'node:os';

@@ -12,22 +12,49 @@ import { join } from 'node:path';

export const encodeProjectDir = (directory) => directory.replace(/[^a-zA-Z0-9]/g, '-');
// Scanning a transcript for the four things the roster wants. A finished session's transcript can
// reach several MB, so each line is substring-tested before it is parsed — the interesting records
// are a handful out of thousands, and JSON.parse on every line of every session was the difference
// between a listing that is free and one that is felt.
function scan(file) {
let raw;
try {
raw = readFileSync(file, 'utf8');
}
catch {
return {}; // deleted or unreadable between readdir and here; it simply isn't a session
}
const out = {};
for (const line of raw.split('\n')) {
// ai-title is rewritten as the session is re-titled, so the last one wins; cwd is the same on
// every record, so the first is enough and the rest are skipped.
const wantsCwd = out.cwd === undefined && line.includes('"cwd"');
// #46: the last-resort name for a session claude has not titled. A transcript only grows an
// `ai-title` once claude names it and a `last-prompt` once one is recorded — 78 of the 857
// transcripts on this machine (9%) have neither, and every one of them rendered as its bare UUID.
// The text that started the session is right there in the first user record, and it is what peek
// already shows for the row, so the roster and peek stop disagreeing.
// The FIRST user text, not the last: this is the session's subject the way opencode names a session
// from its opening prompt, where `last-prompt` is whatever was typed most recently.
// Capped because a prompt is not a title — a pasted stack trace is a legitimate first prompt, and
// this string reaches `ls` on raw stdout where nothing else truncates it.
const FIRST_PROMPT_CAP = 200;
function promptText(rec) {
// isMeta records are claude's own boilerplate (the `<local-command-caveat>` preamble), not
// something a person typed — 4 of those 78 open with one.
if (rec?.isMeta)
return '';
const content = rec?.message?.content;
const text = typeof content === 'string'
? content
: // A user record can also carry tool results, which have no text block and leave this empty
// so the scan keeps looking at the next user record rather than titling a row with a tool id.
Array.isArray(content)
? content.filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join(' ')
: '';
return text.replace(/\s+/g, ' ').trim().slice(0, FIRST_PROMPT_CAP);
}
const scanCache = new Map();
const freshEntry = () => ({ offset: 0, rest: '', ino: 0, decoder: new TextDecoder() });
// Folds the records in `text` onto `entry`. Each line is substring-tested before it is parsed — the
// interesting records are a handful out of thousands, and JSON.parse on every line of every session
// was the difference between a listing that is free and one that is felt.
function fold(entry, text) {
const lines = text.split('\n');
entry.rest = lines.pop() ?? ''; // partial trailing line: the next read completes it
for (const line of lines) {
// ai-title is rewritten as the session is re-titled, so the last one wins — including one that
// arrives in a later chunk, which is why these merge over the cached values rather than
// replacing them. cwd is the same on every record and createdAt is the first record's, so both
// are immutable once found and the rest of the file is skipped for them.
const wantsCwd = entry.cwd === undefined && line.includes('"cwd"');
const wantsCreated = entry.createdAt === undefined && line.includes('"timestamp"');
const wantsTitle = line.includes('"ai-title"');
const wantsPrompt = line.includes('"last-prompt"');
if (!wantsCwd && !wantsTitle && !wantsPrompt)
// Only until one is found: the substring test matches every user record in the file, so the
// parse this opens is paid once per transcript (claude's first record is a user record), not
// once per line — the whole point of the tests above.
const wantsFirstPrompt = entry.firstPrompt === undefined && line.includes('"user"');
if (!wantsCwd && !wantsCreated && !wantsTitle && !wantsPrompt && !wantsFirstPrompt)
continue;

@@ -41,12 +68,56 @@ let rec;

}
if (typeof rec?.cwd === 'string' && out.cwd === undefined)
out.cwd = rec.cwd;
if (typeof rec?.cwd === 'string' && entry.cwd === undefined)
entry.cwd = rec.cwd;
// Every transcript on this machine (33/33) carries an ISO timestamp on its first record. A
// record that lacks one, or whose value doesn't parse, leaves createdAt unset and the caller
// falls back to the mtime it always used.
if (entry.createdAt === undefined && typeof rec?.timestamp === 'string') {
const parsed = Date.parse(rec.timestamp);
if (Number.isFinite(parsed))
entry.createdAt = parsed;
}
if (rec?.type === 'ai-title' && typeof rec.aiTitle === 'string')
out.aiTitle = rec.aiTitle;
entry.aiTitle = rec.aiTitle;
if (rec?.type === 'last-prompt' && typeof rec.lastPrompt === 'string')
out.lastPrompt = rec.lastPrompt;
entry.lastPrompt = rec.lastPrompt;
if (entry.firstPrompt === undefined && rec?.type === 'user') {
const first = promptText(rec);
if (first)
entry.firstPrompt = first; // empty (a tool-result record) leaves it unset, so the next user record still counts
}
}
return out;
}
const scanCache = new Map();
// Reads whatever has been appended to `file` since `prev` last looked, and returns the merged entry.
// A different inode is a different file: compaction that renames a rewritten transcript over the old
// path lands at whatever size it likes, so size alone misses every rewrite that happens to be
// equal-or-larger and the scan then folds mid-line garbage from a stale offset. ino 0 (some Windows
// filesystems report it) is no signal at all, so it never triggers a reset. Same rule, same reasons,
// as the copilot backend's readFrom.
function scan(file, prev, size, ino) {
let entry = prev ?? freshEntry();
const replaced = entry.ino !== 0 && ino !== 0 && entry.ino !== ino;
if (size < entry.offset || replaced)
entry = freshEntry();
entry.ino = ino;
if (size <= entry.offset)
return entry; // nothing appended; the cached fold is still the answer
let chunk;
try {
const fd = openSync(file, 'r');
try {
const buf = Buffer.allocUnsafe(size - entry.offset);
const read = readSync(fd, buf, 0, buf.length, entry.offset);
chunk = entry.decoder.decode(buf.subarray(0, read), { stream: true });
entry.offset += read;
}
finally {
closeSync(fd);
}
}
catch {
return entry; // deleted or unreadable between readdir and here; keep what was already folded
}
fold(entry, entry.rest + chunk);
return entry;
}
// Sessions Claude Code has recorded for `directory`, newest first. The mtime is the only freshness

@@ -77,2 +148,3 @@ // signal there is: a transcript carries no marker saying its session is still running, so a

let size;
let ino;
try {

@@ -82,2 +154,3 @@ const st = statSync(file);

size = st.size;
ino = Number(st.ino);
}

@@ -87,6 +160,5 @@ catch {

}
const cached = prev?.get(name);
const scanned = cached?.mtimeMs === updatedAt && cached?.size === size ? cached.scanned : scan(file);
fresh.set(name, { mtimeMs: updatedAt, size, scanned });
const { cwd, aiTitle, lastPrompt } = scanned;
const entry = scan(file, prev?.get(name), size, ino);
fresh.set(name, entry);
const { cwd, aiTitle, lastPrompt, firstPrompt, createdAt } = entry;
// The folder name is a lossy hash of the path, so a transcript whose own cwd disagrees belongs

@@ -100,4 +172,8 @@ // to a different directory that happens to encode the same way, and showing it under this one

directory,
title: aiTitle ?? lastPrompt ?? '',
// #46: '' when even the opening prompt is unreadable, and it must stay '' rather than becoming
// the id — an empty title is what marks the session as unnamed all the way to the store, where
// a dispatch's provisional prompt is kept only while the reported title is a placeholder.
title: aiTitle ?? lastPrompt ?? firstPrompt ?? '',
updatedAt,
createdAt: createdAt ?? 0,
});

@@ -104,0 +180,0 @@ }

@@ -74,2 +74,9 @@ // Reading a `claude -p --output-format=stream-json` run. Pure: takes text, returns events and a

if (e?.type === 'result') {
// First result wins. A run that writes a valid `result` and *then* exits non-zero gets a
// synthetic failure line appended by the backend's exit handler, and a second terminal line
// must not overwrite what the run itself reported. Mirrors copilot's `if (run.exitCode !==
// null) return run` in events.ts. An `init` (a resume) puts the state back to 'working', so a
// follow-up prompt's own result still lands.
if (state.status === 'completed' || state.status === 'failed' || state.status === 'needs-input')
return state;
const denials = Array.isArray(e.permission_denials) ? e.permission_denials : [];

@@ -76,0 +83,0 @@ const errors = Array.isArray(e.errors) ? e.errors.filter((x) => typeof x === 'string') : [];

@@ -9,3 +9,3 @@ // GitHub Copilot CLI as a Backend (see types.ts). Process-backed: there is no server, so a session

import { join } from 'node:path';
import { configDir, envWithoutServerPassword } from "../../registry.js";
import { configDir, childEnv } from "../../registry.js";
import { foldEvents, initialRun, parseJsonlChunk } from "./events.js";

@@ -107,4 +107,4 @@ import { listSessions, lockInfo, runningPid, sessionStateDir } from "./sessions.js";

// Without the opencode server password: a dispatched agent runs attacker-influenced prompts,
// and that credential would hand it ungated shell on the server (envWithoutServerPassword).
env: envWithoutServerPassword(),
// and that credential would hand it ungated shell on the server (childEnv).
env: childEnv(),
});

@@ -176,6 +176,3 @@ // An async spawn failure (ENOENT when copilot isn't installed) would otherwise be an unhandled

// means "exists", and anything else means the lock is stale.
const lockAlive = (id) => {
const pid = runningPid(join(stateDir, id));
if (pid === null)
return false;
const aliveImpl = (pid) => {
try {

@@ -189,8 +186,20 @@ killImpl(pid, 0);

};
const lockAlive = (id) => {
const pid = runningPid(join(stateDir, id));
if (pid === null)
return false;
return aliveImpl(pid);
};
const visible = (dir) => {
const disk = listSessions(dir, stateDir).map((s) => (s.running && !lockAlive(s.id) ? { ...s, running: false } : s));
const onDisk = new Set(disk.map((s) => s.id));
// Probed, not assumed: a run copilot rejected on argv exits non-zero without ever writing a
// state directory, so it never reaches `onDisk` and never leaves `started` (only abort()
// removes entries) — and `child.on('error')` never fires, because that is a *spawn* failure
// only. Hardcoding `running: true` here left such a session claiming `working` forever.
// Same probe the disk branch above runs through lockAlive; statusOf then reports `failed`,
// which is exactly what its comment describes for "no terminal line and no live process".
const pending = [...started]
.filter(([id, s]) => s.directory === dir && !onDisk.has(id))
.map(([id]) => ({ id, directory: dir, running: true }));
.map(([id, s]) => ({ id, directory: dir, running: aliveImpl(s.pid) }));
return [...disk, ...pending];

@@ -197,0 +206,0 @@ };

@@ -52,10 +52,19 @@ import { spawn } from 'node:child_process';

export async function probeServer(server, fetchImpl = globalThis.fetch) {
const url = `http://${server.host}:${server.port}/project`;
try {
const auth = authHeader();
const res = await fetchImpl(`http://${server.host}:${server.port}/project`, {
headers: auth ? { authorization: auth } : undefined,
signal: AbortSignal.timeout(2000),
});
if (res.status === 401)
return 'unauthorized';
// The first request carries NO credential, on purpose. fleetview adopts the password from
// server.json before it has any evidence about who holds the port, so an authenticated first
// probe hands that credential to whatever is listening — including a process that is not
// fleetview's server and not opencode at all. Only a 401 is evidence the listener wants a
// credential, and only then is one presented, on a single retry. Costs one extra round trip on
// the password path; changes no adoption semantics.
let res = await fetchImpl(url, { signal: AbortSignal.timeout(2000) });
if (res.status === 401) {
const auth = authHeader();
if (!auth)
return 'unauthorized';
res = await fetchImpl(url, { headers: { authorization: auth }, signal: AbortSignal.timeout(2000) });
if (res.status === 401)
return 'unauthorized';
}
if (!res.ok)

@@ -70,3 +79,3 @@ return 'unreachable';

}
return Array.isArray(body) ? 'healthy' : 'unreachable';
return isProjectList(body) ? 'healthy' : 'unreachable';
}

@@ -77,2 +86,22 @@ catch {

}
// "Is this opencode" used to be "did it answer with a JSON array", which `[]` from any listener
// satisfies — and fleetview then feeds the adopted server prompts, `!` shell commands, and takes
// back from it the directory arguments it hands to git and spawnSync. Requiring the shape
// `types.ts` declares for a Project turns "answer []" into "reimplement enough of opencode's API".
// It does not stop a listener that mimics the shape; it raises the bar.
//
// The list must be non-empty: a real `opencode serve` always reports at least its own global
// project (verified against opencode 1.18.5 with a fresh, empty config dir — GET /project returned
// [{"id":"global","worktree":"/","time":{...},"sandboxes":[]}]), so requiring one well-formed entry
// costs nothing on the real thing while rejecting a bare `[]`.
function isProjectList(body) {
if (!Array.isArray(body) || body.length === 0)
return false;
return body.every((entry) => {
if (typeof entry !== 'object' || entry === null)
return false;
const project = entry;
return typeof project.id === 'string' && project.id !== '' && typeof project.worktree === 'string' && project.worktree !== '';
});
}
// Whether a set password is actually enforced by the server on `server`. True when no password is

@@ -83,2 +112,7 @@ // set (nothing to enforce) or when an unauthenticated request is rejected. False means a password is

// applies to a server fleetview spawns) is silently off and the shell route stays open.
//
// "True" here means "nothing is being ignored", not "this server is protected": with no password
// set there is nothing to enforce, and that no-password case is exactly where adopting a foreign
// listener is most likely and least visible. Saying so is the adoption notice's job in
// makeEnsureServer, which reports the no-password case rather than leaving this `true` silent.
export async function isAuthEnforced(server, fetchImpl = globalThis.fetch) {

@@ -85,0 +119,0 @@ if (!authHeader())

@@ -28,3 +28,3 @@ #!/usr/bin/env node

import { BACKEND_NAMES, DEFAULT_BACKEND, createBackends, defaultBackendName, isBackendName } from "./backends/index.js";
import { loadServer, saveServer, defaultServerFile } from "./registry.js";
import { loadServer, saveServer, defaultServerFile, childEnv } from "./registry.js";
import { spawnServer, isServerHealthy, isAuthEnforced, probeServer } from "./backends/opencode/server-manager.js";

@@ -69,3 +69,6 @@ import { loadSeen, saveSeen, defaultSeenFile } from "./seen-store.js";

// password we don't hold" question needs the difference between 401 and nothing listening.
probeServer = async (server) => ((await isServerHealthy(server)) ? 'healthy' : 'unreachable'), spawnServer, saveServer, serverFile, pollMs = 500, processKill = process.kill, spawnSyncImpl = spawnSync, warn = console.warn, env = process.env, }) {
probeServer = async (server) => ((await isServerHealthy(server)) ? 'healthy' : 'unreachable'), spawnServer, saveServer, serverFile, pollMs = 500, processKill = process.kill, spawnSyncImpl = spawnSync, warn = console.warn,
// Informational sink, separate from `warn` on purpose: adopting a server fleetview didn't spawn
// is supported, documented behaviour, so it gets one plain line rather than a warning voice.
notice = console.error, identifyServer = looksLikeOpencodeServer, env = process.env, }) {
// opencode cold boot (plugins) can exceed 5s; observed live on 1.18.4

@@ -112,2 +115,19 @@ const pollUntilHealthy = async (candidate) => {

};
// ensureServer runs again on every roster-loop iteration, so the adoption notice below is once per
// process: the fact is worth stating, restating it every few seconds is not.
let adoptionNoticed = false;
const noticeIfForeign = (server) => {
if (adoptionNoticed)
return;
// Fleetview's own server is the one whose pid server.json recorded and which is still a live
// `opencode serve`. Anything else on that port is a server fleetview is adopting: usually the
// user's own, occasionally not the one they think.
if (server.pid && identifyServer(server.pid).ok)
return;
adoptionNoticed = true;
const unprotected = env.OPENCODE_SERVER_PASSWORD
? ''
: ' No OPENCODE_SERVER_PASSWORD is set, so its shell route is reachable by anything on loopback.';
notice(`fleetview: using the opencode server already running on ${server.host}:${server.port} — fleetview did not start it.${unprotected}`);
};
return async function ensureServer(server) {

@@ -117,4 +137,6 @@ // M11: a password fleetview generated for a server it spawned lives in server.json, because that

// run probes its own still-running server unauthenticated, reads the 401 as "dead", and spawns a
// duplicate on every fallback port.
if (server.password && !env.OPENCODE_SERVER_PASSWORD)
// duplicate on every fallback port. probeServer only presents it to a listener that answered 401,
// so putting it in the environment here is not the same as showing it to whoever holds the port.
const adoptedSaved = Boolean(server.password) && !env.OPENCODE_SERVER_PASSWORD;
if (adoptedSaved)
env.OPENCODE_SERVER_PASSWORD = server.password;

@@ -129,2 +151,3 @@ const state = await probeServer(server);

}
noticeIfForeign(server);
return { ok: true, server };

@@ -138,3 +161,12 @@ }

let stalePassword = false;
if (server.password && env.OPENCODE_SERVER_PASSWORD !== server.password) {
if (state === 'unauthorized' && adoptedSaved) {
// The saved password was adopted from server.json and then rejected by whatever holds the
// port. Two things follow: that listener is not the server the password was saved for, and it
// has now seen the password. Reusing it for the replacement fleetview is about to spawn on a
// fallback port would arm that server with a credential the rejecting listener already holds,
// so the saved one is burned here and `generated` below mints a fresh UUID instead.
delete env.OPENCODE_SERVER_PASSWORD;
stalePassword = true;
}
else if (server.password && env.OPENCODE_SERVER_PASSWORD !== server.password) {
const userPassword = env.OPENCODE_SERVER_PASSWORD;

@@ -276,2 +308,6 @@ env.OPENCODE_SERVER_PASSWORD = server.password;

// reports fleetview's own server dead and `server stop` no-ops before reaching the kill.
// Putting it in the environment is not the same as showing it to the port: probeServer (which
// isServerHealthy runs on) asks unauthenticated first and only presents the credential to a
// listener that answered 401. `status`/`stop` spawn nothing, so there is no replacement server
// here for a rejected password to ride onto — that half is handled in makeEnsureServer.
if (server.password && !env.OPENCODE_SERVER_PASSWORD)

@@ -382,10 +418,15 @@ env.OPENCODE_SERVER_PASSWORD = server.password;

// `fleetview --json` / `fleetview ls`: what is running, without opening anything.
async function listSessions({ all, json, cwd }, ensureServer, serverFile) {
// Takes its collaborators the same way `runBg` does, so the listing can be driven end to end in a
// test without a server — the seeding rounds below are where `ls` decides a session's state, and
// 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) => {
process.exitCode = code;
}, } = {}) {
const r = await ensureServer(loadServer(serverFile) ?? DEFAULT_SERVER);
if (!r.ok) {
console.error(r.reason ?? 'opencode server unreachable');
process.exitCode = 1;
error(r.reason ?? 'opencode server unreachable');
setExitCode(1);
return;
}
const client = new OpencodeClient(`http://${r.server.host}:${r.server.port}`);
const client = createClient(`http://${r.server.host}:${r.server.port}`);
const projects = allProjectDirectories(await client.listProjects());

@@ -396,3 +437,3 @@ const parents = sandboxParents(projects);

// re-parsing it per project bought nothing and cost a stat + parse per project.
const seen = loadSeen(defaultSeenFile());
const seen = loadSeenImpl(seenFile());
// #32: read-only, and once, for the same reason — the roster is what remembers the dispatch

@@ -405,3 +446,3 @@ // prompt behind a session the server never named (a `! cmd` job never takes a model turn, so

try {
members = new Map(loadRoster(defaultRosterFile()).sessions.map((m) => [`${m.worktree}:${m.id}`, m]));
members = new Map(loadRosterImpl(rosterFile()).sessions.map((m) => [`${m.worktree}:${m.id}`, m]));
}

@@ -413,6 +454,23 @@ catch { }

lists.forEach((sessions, i) => sessions && store.setSessions(projects[i].worktree, sessions, seen));
// Statuses come from the same endpoint the UI seeds from, so a listing and the roster agree.
// Statuses come from the same endpoints the UI seeds from, so a listing and the roster agree.
// Still a second round: seedStatuses only has anything to attach to once the sessions are in.
const statuses = await Promise.all(projects.map((p) => client.sessionStatus(p.worktree).catch(() => null)));
statuses.forEach((s, i) => s && store.seedStatuses(projects[i].worktree, s));
// #54: and a third, for the pending state a status map does not carry. Without it both of
// derive's `waiting` paths are unreachable here by construction, so `ls`/`--json` reported a
// permission-blocked session as `working` with no `waitingFor` — contradicting the parity with
// `claude agents --json` the README documents. Concurrent like the rounds above, so the wall
// clock grows by about one round trip rather than 2N; each catch degrades that project to the
// previous behaviour rather than failing the listing.
const mark = store.seedMark();
await Promise.all(projects.flatMap((p) => [
client
.listPermissions(p.worktree)
.then((x) => store.seedPermissions(p.worktree, x, mark))
.catch(() => { }),
client
.listQuestions(p.worktree)
.then((x) => store.seedQuestions(p.worktree, x, mark))
.catch(() => { }),
]));
const rows = [];

@@ -429,7 +487,7 @@ for (const group of store.byProject()) {

if (json)
return console.log(JSON.stringify(visible, null, 2));
return log(JSON.stringify(visible, null, 2));
if (visible.length === 0)
return console.log(all ? 'no sessions' : 'nothing running — fleetview ls --all to include finished sessions');
return log(all ? 'no sessions' : 'nothing running — fleetview ls --all to include finished sessions');
for (const row of visible)
console.log(formatRow(row));
log(formatRow(row));
}

@@ -560,2 +618,18 @@ export async function main() {

}
// A child killed with child.kill() never gets to put back the input modes it turned on, and every
// one of them keeps sending fleetview bytes it never asked for: focus reporting answers a window
// focus change with ESC[I / ESC[O, bracketed paste wraps pastes in ESC[200~ … ESC[201~, application
// cursor keys re-spell the arrows, and modifyOtherKeys / kitty keyboard re-spell everything else.
// Those sequences arrive fragmented often enough that their printable tails land in the dispatch
// input as phantom characters (#20). Reclaiming the terminal means reclaiming its modes, so reset
// the ones an attached TUI plausibly set — a terminal ignores a reset for a mode it never had on.
// Declared above runRoster because RESTORE_SCREEN needs it too (#57).
export const RESET_INPUT_MODES = '\x1b[?2004l\x1b[?1004l\x1b[?1l\x1b[>4;0m\x1b[<u';
// What the exit/signal path puts back. Mouse-off belongs here: a crash must never leave the user's
// shell interpreting clicks. `?25h` too: the crash path must never leave the user's shell without a
// cursor now that the gate hides it while fleetview owns the terminal. And RESET_INPUT_MODES (#57):
// a signal or crash while attached is exactly the case that constant exists for, yet it used to be
// written only on the clean resume path (reclaimTerminal) — leaving bracketed paste, focus
// reporting, DECCKM and the kitty stack on in the shell fleetview hands back.
export const RESTORE_SCREEN = `${MOUSE_OFF}${RESET_INPUT_MODES}\x1b[?25h\x1b[?1049l`;
async function runRoster(args, serverFile, launch = {}) {

@@ -571,6 +645,3 @@ const rosterFile = defaultRosterFile();

const tty = process.stdout.isTTY;
// Mouse-off belongs here too: a crash must never leave the user's shell interpreting clicks.
// `?25h` too: the crash path must never leave the user's shell without a cursor now that the
// gate hides it while fleetview owns the terminal.
const restoreScreen = () => process.stdout.write(`${MOUSE_OFF}\x1b[?25h\x1b[?1049l`);
const restoreScreen = () => process.stdout.write(RESTORE_SCREEN);
if (tty) {

@@ -711,10 +782,2 @@ process.stdout.write('\x1b[?1049h');

const CLEAR_SCREEN = '\x1b[2J\x1b[3J\x1b[H';
// A child killed with child.kill() never gets to put back the input modes it turned on, and every
// one of them keeps sending fleetview bytes it never asked for: focus reporting answers a window
// focus change with ESC[I / ESC[O, bracketed paste wraps pastes in ESC[200~ … ESC[201~, application
// cursor keys re-spell the arrows, and modifyOtherKeys / kitty keyboard re-spell everything else.
// Those sequences arrive fragmented often enough that their printable tails land in the dispatch
// input as phantom characters (#20). Reclaiming the terminal means reclaiming its modes, so reset
// the ones an attached TUI plausibly set — a terminal ignores a reset for a mode it never had on.
const RESET_INPUT_MODES = '\x1b[?2004l\x1b[?1004l\x1b[?1l\x1b[>4;0m\x1b[<u';
// TODO(types): `out` (gated stdout) and `instance` (Ink render handle) come from untyped modules.

@@ -812,2 +875,8 @@ export function reclaimTerminal(out, instance) {

spawn: pty.spawn,
// A process-backed child gets the scoped env the dispatch path already uses: it must not
// inherit the opencode server password (an attached `claude --resume` runs the same
// attacker-influenced history with the same tool access as a dispatch) nor this process's
// Claude Code session markers, which silently turn transcript saving off in the child.
// opencode's attach is excluded — it authenticates with that password.
env: (target.backend ?? DEFAULT_BACKEND) === DEFAULT_BACKEND ? process.env : childEnv(),
// Detaching mid-turn kills a process-backed child's in-flight work, so claude/copilot get the

@@ -814,0 +883,0 @@ // double-press guard. opencode sessions live on the server — detach loses nothing there.

// Commands agent view runs in the view rather than dispatching: "/exit and /quit close agent view
// ... /model sets the dispatch model". /login and /logout have no fleetview equivalent.
const VIEW_COMMANDS = new Set(['exit', 'quit', 'model', 'fork']);
// #51: the two view commands that take no arguments, and the only two whose action is
// irreversible. `/model <provider>/<model>` and `/fork [prompt]` legitimately take args
// (README.md:111), so they stay view commands whatever follows them; `/exit codes should be
// documented in the README` is a prompt that happens to start with the word, and quitting on it
// destroyed the text with no way to recover it.
const NO_ARG_VIEW_COMMANDS = new Set(['exit', 'quit']);
const FILTER = /^(a|s):(\S*)$/;

@@ -47,4 +53,5 @@ // "`#<number>` or a PR URL — shows the session working on that pull request." Anchored on both ends

return { kind: 'empty' };
const isView = VIEW_COMMANDS.has(word) && !(rest.length && NO_ARG_VIEW_COMMANDS.has(word));
return {
kind: VIEW_COMMANDS.has(word) ? 'view-command' : 'command',
kind: isView ? 'view-command' : 'command',
command: word,

@@ -51,0 +58,0 @@ args: rest.join(' '),

@@ -187,22 +187,26 @@ // Runs `opencode attach` as a child PTY while fleetview stays alive behind it.

// the child's next redraw may overwrite it, which is acceptable for a transient hint.
//
// Every chord goes through here, not just detach (#56): Alt+N leaves the attachment via the same
// cleanup() → child.kill(), so switching rows mid-turn destroys exactly the in-flight work this
// guard exists to protect. guardArmedAt is shared, so an armed Ctrl+Z followed by Alt+2 switches.
const BUSY_WINDOW_MS = 2000;
const ARM_WINDOW_MS = 3000;
let guardArmedAt = null;
const detachRequested = () => {
const guarded = (chord) => {
if (busyDetachGuard) {
const t = now();
if (guardArmedAt !== null && t - guardArmedAt <= ARM_WINDOW_MS)
return finish({ type: 'detach' });
// Past the window (or never armed): a busy child re-arms the guard rather than detaching.
return finish(chord);
// Past the window (or never armed): a busy child re-arms the guard rather than leaving.
if (lastOutputAt !== null && t - lastOutputAt < BUSY_WINDOW_MS) {
guardArmedAt = t;
const rows = stdout.rows || 24;
stdout.write(`\x1b7\x1b[${rows};1H\x1b[7m still working — press again to detach \x1b[0m\x1b8`);
stdout.write(`\x1b7\x1b[${rows};1H\x1b[7m still working — press again \x1b[0m\x1b8`);
return;
}
}
finish({ type: 'detach' });
finish(chord);
};
const reader = makeChordReader({
onChord: (chord) => (chord.type === 'detach' ? detachRequested() : finish(chord)),
onChord: (chord) => guarded(chord),
// cleanup() flushes any held Escape, and cleanup also runs on the exit path — where writing

@@ -209,0 +213,0 @@ // to an already-dead pty would throw straight out of the onExit handler.

@@ -27,2 +27,36 @@ import { readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, existsSync } from 'node:fs';

};
// Per-session markers Claude Code stamps on its own children (its own MCP-server scrub list keeps
// the same kind of denylist). Inheriting them makes fleetview's child believe it is a nested run of
// the session fleetview was launched from: an interactive `claude --resume` then suppresses
// transcript persistence — the very file this repo's claude backend reads — and a proxied
// WebFetch/WebSearch is attributed to the parent session.
//
// An explicit list, deliberately not a /^CLAUDE(CODE)?_/ regex: CLAUDE_CONFIG_DIR,
// CLAUDE_CODE_USE_BEDROCK, CLAUDE_CODE_USE_VERTEX and CLAUDE_CODE_MAX_OUTPUT_TOKENS are legitimate
// user configuration, and blanket-stripping them would break every Bedrock/Vertex user — a worse
// bug than the one being fixed. Data, so adding a name later is one line.
const SESSION_MARKERS = [
'CLAUDECODE',
'CLAUDE_CODE_SESSION_ID',
'CLAUDE_CODE_CHILD_SESSION',
'CLAUDE_CODE_ENTRYPOINT',
'CLAUDE_CODE_EXECPATH',
'CLAUDE_PID',
'CLAUDE_EFFORT',
'CLAUDE_JOB_DIR',
'CLAUDE_CODE_TASK_LIST_ID',
'CLAUDE_CODE_BRIDGE_SESSION_ID',
'CLAUDE_CODE_INVOKED_SKILLS',
'AI_AGENT',
'TRACEPARENT',
];
// The env a process-backed child (claude/copilot) should get: no server password, no inherited
// session identity. Used by both spawn families and by the attach path in cli.ts — opencode's
// attach is excluded there, because `opencode attach` authenticates with that very password.
export const childEnv = (env = process.env) => {
const rest = envWithoutServerPassword(env);
for (const key of SESSION_MARKERS)
delete rest[key];
return rest;
};
// Local rather than shared with seen-store: a five-line best-effort rename is cheaper than either

@@ -29,0 +63,0 @@ // a cross-import between two unrelated stores or a new module to hold one function.

@@ -1,2 +0,2 @@

import { readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, existsSync } from 'node:fs';
import { readFileSync, writeFileSync, mkdirSync, chmodSync, renameSync, existsSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';

@@ -65,18 +65,15 @@ import { homedir } from 'node:os';

const memberKey = (m) => `${m.worktree}:${m.id}`;
// App holds one whole-roster snapshot, loaded at mount, and rewrites the file from it on every
// change. So a `fleetview bg` append from another terminal was silently dropped by the next pin,
// collapse or shell-job TTL clean in the running instance (#70) — the session kept running
// server-side, it just stopped being listed. Merge onto a fresh read instead, same shape and same
// reason as makePersistSeen in cli.ts: a wholesale write drops what this process never saw.
//
// The hard half is telling "this instance removed it" (^x, ^a, deleteGroup, the TTL clean) from
// "another process added it" — on disk and absent from the snapshot, both look identical. `prev`,
// the snapshot this instance last held, decides: a member in prev but gone from snap was removed
// here and must stay removed, while a member on disk that prev never carried is someone else's
// addition and must survive. prev tracks what this instance knew, never the merged result —
// adopting the merge would make the next save read a foreign member as one of its own and delete
// it, resurrecting the bug one write later.
export function makePersistRoster({ roster, file }) {
let prev = roster;
return (snap) => {
const stamp = () => {
try {
const s = statSync(file);
return `${s.mtimeMs}:${s.size}`;
}
catch {
return ''; // missing/unreadable: no stamp to compare, and reload's loadRoster will answer for it
}
};
let lastStamp = stamp();
const persist = (snap) => {
let disk;

@@ -112,4 +109,18 @@ try {

// nothing to merge: last writer wins, exactly as before this merge existed.
return saveRoster(file, { ...snap, sessions });
saveRoster(file, { ...snap, sessions });
lastStamp = stamp();
};
persist.reload = () => {
const now = stamp();
if (now === lastStamp)
return null;
lastStamp = now;
try {
return loadRoster(file);
}
catch {
return null; // corrupt mid-run: the in-memory roster is the better answer, and persist repairs the file
}
};
return persist;
}

@@ -50,12 +50,22 @@ // M5: shared grapheme-safe string helpers. A code-unit slice/truncate can chop a surrogate pair

// prompt legitimately starts with `[ok]` or `[1] retry`, and eating those would be the worse bug.
// A generic `^\[[0-9;]+[a-z]` is where false positives start (`[2fix that` would die), so every
// shape here ends in a terminator no prose reaches for, or is matched whole-remnant only.
// Only a leading remnant is stripped — mid-text these shapes are far likelier to be prose.
const RESIDUE = [
/^\[20[01]~/, // bracketed paste start/end
/^O[A-Z]$/, // SS3 arrow/F-key, e.g. `OA` — whole-remnant only, so `OK done` survives (#58)
/^\[[IO]$/, // focus in/out — whole-remnant only, so `[Inbox]` survives
/^\[[0-9]+;[0-9]+R/, // cursor position report
/^\[[0-9;]+u/, // kitty CSI-u key, e.g. `[97;5u` (#58)
/^\[[0-9;]+~/, // modifyOtherKeys / tilde keys, e.g. `[27;5;99~`; paste markers are a subset (#58)
/^\[[0-9]*n$/, // device status report, e.g. `[0n` — whole-remnant only, it is the loosest shape (#58)
/^\[\?[0-9;]*[a-zA-Z]/, // device attributes and mode reports, e.g. `[?65;4c`
/^\][0-9]+;[\s\S]*$/, // OSC answer tail, e.g. `]11;rgb:1c1c/1c1c/1c1c`
/^\][0-9]+;\S*$/, // OSC answer tail, e.g. `]11;rgb:1c1c/1c1c/1c1c` — whole-remnant and
// whitespace-free, or a prompt like `]0;title of the thing` would be eaten to end of chunk (#58)
];
// The paste terminator is the one shape that can never be leading — by definition it follows the
// pasted text — so the leading-only loop below could never remove it, and it fires on every paste
// while bracketed paste is left on (#58).
const PASTE_TERMINATOR = /\[201~/g;
export const stripEscapeResidue = (text) => {
let out = text;
let out = text.replace(PASTE_TERMINATOR, '');
for (let matched = true; matched && out;) {

@@ -62,0 +72,0 @@ matched = false;

// SGR mouse reporting (\x1b[?1000h + \x1b[?1006h): the terminal sends `\x1b[<b;x;yM` on press
// and `...m` on release, with wheel ticks as buttons 64/65. 1006 (SGR encoding) matters because
// the legacy X10 encoding breaks past column 223 and isn't UTF-8 safe.
// MOUSE_OFF also resets the motion-tracking and legacy-encoding modes fleetview never turns on
// (#57): an attached child that enabled cell-motion (?1002) or any-motion (?1003) leaves it on, and
// re-opening with MOUSE_ON puts SGR encoding back — so every mouse *movement* over the roster
// reports as a press. A terminal ignores a reset for a mode it never had on.
export const MOUSE_ON = '\x1b[?1000;1006h';
export const MOUSE_OFF = '\x1b[?1000;1006l';
export const MOUSE_OFF = '\x1b[?1000;1002;1003;1006;1015l';
export function parseMouseEvents(chunk) {

@@ -7,0 +11,0 @@ if (!chunk || !chunk.includes('[<'))

@@ -205,2 +205,9 @@ import { useEffect, useRef, useState } from 'react';

else if (key.upArrow || key.downArrow) {
// #61: a typed reply is state worth protecting, the same way escape and Ctrl+C already
// treat it. Retargeting `peekTarget` while a draft is live meant the next ⏎ sent the text
// written for one session to a different one — a real `--resume`/`promptAsync` against the
// wrong agent in the wrong repo, with no undo. So the first arrow with a draft consumes the
// key to clear it, and only the next one navigates.
if (!replyEmpty)
return setPeekReply('');
// Walk the session rows in screen order by their group-form nav key — the same key the

@@ -221,2 +228,7 @@ // roster arrows and mouse set. Using `flat`/`keyOf` here compared two key vocabularies

}
else if (!replyEmpty && key.rightArrow) {
// #61: ⏎ with a draft is consumed above as "send", but → fell through to attach and threw
// the draft away silently. Same treatment as the arrows: clear it first, attach second.
return setPeekReply('');
}
else if ((key.return || key.rightArrow) && peekTarget) {

@@ -223,0 +235,0 @@ attach(peekTarget);

{
"name": "fleetview",
"version": "0.3.0",
"version": "0.4.0",
"description": "Claude Code's agent view for opencode: a roster TUI for backgrounded sessions — dispatch, watch, answer, attach",

@@ -5,0 +5,0 @@ "scripts": {

@@ -88,3 +88,4 @@ # fleetview

the choice persists), and `^x` deletes every session in the group — twice, like the per-session
form. `completed` folds into a `… N more` line when the screen runs out; failures never fold.
form. `completed` folds into a `… N more` line when the screen runs out; failures, sessions with
an open pull request, and the row you have selected never fold.

@@ -181,3 +182,7 @@ ![A pinned group on top, a folded `… 3 more` line under working, and completed collapsed to its header and count](docs/images/groups.png)

the roster. That matters because the main list only shows sessions you dispatched from fleetview
or added yourself; removing one from the roster doesn't stop it.
or added yourself; removing one from the roster doesn't stop it. A member whose session has gone
for good (deleted elsewhere, or its worktree removed) still gets a dim row under `completed` so
`^x` can drop it, rather than becoming an invisible entry you can only clear by editing
`roster.json`. A session dispatched with `fleetview bg` from another terminal joins a running
roster on the next poll, without a restart.

@@ -191,3 +196,5 @@ ![Browse: every opencode session grouped by project, with `[roster]` marking the ones on the main list](docs/images/browse.png)

prefix with `!` to run a shell command in that session instead. `↑`/`↓` peek adjacent sessions
without closing, `→` attaches, `esc` clears a half-typed reply and then closes the panel.
without closing, `→` attaches, `esc` clears a half-typed reply and then closes the panel. A reply
you have started typing is protected: the first `↑`/`↓`/`→` after it clears the draft rather
than moving, so a follow-up written for one session can never be sent to another.

@@ -220,3 +227,4 @@ A pending permission is answerable with `y` allow once · `a` always · `d` deny; a pending

interactive client there would kill the in-flight turn; opencode detaches immediately, its
sessions live on the server.
sessions live on the server. The same second press guards `alt+1`…`alt+9` there, since switching
away kills that client exactly as detaching does.

@@ -321,2 +329,10 @@ Because fleetview stays resident, its terminal-level signals keep working while you're attached

The saved password is never handed to a listener that hasn't proved it wants one: fleetview probes
the port unauthenticated first and only retries with credentials against something that answered
401. If a listener rejects the saved password, that password is treated as burned — the server it
was saved for is gone, so a fresh one is minted for the replacement rather than reused. Adoption
also checks that the thing on the port answers like opencode (a well-formed project listing, not
merely a JSON array), and fleetview says so in one line whenever it adopts a server it did not
spawn itself.
## From the shell

@@ -323,0 +339,0 @@

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display