New:Socket for Asana Is Now Available.Learn more
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.2
to
0.7.0
+33
-3
dist/cli-args.js

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

import { resolve } from 'node:path';
// Agent view has a shell surface next to the view itself — `claude agents --cwd/--json`, and

@@ -17,4 +18,5 @@ // `attach`, `logs`, `stop`, `rm` as subcommands. fleetview took no arguments at all, which made the

The shell commands below (ls/--json/attach/logs/stop/rm/bg) act on opencode
sessions only; the roster TUI shows sessions from every backend.
The shell commands below (ls/--json/attach/logs/add/answer/watch/status/
stop/rm/bg) act on opencode sessions only; the roster TUI shows sessions
from every backend.

@@ -25,2 +27,6 @@ fleetview --json [--all] print sessions as JSON instead of opening the roster

fleetview logs <id> [--all] print a session's recent output (--all for everything)
fleetview add <id> add an existing session to the roster
fleetview answer <id> <reply> answer a waiting session: y, a, d, an option number, or text
fleetview watch <id> stream a session's output until it finishes
fleetview status one line of counts; exits 0 when something awaits input
fleetview stop <id> stop a session, leaving it in the list

@@ -38,3 +44,9 @@ fleetview rm <id> delete a session (keeps a worktree holding commits)

