+33
-3
@@ -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 |
+15
-3
@@ -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; |
+1
-0
@@ -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 |
+13
-0
@@ -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}`; | ||
| } |
+1
-1
| { | ||
| "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
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.
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.
588466
3.79%9363
4.01%62
1.64%