@@ -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()) |
+96
-27
@@ -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(' '), |
+10
-6
@@ -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. |
+34
-0
@@ -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. |
+27
-16
@@ -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; | ||
| } |
+13
-3
@@ -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; |
+5
-1
| // 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('[<')) |
+12
-0
@@ -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); |
+1
-1
| { | ||
| "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": { |
+20
-4
@@ -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 @@  | ||
| 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
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
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
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.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
556445
6.69%8566
5.66%405
4.11%58
5.45%