fleetview --help this text`;
const SUBCOMMANDS = new Set(['attach', 'logs', 'stop', 'rm', 'ls', 'server', 'bg']);
const SUBCOMMANDS = new Set(['attach', 'logs', 'stop', 'rm', 'ls', 'server', 'bg', 'add', 'answer', 'watch', 'status']);
// #107: every path `--cwd` is compared against — opencode's project directories, the roster's
// worktrees — is absolute, so a relative `--cwd .` or `--cwd ../sibling` matched nothing at all.
// Resolved once, between parsing and doing, so listSessions, rosterLoop and runBg all see the same
// absolute path. `base` is a parameter rather than a process.cwd() call inside so this stays as
// testable as the parser above it.
export const resolveCwd = (args, base = process.cwd()) => args.cwd === undefined ? args : { ...args, cwd: resolve(base, args.cwd) };
// Returns {command, id?, cwd?, all?, json?} or {error}. `command` is 'ui' when there is nothing to

@@ -136,2 +148,20 @@ // do but open the roster, which is still the common case.

}
if (name === 'answer') {
// <id> then the answer: `y`/`a`/`d` for a permission, a digit for a question option, anything
// else as typed text. The text half is joined like bg's prompt so a sentence needs no quoting.
if (args.length === 0)
return { error: 'answer needs a session id' };
const reply = args.slice(1).join(' ').trim();
if (!reply)
return { error: 'answer needs a reply: y, a, d, an option number, or text' };
out.id = args[0];
out.reply = reply;
return out;
}
if (name === 'status') {
// A summary of everything, like `ls` — no id to take.
if (args.length > 0)
return { error: 'status takes no arguments' };
return out;
}
if (name === 'server') {

@@ -138,0 +168,0 @@ // `server` takes an action word, not a session id.

@@ -0,1 +1,16 @@

// Everything the dispatch input can mean, worked out from the raw string.
//
// Agent view overloads this one input with dispatching, targeting, shell jobs, view commands and
// filtering, and the only way to keep that legible is to decide it all in one pure function the
// key handler can just read the answer from.
//
// ! cmd → a shell job instead of a session
// a:name / s:state → filter the list, don't dispatch
// /command → a command, some of which run in the view itself
// @name → a subagent if one matches, else a repository, else a backend to run on
// name ... → a subagent when the first word matches one
//
// Takes its vocabularies as arguments (agent names, repo names, backend names) so it stays pure and
// testable.
import { hasOpenPr } from "./pull-requests.js";
// Commands agent view runs in the view rather than dispatching: "/exit and /quit close agent view

@@ -32,3 +47,10 @@ // ... /model sets the dispatch model". /login and /logout have no fleetview equivalent.

const [, axis, value] = filter;
return { kind: 'filter', filter: axis === 'a' ? { agent: value } : { state: value } };
if (axis === 'a')
return { kind: 'filter', filter: { agent: value } };
// #113.5: `s:pr` is not a state — it is agent view's "ready for review" view, which fleetview
// folds into completed rather than giving a fourth section. As a filter it costs one word of
// vocabulary and restores the list on demand. `s:review` reads the same intent, so both work.
if (value === 'pr' || value === 'review')
return { kind: 'filter', filter: { openPr: true } };
return { kind: 'filter', filter: { state: value } };
}

@@ -154,2 +176,6 @@ // Agent view defines this as a filter and nothing else — filtering to that one row is the

}
// #113.5: an open pull request, whatever the session's state — the same predicate the row badge
// and the completed-group fold already use, so the filter and the badges can never disagree.
if (filter.openPr)
return sessions.filter((s) => hasOpenPr(s.prs));
if (filter.pr !== undefined) {

@@ -156,0 +182,0 @@ // A URL filter carries its owner/repo and must match the pull request's own URL, so two

@@ -14,3 +14,15 @@ // Which project a bare (no `@repo`) dispatch runs in.

import { readdirSync, existsSync } from 'node:fs';
import { basename, join } from 'node:path';
import { homedir } from 'node:os';
import { basename, join, resolve } from 'node:path';
// #119: the durable form of "dispatch here unless I say otherwise" — an export in a shell profile,
// read once at launch. A tilde is expanded here because a quoted assignment
// (`FLEETVIEW_DEFAULT_PROJECT="~/repos/ui"`) reaches the process with the `~` still in it, and a
// relative path is resolved against the launch directory so `.` means what it says.
export function defaultProjectFromEnv(env = process.env, home = homedir(), base = process.cwd()) {
const raw = env.FLEETVIEW_DEFAULT_PROJECT?.trim();
if (!raw)
return undefined;
const expanded = raw === '~' ? home : raw.startsWith('~/') ? join(home, raw.slice(2)) : raw;
return resolve(base, expanded);
}
// What `@` can complete to. Agent view lists "Git repositories one level below the launch

@@ -57,3 +69,3 @@ // directory ... [and] any directory that already has a session in the list", and skips any

// behind "<name> no longer exists" with no way to recover in the UI.
export function pickTarget({ cwd, projects = [], current, groupBy, dirExists = existsSync, }) {
export function pickTarget({ cwd, projects = [], current, groupBy, defaultProject, dirExists = existsSync, }) {
const known = new Set(projects.map((p) => p.worktree));

@@ -63,2 +75,9 @@ const candidates = [

cwd && known.has(cwd) ? cwd : undefined,
// #119: a pinned default, above every fallback that is merely incidental. The two candidates
// above it are explicit — the group the user scrolled to, the directory they launched from —
// but the highlighted row under any other grouping and the newest-updated project below are
// just whatever the roster happened to be showing, and that is exactly what put dispatches in
// the wrong repository. Unlike the candidates around it this one need not be a known project:
// its whole job is to name the repository that recency keeps losing to.
defaultProject,
current?.projectKey,

@@ -65,0 +84,0 @@ ...projects.map((p) => p.worktree), // projects arrive sorted newest-updated first

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

import { readFileSync, statSync, openSync, closeSync, unlinkSync } from 'node:fs';
import { readFileSync, renameSync, statSync, openSync, closeSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';

@@ -22,3 +22,12 @@ import { atomicWrite } from "./paths.js";

catch {
throw new Error(`roster file corrupt: ${file} — fix or delete it`);
// Unreadable content is set aside rather than discarded so nothing is silently lost and the
// evidence survives for anyone who wants to look. A corrupt roster no longer stops fleetview
// starting; it recovers the same way seen.json does.
try {
renameSync(file, `${file}.corrupt`);
}
catch {
// nothing to do; the caller falls back to empty state either way
}
return defaultRoster();
}

@@ -58,4 +67,7 @@ // tolerated the same way as seen.json: a non-object parse (e.g. from a torn write) is

// throwing, keeping the pre-#106 behaviour as the floor.
// Exported because the TUI's persist is only one side of the race: `fleetview bg`, `add` and `rm`
// each do their own read-modify-write of the same file from a separate process, and a lock only one
// writer takes serialises nothing.
const STALE_LOCK_MS = 5000;
function withRosterLock(file, fn) {
export function withRosterLock(file, fn) {
const lock = `${file}.lock`;

@@ -62,0 +74,0 @@ let fd = null;

@@ -39,2 +39,3 @@ import React from 'react';

['a:name', 'filter the list by agent'],
['s:pr', 'filter to sessions with an open pull request (s:review too)'],
['#1234', 'filter to the session working on that pull request (a PR URL works too)'],

@@ -41,0 +42,0 @@ ['/model p/m', 'set the model for sessions dispatched from here (/model default clears it)'],

+6
-3

@@ -79,3 +79,3 @@ import React from 'react';

const prLine = (pr) => `${`#${pr.number}`} ${prStatus(pr)} · ${pr.url}`;
export function Peek({ target, messages, pending = [], pendingQuestions = [], error, maxRows = Infinity, columns = 80, reply = '', savedReply = null, now, prReason = null,
export function Peek({ target, messages, pending = [], pendingQuestions = [], error, maxRows = Infinity, columns = 80, reply = '', savedReply = null, now, prReason = null, mergeBack = null,
// Whether this session's backend can take an answer from the roster (backend.capabilities.

@@ -124,2 +124,5 @@ // questions). The banners are unaffected — a blocked session is blocked either way — but the

const prReasonRow = prs.length === 0 && prReason ? truncateGraphemes(prReason, columns) : null;
// Truncated, not wrapped, for the same reason the pull request lines are: a long repository path
// would otherwise eat the panel on a narrow terminal.
const mergeBackRow = mergeBack ? truncateGraphemes(`merge back: ${mergeBack}`, columns) : null;
const permLineText = pending.length > 0

@@ -139,3 +142,3 @@ ? `⚠ permission: ${permissionLabel(pending[0])}${pending.length > 1 ? ` (+${pending.length - 1} more)` : ''}`

// Fixed cost before options: title + reply + each banner and its hint + the error line.
const extras = (waited ? 1 : 0) + (savedReply ? 1 : 0) + prRows.length + (prReasonRow ? 1 : 0);
const extras = (waited ? 1 : 0) + (savedReply ? 1 : 0) + prRows.length + (prReasonRow ? 1 : 0) + (mergeBackRow ? 1 : 0);
const fixedReserve = 2 + extras + (permLineText ? permRows.length + 1 : 0) + (questionLineText ? questionRows.length + 1 : 0) + (error ? 1 : 0);

@@ -166,3 +169,3 @@ // Leave at least one row for the body; drop options rather than overflow the panel.

// session — so it has to be cut here too, or this "one row" wraps to several.
React.createElement(Text, { bold: true }, truncateGraphemes(target.title ?? '', Math.max(8, Math.floor(columns / 2)))), React.createElement(Text, { dimColor: true }, basename(target.projectKey) || target.projectKey)), waited ? React.createElement(Text, { key: 'waited', dimColor: true }, waited) : null, ...prRows.map((r, i) => React.createElement(Text, { key: `pr${i}`, color: r.color }, r.text)), prReasonRow ? React.createElement(Text, { key: 'prReason', dimColor: true }, prReasonRow) : null, ...permRows.map((t, i) => React.createElement(Text, { key: `perm${i}`, color: theme.warn }, t)), permLineText
React.createElement(Text, { bold: true }, truncateGraphemes(target.title ?? '', Math.max(8, Math.floor(columns / 2)))), React.createElement(Text, { dimColor: true }, basename(target.projectKey) || target.projectKey)), waited ? React.createElement(Text, { key: 'waited', dimColor: true }, waited) : null, ...prRows.map((r, i) => React.createElement(Text, { key: `pr${i}`, color: r.color }, r.text)), prReasonRow ? React.createElement(Text, { key: 'prReason', dimColor: true }, prReasonRow) : null, mergeBackRow ? React.createElement(Text, { key: 'mergeBack', dimColor: true }, mergeBackRow) : null, ...permRows.map((t, i) => React.createElement(Text, { key: `perm${i}`, color: theme.warn }, t)), permLineText
? React.createElement(Text, { dimColor: true }, canAnswer ? 'y allow · a always · d deny' : `${backend ?? 'this backend'} can't be answered from here — → to attach`)

@@ -169,0 +172,0 @@ : null, ...questionRows.map((t, i) => React.createElement(Text, { key: `q${i}`, color: theme.info }, t)), ...options.map((o, i) => React.createElement(Text, { key: `opt${i}` }, ` ${i + 1}. ${o.label}`)), questionLineText

@@ -165,1 +165,14 @@ // Worktree isolation, agent view's rule: "Background sessions move into an isolated git worktree

}
// #113.4: the last manual step of dispatch → isolate → complete. The guide ends with "merging back
// is yours", and the two things that step needs — which repository the worktree belongs to and
// which branch the session committed on — are already known here, so the command can be spelled out
// in full instead of leaving the user to reconstruct it.
//
// Shown, never run: a merge can conflict, the parent checkout may have work in it, and neither is
// something a keystroke in a roster should decide. Pure so the wording is testable without a
// repository; the caller supplies the branch lookup.
export function mergeBackCommand(worktree, repo, branch) {
if (!repo || !branch || repo === worktree)
return null;
return `git -C ${repo} merge ${branch}`;
}
{
"name": "fleetview",
"version": "0.5.2",
"version": "0.7.0",
"description": "Claude Code's agent view for opencode: a roster TUI for backgrounded sessions — dispatch, watch, answer, attach",

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

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

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