diff --git a/.ai/contexts/changes-view.md b/.ai/contexts/changes-view.md index 6459855f..c0d00888 100644 --- a/.ai/contexts/changes-view.md +++ b/.ai/contexts/changes-view.md @@ -590,11 +590,15 @@ message rather than to the "not a git repository" line. `resolveGitChangesTarget(sessionId, deps)`, in order: -0. **`isValidChangesSessionId(sessionId)`** — refused before any dependency runs, including `getCachedFolder`. Accepts a plain CLI-issued id (`/^[A-Za-z0-9._-]+$/`, excluding the bare `.`/`..` — same shape and rationale as `isValidSessionId` in `delete-session-target.js`, since a local id ultimately reaches `resolveSessionRealCwd` → `path.join(projectsDir, folder, sessionId + '.jsonl')`) or a remote descriptor-only placeholder id, `pid:` (`remote-index.js` `buildPlaceholderSession` — a live descriptor with no CLI-issued session id yet is keyed on its pid instead). Everything else — a `/` or `\`, a `..` segment, a bare `sub::` subagent id — is refused: `main.js` never routes a subagent id to either `git-changes-status` or `git-changes-diff` (subagents render as a read-only transcript, not a Changes-panel-bearing session), so that shape is out of scope rather than silently accepted. +0. **`isValidChangesSessionId(sessionId)`** — refused before any dependency runs, including `getCachedFolder`. Accepts a plain CLI-issued id (`/^[A-Za-z0-9._-]+$/`, excluding the bare `.`/`..` — same shape and rationale as `isValidSessionId` in `delete-session-target.js`, since a local id ultimately reaches `resolveSessionRealCwd` → `path.join(projectsDir, folder, sessionId + '.jsonl')`) or a remote descriptor-only placeholder id, `pid:` (`remote-index.js` `buildPlaceholderSession` — a live descriptor with no CLI-issued session id yet is keyed on its pid instead). Everything else — a `/` or `\`, a `..` segment — is refused. The composite `sub::` shape is refused too unless the caller passes `{allowSubagent: true}`; only `git-changes-status` and `git-changes-diff` do, so the editor, save, locate, watch, the panel shell and the terminal path links keep refusing it (`test/git-changes-file.test.js` pins the wiring). With the opt-in, both parts must match `[A-Za-z0-9._-]+` and not be `.`/`..`, and that check runs before any dependency. 1. **Remote folder** → the host's live descriptor list (`remoteIndexer.getRemoteSessions(alias)`), matched by `sessionId`. No PTY/attach required — issue #251's acceptance criteria is "works without attaching". 2. **Local, live in this app** → the session's own recorded `.cwd` (may be a worktree) — short-circuits the disk scan below. 3. **Local, not live here** → `resolveSessionRealCwd()`, the same disk scan `open-terminal`'s resume path uses ("For a Claude resume, spawn in the session's real recorded cwd…", `main.js`), so Changes and `claude --resume` never disagree about which directory a session's cwd really is. Refused if the resolved path no longer exists on disk. +**Subagent id (`sub::`, issue #303).** The target is read from the sidecar `//subagents/agent-.meta.json` (`readSubagentMeta`, the reader the indexer uses), not from the transcript: the sidecar exists when transcript saving is off, and a worktree-isolated agent records `worktreePath` there while an agent sharing the parent's directory records none. The folder comes from the session cache row of the `sub:` id; a remote folder is refused. No `worktreePath` resolves to the parent's own target, so a subagent sharing the parent's directory costs one sidecar read and shows the parent's rows. A recorded path must be absolute, free of control characters and `..` segments, and exist; a path that no longer exists is `{ok: false, reason: 'worktree-removed'}`, never a fall back to the parent's directory. The runner's `isSafeCwd` and the rest of the guards apply to it unchanged, the same trust the session's own recorded cwd gets. The result carries `subagent: true`. A sidecar that is missing or unparsable is `{ok: false, reason: 'no-worktree-recorded'}`: nothing says where the agent works, so the parent's rows are never presented as its own. A recorded path that starts with two separators (UNC) is refused; the parent's own cwd is left as it was. + +**Subagent groups in the parent's panel.** `git-changes-status` of a local session also returns `subagents: [{sessionId, agentId, label, branch, files, totals}]` and `subagentsOmitted`. `listSubagentWorktrees` (async, resolves `{worktrees, notScanned}`) reads each sidecar with `readSubagentMetaAsync`, at most 8 at a time. Answers are cached with a lifetime: a sidecar that names a worktree 5 minutes, one that records none or a removed worktree 30 seconds, an unreadable sidecar 10 seconds, the parent's `git rev-parse --git-common-dir` 30 seconds (the cache is cleared past 4000 entries), so a recreated worktree or a late sidecar is seen. It keeps the worktrees that exist, differ from the parent's cwd (`path.relative`, so case-insensitive on Windows) and are linked worktrees of the parent's repository: the worktree's `.git` must be a file (read on every pass, no git spawn) whose `gitdir:` target lies under `/worktrees/`, so git never runs in a repository the user did not already trust. Candidates are taken newest first (parsed `modified`, a missing time last) and at most 24 are scanned, accepted or not; the others are `notScanned` and counted in the omitted note. `collectSubagentChanges` runs `status()` for them 3 at a time and keeps the groups that have files: the cap of 8 applies to those, so clean worktrees take no slot, and the rest, plus `notScanned`, are counted in `subagentsOmitted` (the panel says `+N more subagent worktrees not shown`). The same refresh as the parent's own status, no watcher of its own. `git-changes-status` and `git-changes-diff` run `checkSubagentRepo` on a `sub:` target, which applies the same repository test. Every git run in a subagent worktree is a hardened runner (`hardened: true`): `-c core.fsmonitor=false` on every command, since a worktree's own config (`config.worktree`) could otherwise name an fsmonitor program. A recorded path may be an extended-length drive path (`\\?\C:\...`), normalised to the drive path; any other path starting with two separators is refused. The renderer lists each group under a header (`label · branch`) and a row carries `subSessionId`: its click goes straight to `git-changes-diff` with the `sub:` id, never to the editable content pair, and a subagent row's selection and untracked counts are matched on `(subSessionId, path)`, since a path can repeat across the parent and a worktree. Edit, save, locate and watch still refuse a `sub:` id. + Extracted out of the two IPC handlers into its own module, fully dependency-injected, so this order is unit-tested without booting Electron (`test/git-changes-target.test.js`) — same rationale `delete-session-target.js` and `run-schedule-now-target.js` already document for their own handlers. Step 0's whole point is to be provably reachable *before* the disk-scanning fallback (step 3): `test/git-changes-target.test.js` asserts `resolveSessionRealCwd` is never called for `"../../x"`. `filePath` on `git-changes-diff` is a git pathspec relative to that cwd, not an absolute filesystem path, so `ipc-path-validator.js`'s allowlist/denylist helpers (which assume an absolute path under a known root) don't fit — it's validated by the runner's own `isSafeGitPath` instead (see "Quoting rule" above). diff --git a/CHANGELOG.md b/CHANGELOG.md index c841a860..39b57bd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ What changes for you in each release of Switchboard. How to write an entry: [doc ## Unreleased ### New +- A session's Changes panel also lists the changes in the worktrees its subagents are working in, under a header naming the agent and its branch. Those rows open as read-only diffs; a subagent that works in the session's own directory adds nothing. (#303) - A live session on a remote host that is not open in a terminal has a Send a prompt… button on its row: type a text and it is written to the running session as a new prompt, without attaching. It needs `ncat` or an OpenBSD `nc` on the host, and is refused for a Windows host. The dialog says "Sent": the session's own status shows whether it picked the prompt up. (#219) - A remote session that is not open in a tab and waits on a dialog on its host, such as a permission prompt or a question, shows the orange attention state, and its status line says what it waits for. It appears and clears with the next refresh of the host. (#394) diff --git a/docs/changes-view.md b/docs/changes-view.md index c536bd51..fa50ae30 100644 --- a/docs/changes-view.md +++ b/docs/changes-view.md @@ -43,6 +43,11 @@ the plain file viewer. When a working tree holds tens of thousands of untracked files, the untracked part is listed by directory instead, as `git status` does by default, and the panel says so. +- When a subagent of the session works in a worktree of its own, its changes + are listed after the session's own, under a header with the agent's name and + branch (at most 8 agents with changes, 100 rows each; the panel counts the agents it leaves out). Those rows open as read-only + diffs. A subagent in the session's directory, one whose worktree is gone, and + one with nothing changed add nothing. Local sessions only. - **Refresh** reloads the list. Clicking a row opens the file under the list, which stays visible with the row diff --git a/git-changes-runner.js b/git-changes-runner.js index 3a6deb62..7bd516f8 100644 --- a/git-changes-runner.js +++ b/git-changes-runner.js @@ -368,8 +368,9 @@ function missingCwdError(cwd, fsOps = DEFAULT_FS_OPS) { } // --literal-pathspecs on every invocation — see .ai/contexts/changes-view.md ("Quoting rule"). -function buildGitArgs(args) { - return ['--literal-pathspecs', ...args]; +function buildGitArgs(args, opts) { + const hardened = opts && opts.hardened ? ['-c', 'core.fsmonitor=false'] : []; + return ['--literal-pathspecs', ...hardened, ...args]; } // Cut on a line boundary at or under maxBytes, measured in UTF-8 bytes — see .ai/contexts/changes-view.md ("Runner interface") @@ -449,7 +450,7 @@ function isStdoutCapFailure(result) { } // {kind, cwd, alias, exec, timeoutMs, fsOps, countLimits} — see .ai/contexts/changes-view.md ("Runner interface") -function createGitChangesRunner({ kind, cwd, alias, exec, timeoutMs, fsOps, countLimits } = {}) { +function createGitChangesRunner({ kind, cwd, alias, exec, timeoutMs, fsOps, countLimits, hardened } = {}) { if (kind !== 'local' && kind !== 'remote') { throw new Error('createGitChangesRunner requires kind "local" or "remote"'); } @@ -473,7 +474,7 @@ function createGitChangesRunner({ kind, cwd, alias, exec, timeoutMs, fsOps, coun // opts.at runs the command in that directory instead of the session cwd; maxStdoutBytes matters only remotely — see .ai/contexts/changes-view.md ("Remote transport stdout cap") function invoke(args, opts = {}) { - const fullArgs = buildGitArgs(args); + const fullArgs = buildGitArgs(args, { hardened: !!hardened && kind === 'local' }); const at = opts.at || cwd; if (kind === 'local') { const localOpts = {}; diff --git a/git-changes-target.js b/git-changes-target.js index 9d5cb982..8e74c20b 100644 --- a/git-changes-target.js +++ b/git-changes-target.js @@ -2,21 +2,300 @@ 'use strict'; +const path = require('path'); + // Accepted sessionId shapes — see .ai/contexts/changes-view.md ("cwd resolution"). const PLAIN_SESSION_ID_RE = /^[A-Za-z0-9._-]+$/; const PLACEHOLDER_SESSION_ID_RE = /^pid:[1-9][0-9]*$/; +const SUBAGENT_SESSION_ID_RE = /^sub:([A-Za-z0-9._-]+):([A-Za-z0-9._-]+)$/; + +function isDotSegment(s) { + return s === '.' || s === '..'; +} + +function parseSubagentId(id) { + const m = typeof id === 'string' ? SUBAGENT_SESSION_ID_RE.exec(id) : null; + if (!m || isDotSegment(m[1]) || isDotSegment(m[2])) return null; + return { parentId: m[1], agentId: m[2] }; +} -function isValidChangesSessionId(id) { +// opts.allowSubagent admits the sub:: shape, for the read-only IPCs only. +function isValidChangesSessionId(id, opts) { if (typeof id !== 'string' || id === '') return false; if (id === '.' || id === '..') return false; if (PLAIN_SESSION_ID_RE.test(id)) return true; - return PLACEHOLDER_SESSION_ID_RE.test(id); + if (PLACEHOLDER_SESSION_ID_RE.test(id)) return true; + return !!(opts && opts.allowSubagent) && parseSubagentId(id) !== null; +} + +const BACKSLASH = String.fromCharCode(92); +const DRIVE_RE = /^[A-Za-z]:/; + +function isSep(c) { + return c === '/' || c === BACKSLASH; +} + +function hasControlChar(s) { + for (let i = 0; i < s.length; i++) if (s.charCodeAt(i) < 32) return true; + return false; +} + +// The path to use, or null. An extended-length drive path (backslash-backslash, '?' or '.', backslash, then a drive +// and a root) is normalised to the drive path; every other path starting with two separators is refused. +function normaliseWorktreePath(p) { + if (typeof p !== 'string' || p === '' || p.length > 4096) return null; + if (hasControlChar(p)) return null; + let q = p; + if (isSep(q[0]) && isSep(q[1])) { + const extended = q[0] === BACKSLASH && q[1] === BACKSLASH && (q[2] === '?' || q[2] === '.') && q[3] === BACKSLASH; + const rest = extended ? q.slice(4) : ''; + if (!DRIVE_RE.test(rest) || !isSep(rest[2])) return null; + q = rest; + } + if (!path.win32.isAbsolute(q) && !path.posix.isAbsolute(q)) return null; + if (q.split('/').join(BACKSLASH).split(BACKSLASH).includes('..')) return null; + return q; +} + +function samePath(a, b, pathOps = path) { + return pathOps.relative(pathOps.resolve(a), pathOps.resolve(b)) === ''; +} + +// {ok: true, worktree: string|null} (null: the agent records none, so it shares its parent's directory) or a refusal. +function worktreeFromMeta(meta) { + if (!meta || typeof meta !== 'object') { + return { ok: false, reason: 'no-worktree-recorded', error: 'the subagent has no readable record of its worktree' }; + } + const recorded = meta.worktreePath; + if (recorded === undefined || recorded === null) return { ok: true, worktree: null }; + const worktree = normaliseWorktreePath(recorded); + if (worktree === null) return { ok: false, error: 'the subagent recorded an unusable worktree path' }; + return { ok: true, worktree }; +} + +function subagentJsonlPath(id, folder, deps) { + const { parentId, agentId } = parseSubagentId(id); + return path.join(deps.projectsDir, folder, parentId, 'subagents', `agent-${agentId}.jsonl`); +} + +function readSubagentWorktree(id, deps) { + let folder = null; + try { folder = deps.getCachedFolder(id); } catch {} + if (!folder) return { ok: false, error: 'subagent not found' }; + if (deps.isRemoteFolder(folder)) return { ok: false, error: 'a remote subagent has no Changes view' }; + + const found = worktreeFromMeta(deps.readSubagentMeta(subagentJsonlPath(id, folder, deps))); + if (!found.ok || found.worktree === null) return found; + if (!deps.existsSync(found.worktree)) { + return { ok: false, reason: 'worktree-removed', error: 'the worktree of this subagent no longer exists' }; + } + return found; +} + +function resolveSubagentTarget(id, deps) { + const found = readSubagentWorktree(id, deps); + if (!found.ok) return found; + if (found.worktree === null) { + const parent = resolveGitChangesTarget(parseSubagentId(id).parentId, deps); + return parent.ok ? { ...parent, subagent: true } : parent; + } + return { ok: true, kind: 'local', cwd: found.worktree, subagent: true }; +} + +const MAX_SUBAGENT_WORKTREES = 8; +const MAX_SCANNED_WORKTREES = 24; +const SIDECAR_READ_CONCURRENCY = 8; +const SUBAGENT_STATUS_CONCURRENCY = 3; +const MAX_CACHE_ENTRIES = 4000; +const TTL_WORKTREE_MS = 300_000; +const TTL_SHORT_MS = 30_000; +const TTL_UNREADABLE_MS = 10_000; + +const defaultCache = new Map(); + +async function mapLimit(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + const workers = []; + for (let w = 0; w < Math.min(limit, items.length); w++) { + workers.push((async () => { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i], i); + } + })()); + } + await Promise.all(workers); + return results; +} + +function nowOf(deps) { + return typeof deps.now === 'function' ? deps.now() : Date.now(); +} + +function cacheGet(cache, key, now) { + const hit = cache.get(key); + if (!hit) return undefined; + if (hit.expires <= now) { + cache.delete(key); + return undefined; + } + return hit.value; +} + +function cacheSet(cache, key, value, ttlMs, now) { + if (cache.size >= MAX_CACHE_ENTRIES) cache.clear(); + cache.set(key, { value, expires: now + ttlMs }); +} + +async function commonDirOf(cwd, deps, cache) { + const key = 'common:' + cwd; + const now = nowOf(deps); + const cached = cacheGet(cache, key, now); + if (cached) return cached; + let dir = null; + try { dir = await deps.gitCommonDir(cwd); } catch {} + if (typeof dir !== 'string' || dir === '') return null; + cacheSet(cache, key, dir, TTL_SHORT_MS, now); + return dir; +} + +// A linked worktree's .git is a file naming a directory under /worktrees; read it, never ask git. +// deps.readDotGit(worktree) -> {file: boolean, content: string} | null +async function worktreeBelongsTo(worktree, parentCommon, deps, pathOps) { + let info = null; + try { info = await deps.readDotGit(worktree); } catch {} + if (!info || info.file !== true || typeof info.content !== 'string') return false; + const line = info.content.split('\n')[0].trim(); + if (!line.startsWith('gitdir:')) return false; + const target = line.slice('gitdir:'.length).trim(); + if (target === '' || hasControlChar(target)) return false; + const rel = pathOps.relative(pathOps.resolve(parentCommon, 'worktrees'), pathOps.resolve(worktree, target)); + return rel !== '' && rel.split(pathOps.sep)[0] !== '..' && !pathOps.isAbsolute(rel); +} + +function timeOf(row) { + const t = Date.parse(row && row.modified); + return Number.isNaN(t) ? -Infinity : t; +} + +// deps adds listSubagents(parentId) -> [{sessionId, agentId, description, subagentType, modified}], +// readSubagentMetaAsync(jsonlPath), exists(path), gitCommonDir(cwd), readDotGit(worktree) and optionally cache, now and pathOps. +// Resolves {worktrees, notScanned}. see .ai/contexts/changes-view.md ("Subagent worktrees") +async function listSubagentWorktrees(parentId, deps) { + const none = { worktrees: [], notScanned: 0 }; + const id = String(parentId || ''); + if (!isValidChangesSessionId(id)) return none; + const parent = resolveGitChangesTarget(id, deps); + if (!parent.ok || parent.kind !== 'local') return none; + const pathOps = deps.pathOps || path; + const cache = deps.cache || defaultCache; + const now = nowOf(deps); + + const items = []; + for (const row of deps.listSubagents(id) || []) { + if (!row || !isValidChangesSessionId(row.sessionId, { allowSubagent: true })) continue; + const parsed = parseSubagentId(row.sessionId); + if (!parsed || parsed.parentId !== id) continue; + let folder = null; + try { folder = deps.getCachedFolder(row.sessionId); } catch {} + if (!folder || deps.isRemoteFolder(folder)) continue; + items.push({ row, parsed, key: 'sidecar:' + subagentJsonlPath(row.sessionId, folder, deps) }); + } + + await mapLimit(items, SIDECAR_READ_CONCURRENCY, async (item) => { + let entry = cacheGet(cache, item.key, now); + if (!entry) { + let meta = null; + try { meta = await deps.readSubagentMetaAsync(item.key.slice('sidecar:'.length)); } catch {} + const found = worktreeFromMeta(meta); + if (found.reason === 'no-worktree-recorded') { + cacheSet(cache, item.key, { unreadable: true }, TTL_UNREADABLE_MS, now); + return; + } + entry = { worktree: found.ok ? found.worktree : null }; + cacheSet(cache, item.key, entry, entry.worktree ? TTL_WORKTREE_MS : TTL_SHORT_MS, now); + } + item.entry = entry; + }); + + const candidates = items + .filter((item) => item.entry && item.entry.worktree && !cacheGet(cache, 'gone:' + item.entry.worktree, now)) + .sort((x, y) => timeOf(y.row) - timeOf(x.row)); + if (candidates.length === 0) return none; + + const parentCommon = await commonDirOf(parent.cwd, deps, cache); + if (!parentCommon) return none; + + const worktrees = []; + let scanned = 0; + for (const item of candidates) { + if (scanned >= MAX_SCANNED_WORKTREES) break; + scanned++; + const worktree = item.entry.worktree; + let present = false; + try { present = await deps.exists(worktree); } catch {} + if (!present) { + cacheSet(cache, 'gone:' + worktree, true, TTL_SHORT_MS, now); + continue; + } + if (samePath(parent.cwd, worktree, pathOps)) continue; + if (!(await worktreeBelongsTo(worktree, parentCommon, deps, pathOps))) continue; + worktrees.push({ + sessionId: item.row.sessionId, + agentId: item.parsed.agentId, + label: item.row.description || item.row.subagentType || item.parsed.agentId, + cwd: worktree, + }); + } + return { worktrees, notScanned: candidates.length - scanned }; +} + +// A subagent worktree is only read through git when it is a linked worktree of the parent's repository. +async function checkSubagentRepo(sessionId, target, deps) { + if (!target || target.ok !== true) return target; + if (!target.subagent) return { ok: true }; + const cache = deps.cache || defaultCache; + const pathOps = deps.pathOps || path; + const parent = resolveGitChangesTarget(parseSubagentId(sessionId).parentId, deps); + if (!parent.ok) return parent; + if (samePath(parent.cwd, target.cwd, pathOps)) return { ok: true }; + const parentCommon = await commonDirOf(parent.cwd, deps, cache); + if (!parentCommon || !(await worktreeBelongsTo(target.cwd, parentCommon, deps, pathOps))) { + return { ok: false, reason: 'other-repo', error: 'the subagent worktree does not belong to the session repository' }; + } + return { ok: true }; +} + +async function collectSubagentChanges(groups, runnerFor) { + const settled = await mapLimit(groups, SUBAGENT_STATUS_CONCURRENCY, async (group) => { + try { + const result = await runnerFor(group.cwd).status(); + if (!result || result.ok === false || !Array.isArray(result.files) || result.files.length === 0) return null; + return { + sessionId: group.sessionId, + agentId: group.agentId, + label: group.label, + branch: result.branch, + files: result.files, + totals: result.totals, + }; + } catch { + return null; + } + }); + const withChanges = settled.filter(Boolean); + return { + subagents: withChanges.slice(0, MAX_SUBAGENT_WORKTREES), + omitted: Math.max(0, withChanges.length - MAX_SUBAGENT_WORKTREES), + }; } -// deps: {getCachedFolder, isRemoteFolder, parseFolderKey, getRemoteSessions, activeSessions, resolveSessionRealCwd, existsSync, projectsDir} -function resolveGitChangesTarget(sessionId, deps) { +// deps: {getCachedFolder, isRemoteFolder, parseFolderKey, getRemoteSessions, activeSessions, resolveSessionRealCwd, existsSync, projectsDir, readSubagentMeta} +function resolveGitChangesTarget(sessionId, deps, opts) { const id = String(sessionId || ''); - if (!isValidChangesSessionId(id)) return { ok: false, error: 'invalid session id' }; + if (!isValidChangesSessionId(id, opts)) return { ok: false, error: 'invalid session id' }; + if (parseSubagentId(id)) return resolveSubagentTarget(id, deps); let folder = null; try { folder = deps.getCachedFolder(id); } catch {} @@ -40,4 +319,4 @@ function resolveGitChangesTarget(sessionId, deps) { return { ok: false, error: 'could not resolve a working directory for this session' }; } -module.exports = { resolveGitChangesTarget, isValidChangesSessionId }; +module.exports = { resolveGitChangesTarget, isValidChangesSessionId, parseSubagentId, listSubagentWorktrees, collectSubagentChanges, checkSubagentRepo }; diff --git a/main.js b/main.js index b57c73e3..c466942c 100644 --- a/main.js +++ b/main.js @@ -81,7 +81,8 @@ const { createTriggerContext } = require('./trigger-context'); const { createTmuxAttachAdapter } = require('./remote-attach'); const { createRemoteStopAdapter } = require('./remote-stop'); const { createRemoteSendAdapter, handleSendRequest } = require('./remote-send'); -const { createGitChangesRunner } = require('./git-changes-runner'); +const { createGitChangesRunner, localGitEnv } = require('./git-changes-runner'); +const { runToExit } = require('./run-to-exit'); const gitChangesTarget = require('./git-changes-target'); const terminalPathTarget = require('./terminal-path-target'); const { resolvePanelTerminalCwd, isPanelShellSession } = require('./panel-terminal-target'); @@ -476,7 +477,7 @@ sessionCache.init({ const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem, buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker, scanFoldersViaWorker, setRemoteRoots, resolveFolderDir, isIndexingFinished } = sessionCache; -const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file'); +const { resolveJsonlPath, enumerateSessionFiles, readSubagentMeta } = require('./read-session-file'); // --- Remote SSH hosts (observation only) — see .ai/contexts/session-cache.md --- const { isRemoteFolder, parseFolderKey, joinFolderKey, enabledHosts } = require('./remote-hosts'); @@ -1721,8 +1722,8 @@ ipcMain.handle('remote-send-prompt', (_event, payload) => handleSendRequest(payl })); // --- IPC: git-changes-status / git-changes-diff — see .ai/contexts/changes-view.md --- -function resolveGitChangesTarget(sessionId) { - return gitChangesTarget.resolveGitChangesTarget(sessionId, { +function gitChangesTargetDeps() { + return { getCachedFolder, isRemoteFolder, parseFolderKey, @@ -1731,21 +1732,64 @@ function resolveGitChangesTarget(sessionId) { resolveSessionRealCwd, existsSync: (p) => fs.existsSync(p), projectsDir: PROJECTS_DIR, - }); + readSubagentMeta, + readSubagentMetaAsync, + exists: (p) => fs.promises.access(p).then(() => true, () => false), + gitCommonDir: gitCommonDirOf, + readDotGit: readDotGitFile, + listSubagents: (parentId) => getCachedByParent(parentId), + }; +} + +async function readSubagentMetaAsync(jsonlPath) { + try { + return JSON.parse(await fs.promises.readFile(jsonlPath.replace(/[.]jsonl$/, '.meta.json'), 'utf8')); + } catch { + return null; + } +} + +async function readDotGitFile(worktree) { + const dotGit = path.join(worktree, '.git'); + try { + const stat = await fs.promises.lstat(dotGit); + if (!stat.isFile() || stat.size > 4096) return { file: false, content: '' }; + return { file: true, content: await fs.promises.readFile(dotGit, 'utf8') }; + } catch { + return null; + } +} + +async function gitCommonDirOf(cwd) { + const result = await runToExit('git', ['rev-parse', '--git-common-dir'], { cwd, env: localGitEnv(), timeoutMs: 5000, maxBuffer: 65536 }); + if (result.code !== 0) return null; + const out = result.stdout.toString('utf8').trim(); + return out ? path.resolve(cwd, out) : null; +} + +function resolveGitChangesTarget(sessionId, opts) { + return gitChangesTarget.resolveGitChangesTarget(sessionId, gitChangesTargetDeps(), opts); } function gitChangesRunnerFor(target) { return target.kind === 'remote' ? createGitChangesRunner({ kind: 'remote', cwd: target.cwd, alias: target.alias }) - : createGitChangesRunner({ kind: 'local', cwd: target.cwd }); + : createGitChangesRunner({ kind: 'local', cwd: target.cwd, hardened: !!target.subagent }); } ipcMain.handle('git-changes-status', async (_event, sessionId) => { - const target = resolveGitChangesTarget(sessionId); + const resolved = resolveGitChangesTarget(sessionId, { allowSubagent: true }); + const target = await gitChangesTarget.checkSubagentRepo(sessionId, resolved, gitChangesTargetDeps()); if (!target.ok) return target; try { - const result = await gitChangesRunnerFor(target).status(); - return result.ok === false ? result : { ...result, kind: target.kind }; + const result = await gitChangesRunnerFor(resolved).status(); + if (result.ok === false) return result; + const { worktrees, notScanned } = await gitChangesTarget.listSubagentWorktrees(sessionId, gitChangesTargetDeps()); + if (worktrees.length === 0) return { ...result, kind: resolved.kind }; + const { subagents, omitted } = await gitChangesTarget.collectSubagentChanges( + worktrees, (cwd) => createGitChangesRunner({ kind: 'local', cwd, hardened: true })); + if (subagents.length === 0) return { ...result, kind: resolved.kind }; + return { ...result, kind: resolved.kind, subagents, subagentsOmitted: omitted + notScanned }; } catch (err) { return { ok: false, error: err.message }; } @@ -1754,10 +1798,11 @@ ipcMain.handle('git-changes-status', async (_event, sessionId) => { // filePath is a git pathspec, or an untracked file's --no-index operand — see .ai/contexts/changes-view.md ipcMain.handle('git-changes-diff', async (_event, sessionId, filePath, staged, untracked) => { if (typeof filePath !== 'string' || !filePath) return { ok: false, error: 'invalid path' }; - const target = resolveGitChangesTarget(sessionId); + const resolved = resolveGitChangesTarget(sessionId, { allowSubagent: true }); + const target = await gitChangesTarget.checkSubagentRepo(sessionId, resolved, gitChangesTargetDeps()); if (!target.ok) return target; try { - return await gitChangesRunnerFor(target).diff(filePath, { staged: !!staged, untracked: !!untracked }); + return await gitChangesRunnerFor(resolved).diff(filePath, { staged: !!staged, untracked: !!untracked }); } catch (err) { return { ok: false, error: err.message }; } diff --git a/public/file-panel.js b/public/file-panel.js index bd30ae51..f24049dc 100644 --- a/public/file-panel.js +++ b/public/file-panel.js @@ -51,6 +51,7 @@ let changesListSplitterEl = null; // Row ceiling for the Changes list — see .ai/contexts/changes-view.md ("Untracked files") const MAX_CHANGES_ROWS = 500; +const MAX_SUBAGENT_GROUP_ROWS = 100; const CHANGES_LIST_HEIGHT_KEY = 'changesListHeight'; const DEFAULT_CHANGES_LIST_HEIGHT = 200; @@ -1098,7 +1099,10 @@ async function openChangesDiff(sessionId, file, line = null) { tab.diffLoading = true; if (currentPanelSessionId === sessionId) renderPanel(sessionId); - if (!tab.remote) { + const ipcId = file.subSessionId || sessionId; + if (file.subSessionId) { + tab.fallbackReason = 'subagent worktree'; + } else if (!tab.remote) { const pair = await window.api.gitChangesFile(sessionId, file.path, { staged: !!file.staged }); const pairState = filePanelState.get(sessionId); @@ -1119,7 +1123,7 @@ async function openChangesDiff(sessionId, file, line = null) { tab.fallbackReason = describeFallback(pair); } - const result = await window.api.gitChangesDiff(sessionId, file.path, file.staged, file.untracked); + const result = await window.api.gitChangesDiff(ipcId, file.path, file.staged, file.untracked); const stillState = filePanelState.get(sessionId); if (!stillState || stillState.currentTab !== tab || tab.selectedFile !== file) return; @@ -1130,7 +1134,7 @@ async function openChangesDiff(sessionId, file, line = null) { } else { tab.diffContent = result.content; tab.diffTruncated = !!result.truncated; - if (file.untracked) applyUntrackedCounts(tab, dataAtRequest, file.path, result.added, result.deleted, result.countStatus); + if (file.untracked && !file.subSessionId) applyUntrackedCounts(tab, dataAtRequest, file.path, result.added, result.deleted, result.countStatus); } if (currentPanelSessionId === sessionId) renderPanel(sessionId); } @@ -1252,8 +1256,10 @@ function renderChangesList(sessionId, tab) { if (!data) return; const { branch, files, totals } = data; + const subagentGroups = Array.isArray(data.subagents) ? data.subagents : []; + const noChangesText = subagentGroups.length > 0 ? 'No changes in the session directory' : 'No changes'; changesSummaryEl.textContent = totals.files === 0 - ? 'No changes' + ? noChangesText : `${totals.files} file${totals.files === 1 ? '' : 's'} changed +${totals.added} −${totals.deleted}` + describeUncounted(totals.uncounted); if (branchInfoEl) { @@ -1282,6 +1288,36 @@ function renderChangesList(sessionId, tab) { more.textContent = `+${files.length - shown.length} more files not shown`; changesListEl.appendChild(more); } + + for (const group of subagentGroups) appendSubagentChangesGroup(sessionId, tab, group); + if (subagentGroups.length > 0 && data.subagentsOmitted > 0) { + const more = document.createElement('div'); + more.className = 'changes-more-note'; + more.textContent = `+${data.subagentsOmitted} more subagent worktrees not shown`; + changesListEl.appendChild(more); + } +} + +// see .ai/contexts/changes-view.md ("Subagent worktrees") +function appendSubagentChangesGroup(sessionId, tab, group) { + if (!group || typeof group.sessionId !== 'string' || !Array.isArray(group.files) || group.files.length === 0) return; + const header = document.createElement('div'); + header.className = 'changes-subagent-header'; + const parts = [String(group.label || group.agentId || 'subagent')]; + if (group.branch && group.branch.head) parts.push(group.branch.head); + header.textContent = parts.join(' · '); + changesListEl.appendChild(header); + + const shown = group.files.length > MAX_SUBAGENT_GROUP_ROWS ? group.files.slice(0, MAX_SUBAGENT_GROUP_ROWS) : group.files; + for (const file of shown) { + changesListEl.appendChild(buildChangesFileRow(sessionId, tab, file, group.sessionId)); + } + if (shown.length < group.files.length) { + const more = document.createElement('div'); + more.className = 'changes-more-note'; + more.textContent = `+${group.files.length - shown.length} more files not shown`; + changesListEl.appendChild(more); + } } // A row with no count says why — see .ai/contexts/changes-view.md ("Untracked line counts") @@ -1299,10 +1335,11 @@ function describeUncounted(uncounted) { return ` (${uncounted} file${uncounted === 1 ? '' : 's'} not counted)`; } -function buildChangesFileRow(sessionId, tab, file) { +function buildChangesFileRow(sessionId, tab, file, subSessionId = null) { const row = document.createElement('div'); row.className = 'changes-file-row'; row.dataset.path = file.path; + if (subSessionId) row.dataset.subagent = subSessionId; const state = document.createElement('span'); state.className = 'changes-file-state changes-state-' + (file.state || '?').toLowerCase(); @@ -1337,12 +1374,13 @@ function buildChangesFileRow(sessionId, tab, file) { } row.appendChild(counts); - if (isSelectedChangesRow(tab, file)) row.classList.add('selected'); + const identity = { path: file.path, subSessionId }; + if (isSelectedChangesRow(tab, identity)) row.classList.add('selected'); row.addEventListener('click', () => { // prefer the unstaged (worktree) diff when a file has both const staged = !!file.staged && !file.unstaged; - openChangesDiff(sessionId, { path: file.path, staged, untracked: !!file.untracked }); + openChangesDiff(sessionId, { path: file.path, staged, untracked: !!file.untracked, subSessionId }); }); return row; } @@ -1576,7 +1614,7 @@ function mountChangesEditor(dom) { function isSelectedChangesRow(tab, file) { const selected = tab && tab.selectedFile; - return !!selected && selected.path === file.path; + return !!selected && selected.path === file.path && (selected.subSessionId || null) === (file.subSessionId || null); } function changesEditorKey(tab) { diff --git a/public/style.css b/public/style.css index a1414dd8..9655c70f 100644 --- a/public/style.css +++ b/public/style.css @@ -4885,6 +4885,15 @@ body { display: flex; flex-direction: column; } color: var(--text-muted); } +.changes-subagent-header { + padding: 6px 12px; + margin-top: 6px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + border-top: 1px solid var(--hairline); +} + /* The list keeps its explicit height while the editor is open; it is the editor that takes the remaining space. */ #changes-list.changes-list-split { diff --git a/test/dom-file-panel-changes.test.js b/test/dom-file-panel-changes.test.js index a898ac96..84735300 100644 --- a/test/dom-file-panel-changes.test.js +++ b/test/dom-file-panel-changes.test.js @@ -2348,3 +2348,146 @@ test('the tab the panel X closed is reopened by the Changes button', async () => assert.equal(ctx.document.querySelectorAll('.changes-file-row').length, 2); } finally { ctx.destroy(); } }); + +// --- Subagent worktrees (issue #303) ------------------------------------- + +function subagentGroup(overrides = {}) { + return { + sessionId: 'sub:s1:aaaa', + agentId: 'aaaa', + label: 'worktree long', + branch: { head: 'worktree-agent-aaaa', upstream: null, ahead: 0, behind: 0 }, + files: [ + { path: 'src/a.js', origPath: null, staged: false, unstaged: true, untracked: false, renamed: false, state: 'M', added: 2, deleted: 0 }, + { path: 'essai.md', origPath: null, staged: false, unstaged: false, untracked: true, renamed: false, state: '?', added: 2, deleted: null }, + ], + totals: { files: 2, added: 4, deleted: 0, uncounted: 0 }, + ...overrides, + }; +} + +function subagentRows(ctx) { + return Array.from(ctx.document.querySelectorAll('.changes-file-row[data-subagent]')); +} + +test('a parent with no subagent worktrees renders no group header (mutation target: rendering an empty group)', async () => { + const ctx = setupFilePanelDom(); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + await flush(); + assert.equal(ctx.document.querySelectorAll('.changes-subagent-header').length, 0); + assert.equal(subagentRows(ctx).length, 0); + } finally { ctx.destroy(); } +}); + +test('the parent panel lists each subagent worktree under a header naming the agent, rows kept apart from the parent rows', async () => { + const ctx = setupFilePanelDom({ statusImpl: () => makeStatusResult({ subagents: [subagentGroup()] }) }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + await flush(); + + const headers = Array.from(ctx.document.querySelectorAll('.changes-subagent-header')); + assert.equal(headers.length, 1); + assert.match(headers[0].textContent, /worktree long/); + assert.match(headers[0].textContent, /worktree-agent-aaaa/); + + assert.deepEqual(subagentRows(ctx).map((r) => r.dataset.path), ['src/a.js', 'essai.md']); + assert.equal(ctx.document.querySelectorAll('.changes-file-row:not([data-subagent])').length, 2, + 'the parent keeps its own two rows, even where a path repeats'); + assert.match(ctx.document.getElementById('changes-summary').textContent, /2 files changed \+3/, + 'the header total stays the session own directory total'); + } finally { ctx.destroy(); } +}); + +test('subagent worktrees past the cap are counted, not silently dropped (mutation target: ignoring the omitted count)', async () => { + const ctx = setupFilePanelDom({ statusImpl: () => makeStatusResult({ subagents: [subagentGroup()], subagentsOmitted: 3 }) }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + await flush(); + const notes = Array.from(ctx.document.querySelectorAll('.changes-more-note')).map((n) => n.textContent); + assert.ok(notes.includes('+3 more subagent worktrees not shown'), JSON.stringify(notes)); + } finally { ctx.destroy(); } +}); + +test('with no subagent worktree omitted there is no such note', async () => { + const ctx = setupFilePanelDom({ statusImpl: () => makeStatusResult({ subagents: [subagentGroup()], subagentsOmitted: 0 }) }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + await flush(); + assert.equal(ctx.document.querySelectorAll('.changes-more-note').length, 0); + } finally { ctx.destroy(); } +}); + +test('with no change in its own directory the panel says so and still lists the subagent group', async () => { + const empty = { files: [], totals: { files: 0, added: 0, deleted: 0, uncounted: 0 } }; + const ctx = setupFilePanelDom({ statusImpl: () => makeStatusResult({ ...empty, subagents: [subagentGroup()] }) }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + await flush(); + const summary = ctx.document.getElementById('changes-summary').textContent; + assert.match(summary, /No changes/); + assert.match(summary, /session/i, 'it is the session directory that has none'); + assert.equal(subagentRows(ctx).length, 2); + } finally { ctx.destroy(); } +}); + +test('clicking a subagent row diffs it through the subagent id, read-only, with no editor and no watch (mutation target: opening the editor)', async () => { + const ctx = setupFilePanelDom({ statusImpl: () => makeStatusResult({ subagents: [subagentGroup()] }) }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + await flush(); + subagentRows(ctx)[0].dispatchEvent(new ctx.window.Event('click', { bubbles: true })); + await flush(); + + assert.deepEqual(ctx.calls.diff, [{ sessionId: 'sub:s1:aaaa', filePath: 'src/a.js', staged: false, untracked: false }]); + assert.equal(ctx.calls.file.length, 0, 'the editable content pair is never requested for a subagent row'); + assert.equal(ctx.calls.watch.length, 0); + assert.equal(ctx.editors.length, 0); + assert.ok(ctx.document.querySelector('.changes-diff-line, #changes-diff-view *'), 'the diff is shown'); + } finally { ctx.destroy(); } +}); + +test('selecting a subagent row does not select the parent row with the same path, and its counts never land on the parent record (mutation target: matching on path alone)', async () => { + const group = subagentGroup({ + files: [{ path: 'new.txt', origPath: null, staged: false, unstaged: false, untracked: true, renamed: false, state: '?', added: null, deleted: null, countStatus: 'on-open' }], + totals: { files: 1, added: 0, deleted: 0, uncounted: 1 }, + }); + const ctx = setupFilePanelDom({ + statusImpl: () => makeStatusResult({ subagents: [group] }), + diffImpl: () => ({ ok: true, content: '--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1,5 @@\n+a\n+b\n+c\n+d\n+e\n', truncated: false, added: 5, deleted: 0 }), + }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + await flush(); + subagentRows(ctx)[0].dispatchEvent(new ctx.window.Event('click', { bubbles: true })); + await flush(); + + const selected = ctx.document.querySelectorAll('.changes-file-row.selected'); + assert.equal(selected.length, 1); + assert.ok(selected[0].dataset.subagent, 'the subagent row is the selected one'); + const parentRow = ctx.document.querySelector('.changes-file-row[data-path="new.txt"]:not([data-subagent])'); + assert.match(parentRow.textContent, /count on open/, 'the parent record kept its own count status'); + } finally { ctx.destroy(); } +}); + +test('a diff failure on a subagent row is shown, not thrown', async () => { + const ctx = setupFilePanelDom({ + statusImpl: () => makeStatusResult({ subagents: [subagentGroup()] }), + diffImpl: () => ({ ok: false, error: 'the worktree of this subagent no longer exists' }), + }); + try { + ctx.window.switchPanel('s1'); + await ctx.window.openChangesTab('s1'); + await flush(); + subagentRows(ctx)[0].dispatchEvent(new ctx.window.Event('click', { bubbles: true })); + await flush(); + assert.match(ctx.document.getElementById('changes-diff-view').textContent, /no longer exists/); + } finally { ctx.destroy(); } +}); diff --git a/test/git-changes-file.test.js b/test/git-changes-file.test.js index db316af1..498641dc 100644 --- a/test/git-changes-file.test.js +++ b/test/git-changes-file.test.js @@ -130,6 +130,27 @@ test('every Changes handler that touches the filesystem refuses a remote session } }); +test('only the read-only status and diff handlers admit a subagent id (mutation target: the wiring)', () => { + const main = mainSource(); + for (const channel of ['git-changes-status', 'git-changes-diff']) { + assert.match(handlerBody(main, channel), /resolveGitChangesTarget\(sessionId, \{ allowSubagent: true \}\)/, + `${channel} must opt in to a subagent id`); + } + for (const channel of ['git-changes-file', 'git-changes-save', 'git-changes-watch', 'git-changes-locate']) { + assert.doesNotMatch(handlerBody(main, channel), /allowSubagent/, `${channel} must keep refusing a subagent id`); + } + assert.match(main, /readSubagentMeta/, 'main must inject the sidecar reader'); + assert.match(main, /hardened: !!target[.]subagent/, 'a subagent target runs a hardened runner'); + assert.match(handlerBody(main, 'git-changes-status'), /hardened: true/, 'the groups of a status reply run hardened runners'); + for (const channel of ['git-changes-status', 'git-changes-diff']) { + assert.match(handlerBody(main, channel), /await gitChangesTarget[.]checkSubagentRepo[(]sessionId, resolved/, + `${channel} must check a subagent worktree belongs to the session repository`); + } + const status = handlerBody(main, 'git-changes-status'); + assert.match(status, /await gitChangesTarget.listSubagentWorktrees\(sessionId/, 'the status handler lists the parent subagent worktrees'); + assert.match(status, /collectSubagentChanges\(/, 'and attaches their changes to the result'); +}); + test('git-changes-locate is the one handler that takes an absolute path, and it maps it main-side', () => { const main = mainSource(); const body = handlerBody(main, 'git-changes-locate'); diff --git a/test/git-changes-runner.test.js b/test/git-changes-runner.test.js index 5f7c537d..bde69151 100644 --- a/test/git-changes-runner.test.js +++ b/test/git-changes-runner.test.js @@ -242,6 +242,22 @@ test('local runner .status(): three commands, no -C flag (cwd passed via execFil } }); +test('a hardened local runner turns core.fsmonitor off on every git call, status and diff alike; a plain one does not (mutation target: dropping the flag)', async () => { + for (const hardened of [true, false]) { + const { exec, calls } = localFakeExec({}); + const runner = createGitChangesRunner({ kind: 'local', cwd: REPO, exec, hardened }); + await runner.status(); + await runner.diff('foo.js', { staged: false, untracked: false }); + await runner.diff('foo.js', { staged: true, untracked: false }); + assert.ok(calls.length >= 5); + for (const args of calls) { + const off = args.some((a, i) => a === '-c' && args[i + 1] === 'core.fsmonitor=false'); + assert.equal(off, hardened, JSON.stringify(args)); + assert.equal(args[0], '--literal-pathspecs'); + } + } +}); + test('local runner .status(): status runs with -uall so a wholly-untracked directory is listed file by file, never as one directory row (mutation target: dropping -uall)', async () => { const { exec, calls } = localFakeExec({}); const runner = createGitChangesRunner({ kind: 'local', cwd: REPO, exec }); diff --git a/test/git-changes-target.test.js b/test/git-changes-target.test.js index 63356a41..a78b6324 100644 --- a/test/git-changes-target.test.js +++ b/test/git-changes-target.test.js @@ -7,7 +7,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); -const { resolveGitChangesTarget, isValidChangesSessionId } = require('../git-changes-target'); +const { resolveGitChangesTarget, isValidChangesSessionId, listSubagentWorktrees, collectSubagentChanges, checkSubagentRepo } = require('../git-changes-target'); function baseDeps(overrides = {}) { return { @@ -159,3 +159,446 @@ test('a getCachedFolder throw is swallowed, resolution still proceeds as local', const result = resolveGitChangesTarget('s1', deps); assert.deepEqual(result, { ok: true, kind: 'local', cwd: '/home/dev/proj' }); }); + +// --- a subagent's worktree (issue #303) ------------------------------------ + +const path = require('node:path'); + +const SUB_ID = 'sub:parent-1:a219d84ea899'; +const WORKTREE = path.resolve('/repo/.claude/worktrees/agent-a219d84ea899'); +const SUB_OPTS = { allowSubagent: true }; + +function subDeps(meta, overrides = {}) { + const metaReads = []; + const deps = baseDeps({ + getCachedFolder: (id) => (id === SUB_ID || id === 'parent-1' ? '-repo' : null), + existsSync: (p) => p === WORKTREE || p === path.resolve('/repo'), + resolveSessionRealCwd: (_dir, id) => (id === 'parent-1' ? path.resolve('/repo') : null), + readSubagentMeta: (jsonlPath) => { metaReads.push(jsonlPath); return meta; }, + ...overrides, + }); + deps.metaReads = metaReads; + return deps; +} + +test('isValidChangesSessionId: a sub: id stays refused unless the caller opts in, and then only with well-formed parts', () => { + assert.equal(isValidChangesSessionId(SUB_ID), false); + assert.equal(isValidChangesSessionId(SUB_ID, SUB_OPTS), true); + for (const bad of [ + 'sub:', 'sub:a', 'sub:a:', 'sub::b', 'sub:a:b:c', 'sub:../x:b', 'sub:a:../x', 'sub:a:..', 'sub:..:b', + 'sub:a/b:c', 'sub:a:b/c', 'sub:a:b\\c', 'sub:a b:c', 'sub:a:b\0', + ]) { + assert.equal(isValidChangesSessionId(bad, SUB_OPTS), false, JSON.stringify(bad)); + } +}); + +test('resolveGitChangesTarget: a sub: id is refused without opt-in, before any dependency runs', () => { + const deps = subDeps({ worktreePath: WORKTREE }, { getCachedFolder: () => { throw new Error('touched'); } }); + const result = resolveGitChangesTarget(SUB_ID, deps); + assert.equal(result.ok, false); + assert.deepEqual(deps.metaReads, []); +}); + +test('resolveGitChangesTarget: a malformed sub: id is refused before any disk access (mutation target: validating after the meta read)', () => { + let touched = false; + const deps = subDeps({ worktreePath: WORKTREE }, { + getCachedFolder: () => { touched = true; return '-repo'; }, + existsSync: () => { touched = true; return true; }, + resolveSessionRealCwd: () => { touched = true; return null; }, + readSubagentMeta: () => { touched = true; return null; }, + }); + for (const bad of ['sub:../../x:b', 'sub:a:../../x', 'sub:a:b:c', 'sub:a/b:c']) { + const result = resolveGitChangesTarget(bad, deps, SUB_OPTS); + assert.equal(result.ok, false, bad); + } + assert.equal(touched, false); +}); + +test('resolveGitChangesTarget: a subagent in a worktree resolves to the worktree path recorded in its sidecar', () => { + const deps = subDeps({ worktreePath: WORKTREE, worktreeBranch: 'worktree-agent-a219d84ea899' }); + const result = resolveGitChangesTarget(SUB_ID, deps, SUB_OPTS); + assert.deepEqual(result, { ok: true, kind: 'local', cwd: WORKTREE, subagent: true }); + assert.deepEqual(deps.metaReads, [ + path.join('/projects', '-repo', 'parent-1', 'subagents', 'agent-a219d84ea899.jsonl'), + ]); +}); + +test('resolveGitChangesTarget: a subagent with no worktreePath shares the parent target, with no second row source', () => { + const deps = subDeps({ agentType: 'general-purpose' }); + const result = resolveGitChangesTarget(SUB_ID, deps, SUB_OPTS); + assert.deepEqual(result, { ok: true, kind: 'local', cwd: path.resolve('/repo'), subagent: true }); +}); + +test('resolveGitChangesTarget: a sidecar that is missing or unparsable is a distinct refusal, not the parent rows (mutation target: sharing the parent target)', () => { + for (const meta of [null, undefined, 'text', 7]) { + const result = resolveGitChangesTarget(SUB_ID, subDeps(meta), SUB_OPTS); + assert.equal(result.ok, false, JSON.stringify(meta)); + assert.equal(result.reason, 'no-worktree-recorded'); + } +}); + +test('resolveGitChangesTarget: a removed worktree is reported as such, not thrown and not the parent directory (mutation target: falling back to the parent)', () => { + const deps = subDeps({ worktreePath: WORKTREE }, { existsSync: (p) => p === path.resolve('/repo') }); + const result = resolveGitChangesTarget(SUB_ID, deps, SUB_OPTS); + assert.equal(result.ok, false); + assert.equal(result.reason, 'worktree-removed'); + assert.match(result.error, /no longer exists/); +}); + +test('resolveGitChangesTarget: a sidecar worktreePath that is relative, traversing or carries a control character is refused without a stat', () => { + for (const worktreePath of ['rel/worktree', path.resolve('/repo') + '/../etc', WORKTREE + '\nx', 42, '']) { + let stat = false; + const deps = subDeps({ worktreePath }, { existsSync: () => { stat = true; return true; } }); + const result = resolveGitChangesTarget(SUB_ID, deps, SUB_OPTS); + assert.equal(result.ok, false, JSON.stringify(worktreePath)); + assert.equal(stat, false, JSON.stringify(worktreePath)); + } +}); + +test('resolveGitChangesTarget: a subagent of a remote folder is refused', () => { + const deps = subDeps({ worktreePath: WORKTREE }, { isRemoteFolder: () => true }); + const result = resolveGitChangesTarget(SUB_ID, deps, SUB_OPTS); + assert.equal(result.ok, false); + assert.deepEqual(deps.metaReads, []); +}); + +test('resolveGitChangesTarget: a subagent absent from the cache is refused', () => { + const deps = subDeps({ worktreePath: WORKTREE }, { getCachedFolder: () => null }); + const result = resolveGitChangesTarget(SUB_ID, deps, SUB_OPTS); + assert.equal(result.ok, false); + assert.deepEqual(deps.metaReads, []); +}); + +test('resolveGitChangesTarget: a UNC worktreePath is refused without a stat (mutation target: dropping the UNC check)', () => { + for (const worktreePath of ['\\\\host\\share\\wt', '\\\\?\\UNC\\host\\share\\wt', '//host/share/wt']) { + let stat = false; + const deps = subDeps({ worktreePath }, { existsSync: () => { stat = true; return true; } }); + const result = resolveGitChangesTarget(SUB_ID, deps, SUB_OPTS); + assert.equal(result.ok, false, worktreePath); + assert.equal(stat, false, worktreePath); + } +}); + +// --- listing a parent's subagent worktrees --------------------------------- + +const BS = String.fromCharCode(92); +const WT2 = path.resolve('/repo/.claude/worktrees/agent-bbbb'); +const COMMON = path.resolve('/repo/.git'); + +function dotGitFor(wt, common = COMMON) { + return { file: true, content: 'gitdir: ' + path.join(common, 'worktrees', path.basename(wt)) + '\n' }; +} + +function listDeps(rows, metas, overrides = {}) { + const reads = []; + const clock = { t: 1_000_000 }; + const base = subDeps(null, { + getCachedFolder: (id) => (id === 'parent-1' || id.startsWith('sub:parent-1:') ? '-repo' : null), + listSubagents: () => rows, + }); + const deps = { + ...base, + cache: new Map(), + now: () => clock.t, + exists: async (p) => [WORKTREE, WT2, path.resolve('/repo')].includes(p), + readSubagentMetaAsync: async (jsonlPath) => { + reads.push(jsonlPath); + return metas[path.basename(jsonlPath, '.jsonl').slice('agent-'.length)] ?? null; + }, + gitCommonDir: async () => COMMON, + readDotGit: async (wt) => dotGitFor(wt), + ...overrides, + }; + deps.reads = reads; + deps.clock = clock; + return deps; +} + +test('listSubagentWorktrees: lists only subagents whose worktree differs from the parent, labelled description then type then id, newest first', async () => { + const rows = [ + { sessionId: 'sub:parent-1:aaaa', agentId: 'aaaa', description: 'fix the thing', subagentType: 'general-purpose', modified: '2026-10-01T10:00:00Z' }, + { sessionId: 'sub:parent-1:bbbb', agentId: 'bbbb', description: null, subagentType: 'Explore', modified: '2026-10-01T12:00:00Z' }, + { sessionId: 'sub:parent-1:cccc', agentId: 'cccc', description: null, subagentType: null, modified: '2026-10-01T09:00:00Z' }, + { sessionId: 'sub:parent-1:dddd', agentId: 'dddd', description: 'shares', subagentType: 'x' }, + { sessionId: 'sub:parent-1:eeee', agentId: 'eeee', description: 'same as parent', subagentType: 'x' }, + { sessionId: 'sub:parent-1:ffff', agentId: 'ffff', description: 'sidecar gone', subagentType: 'x' }, + { sessionId: 'sub:parent-1:gggg', agentId: 'gggg', description: 'removed', subagentType: 'x' }, + ]; + const metas = { + aaaa: { worktreePath: WORKTREE }, + bbbb: { worktreePath: WT2 }, + cccc: { worktreePath: WORKTREE }, + dddd: { agentType: 'x' }, + eeee: { worktreePath: path.resolve('/repo') }, + gggg: { worktreePath: path.resolve('/repo/.claude/worktrees/gone') }, + }; + const out = await listSubagentWorktrees('parent-1', listDeps(rows, metas)); + assert.deepEqual(out.worktrees.map((o) => [o.agentId, o.label, o.cwd]), [ + ['bbbb', 'Explore', WT2], + ['aaaa', 'fix the thing', WORKTREE], + ['cccc', 'cccc', WORKTREE], + ]); + assert.equal(out.worktrees[1].sessionId, 'sub:parent-1:aaaa'); + assert.equal(out.notScanned, 0); +}); + +test('listSubagentWorktrees: "newest" is by parsed time, a missing or unparsable time sorts last (mutation target: string comparison)', async () => { + const rows = [ + { sessionId: 'sub:parent-1:aaaa', agentId: 'aaaa', modified: '2026-10-01T10:00:00+02:00' }, + { sessionId: 'sub:parent-1:bbbb', agentId: 'bbbb', modified: '2026-10-01T09:00:00Z' }, + { sessionId: 'sub:parent-1:cccc', agentId: 'cccc', modified: 'garbage' }, + { sessionId: 'sub:parent-1:dddd', agentId: 'dddd', modified: null }, + ]; + const metas = {}; + for (const r of rows) metas[r.agentId] = { worktreePath: WORKTREE }; + const out = await listSubagentWorktrees('parent-1', listDeps(rows, metas)); + assert.deepEqual(out.worktrees.map((o) => o.agentId).slice(0, 2), ['bbbb', 'aaaa']); + assert.deepEqual(out.worktrees.map((o) => o.agentId).slice(2).sort(), ['cccc', 'dddd']); +}); + +test('listSubagentWorktrees: a parent that is not a local session, or an invalid id, lists nothing and reads nothing', async () => { + const rows = [{ sessionId: 'sub:parent-1:aaaa', agentId: 'aaaa' }]; + const metas = { aaaa: { worktreePath: WORKTREE } }; + for (const [id, over] of [['../x', {}], ['sub:parent-1:aaaa', {}], ['parent-1', { isRemoteFolder: () => true }]]) { + const deps = listDeps(rows, metas, over); + assert.deepEqual(await listSubagentWorktrees(id, deps), { worktrees: [], notScanned: 0 }); + assert.deepEqual(deps.reads, []); + } +}); + +function manyRows(n) { + const rows = []; const metas = {}; + for (let i = 0; i < n; i++) { + const id = 'a' + String(i).padStart(2, '0'); + rows.push({ sessionId: 'sub:parent-1:' + id, agentId: id, modified: '2026-10-01T10:' + String(i).padStart(2, '0') + ':00Z' }); + metas[id] = { worktreePath: WORKTREE }; + } + return { rows, metas }; +} + +test('listSubagentWorktrees: at most 24 worktrees are scanned, accepted or not, and the rest are counted (mutation target: counting accepted instead of scanned)', async () => { + const { rows, metas } = manyRows(60); + const all = await listSubagentWorktrees('parent-1', listDeps(rows, metas)); + assert.equal(all.worktrees.length, 24); + assert.equal(all.worktrees[0].agentId, 'a59'); + assert.equal(all.notScanned, 36); + + let dotGitReads = 0; + const rejected = await listSubagentWorktrees('parent-1', listDeps(rows, metas, { readDotGit: async () => { dotGitReads++; return null; } })); + assert.deepEqual(rejected.worktrees, []); + assert.equal(rejected.notScanned, 36); + assert.equal(dotGitReads, 24, 'rejected candidates still count against the scan bound'); +}); + +test('listSubagentWorktrees: sidecar and removal answers expire, so a recreated worktree and a late sidecar are seen (mutation target: permanent cache)', async () => { + const rows = [ + { sessionId: 'sub:parent-1:aaaa', agentId: 'aaaa' }, + { sessionId: 'sub:parent-1:gone', agentId: 'gone' }, + { sessionId: 'sub:parent-1:late', agentId: 'late' }, + { sessionId: 'sub:parent-1:null', agentId: 'null' }, + ]; + const gonePath = path.resolve('/repo/.claude/worktrees/gone'); + const metas = { aaaa: { worktreePath: WORKTREE }, gone: { worktreePath: gonePath }, null: { agentType: 'x' } }; + const existing = new Set([WORKTREE, WT2, path.resolve('/repo')]); + const existsCalls = []; + const deps = listDeps(rows, metas, { exists: async (p) => { existsCalls.push(p); return existing.has(p); } }); + + assert.deepEqual((await listSubagentWorktrees('parent-1', deps)).worktrees.map((o) => o.agentId), ['aaaa']); + const readsAfterFirst = deps.reads.length; + assert.equal(readsAfterFirst, 4); + + assert.deepEqual((await listSubagentWorktrees('parent-1', deps)).worktrees.map((o) => o.agentId), ['aaaa']); + assert.equal(deps.reads.length, readsAfterFirst, 'an unreadable sidecar is not hammered within its short delay'); + + deps.clock.t += 11_000; + await listSubagentWorktrees('parent-1', deps); + assert.equal(deps.reads.length, readsAfterFirst + 1, 'an unreadable sidecar is retried after a few seconds'); + assert.equal(existsCalls.filter((p) => p === gonePath).length, 1, 'a removed worktree is not stat-ed again within the window'); + + metas.late = { worktreePath: WT2 }; + existing.add(gonePath); + metas.null = { worktreePath: WT2 }; + deps.clock.t += 31_000; + const later = await listSubagentWorktrees('parent-1', deps); + assert.ok(later.worktrees.some((o) => o.agentId === 'gone'), 'a worktree recreated at the same path is listed again'); + assert.ok(later.worktrees.some((o) => o.agentId === 'late'), 'a sidecar that appeared is read'); + assert.ok(later.worktrees.some((o) => o.agentId === 'null'), 'a sidecar once read as sharing the parent directory is read again after the window'); +}); + +test('listSubagentWorktrees: sidecar reads run a few at a time, not all at once (mutation target: unbounded fan-out)', async () => { + const rows = []; const metas = {}; + for (let i = 0; i < 50; i++) { rows.push({ sessionId: 'sub:parent-1:a' + i, agentId: 'a' + i }); metas['a' + i] = { agentType: 'x' }; } + let inFlight = 0; let peak = 0; + const deps = listDeps(rows, metas, { + readSubagentMetaAsync: async () => { + inFlight++; peak = Math.max(peak, inFlight); + await new Promise((r) => setImmediate(r)); + inFlight--; + return { agentType: 'x' }; + }, + }); + await listSubagentWorktrees('parent-1', deps); + assert.ok(peak > 1 && peak <= 8, 'peak ' + peak); +}); + +test('listSubagentWorktrees: a worktree is kept only if its .git file points under the parent repository worktrees directory (mutation target: each clause)', async () => { + const rows = [{ sessionId: 'sub:parent-1:aaaa', agentId: 'aaaa' }]; + const metas = { aaaa: { worktreePath: WORKTREE } }; + const under = path.join(COMMON, 'worktrees', 'x'); + const cases = [ + ['file under the worktrees dir', { file: true, content: 'gitdir: ' + under + '\n' }, true], + ['forward-slash spelling', { file: true, content: 'gitdir: ' + under.split(BS).join('/') + '\n' }, true], + ['relative to the worktree', { file: true, content: 'gitdir: ' + path.relative(WORKTREE, under) + '\n' }, true], + ['a directory, not a file', { file: false, content: '' }, false], + ['no .git', null, false], + ['no gitdir prefix', { file: true, content: under + '\n' }, false], + ['a different seven-character prefix', { file: true, content: 'GITDIR: ' + under + '\n' }, false], + ['another repository', { file: true, content: 'gitdir: ' + path.join(path.resolve('/elsewhere/.git'), 'worktrees', 'x') + '\n' }, false], + ['the repository git dir itself', { file: true, content: 'gitdir: ' + COMMON + '\n' }, false], + ['the worktrees dir itself', { file: true, content: 'gitdir: ' + path.join(COMMON, 'worktrees') + '\n' }, false], + ['a sibling sharing the prefix', { file: true, content: 'gitdir: ' + path.join(COMMON, 'worktrees-evil', 'x') + '\n' }, false], + ['a traversal out of it', { file: true, content: 'gitdir: ' + path.join(COMMON, 'worktrees', '..', '..', 'other') + '\n' }, false], + ['empty target', { file: true, content: 'gitdir:\n' }, false], + ]; + for (const [name, dotGit, kept] of cases) { + const out = await listSubagentWorktrees('parent-1', listDeps(rows, metas, { readDotGit: async () => dotGit })); + assert.equal(out.worktrees.length, kept ? 1 : 0, name); + } +}); + +test('listSubagentWorktrees: the .git file is read on every pass, so a worktree recreated as another repository is dropped at once (mutation target: caching the verdict)', async () => { + const rows = [{ sessionId: 'sub:parent-1:aaaa', agentId: 'aaaa' }]; + const metas = { aaaa: { worktreePath: WORKTREE } }; + let dotGit = dotGitFor(WORKTREE); + const deps = listDeps(rows, metas, { readDotGit: async () => dotGit }); + assert.equal((await listSubagentWorktrees('parent-1', deps)).worktrees.length, 1); + dotGit = dotGitFor(WORKTREE, path.resolve('/elsewhere/.git')); + assert.equal((await listSubagentWorktrees('parent-1', deps)).worktrees.length, 0); +}); + +test('listSubagentWorktrees: the parent repository is asked for once per window, and an unanswerable one lists nothing (mutation target: no expiry)', async () => { + const rows = [{ sessionId: 'sub:parent-1:aaaa', agentId: 'aaaa' }]; + const metas = { aaaa: { worktreePath: WORKTREE } }; + const asked = []; + const deps = listDeps(rows, metas, { gitCommonDir: async (cwd) => { asked.push(cwd); return COMMON; } }); + await listSubagentWorktrees('parent-1', deps); + await listSubagentWorktrees('parent-1', deps); + assert.equal(asked.length, 1); + deps.clock.t += 31_000; + await listSubagentWorktrees('parent-1', deps); + assert.equal(asked.length, 2, 'the answer expires'); + const none = await listSubagentWorktrees('parent-1', listDeps(rows, metas, { gitCommonDir: async () => null })); + assert.deepEqual(none.worktrees, []); +}); + +test('listSubagentWorktrees: the parent comparison ignores case where the platform does (mutation target: string equality)', async () => { + const rows = [{ sessionId: 'sub:parent-1:aaaa', agentId: 'aaaa' }]; + const parentCwd = 'C:' + BS + 'Repo'; + const wt = 'c:' + BS + 'repo'; + const deps = listDeps(rows, { aaaa: { worktreePath: wt } }, { + pathOps: path.win32, + resolveSessionRealCwd: () => parentCwd, + existsSync: () => true, + exists: async () => true, + }); + assert.deepEqual((await listSubagentWorktrees('parent-1', deps)).worktrees, []); +}); + +test('resolveGitChangesTarget: an extended-length drive path is normalised, any UNC or host path is refused (mutation target: the UNC pattern)', () => { + const accepted = [ + [BS + BS + '?' + BS + 'C:' + BS + 'wt', 'C:' + BS + 'wt'], + [BS + BS + '.' + BS + 'C:' + BS + 'wt', 'C:' + BS + 'wt'], + ]; + for (const [recorded, normalised] of accepted) { + const seen = []; + const deps = subDeps({ worktreePath: recorded }, { existsSync: (p) => { seen.push(p); return true; } }); + const result = resolveGitChangesTarget(SUB_ID, deps, SUB_OPTS); + assert.equal(result.ok, true, recorded); + assert.equal(result.cwd, normalised); + assert.deepEqual(seen, [normalised]); + } + const refused = [ + BS + BS + '?' + BS + 'UNC' + BS + 'h' + BS + 's' + BS + 'wt', + BS + BS + 'h' + BS + 's' + BS + 'wt', + '//h/s/wt', + BS + BS + '?' + BS + 'Volume{1}' + BS + 'wt', + BS + BS + '?' + BS + 'C:', + ]; + for (const recorded of refused) { + const result = resolveGitChangesTarget(SUB_ID, subDeps({ worktreePath: recorded }, { existsSync: () => true }), SUB_OPTS); + assert.equal(result.ok, false, recorded); + } +}); + +// --- the groups the parent's panel shows ----------------------------------- + +function group(i) { + return { sessionId: 'sub:p:a' + i, agentId: 'a' + i, label: 'A' + i, cwd: '/w' + i }; +} +const DIRTY = { ok: true, branch: { head: 'b' }, files: [{ path: 'x.js', state: 'M' }], totals: { files: 1, added: 1, deleted: 0, uncounted: 0 } }; +const CLEAN = { ok: true, branch: { head: 'b' }, files: [], totals: { files: 0 } }; + +test('collectSubagentChanges: keeps the groups git reports files for, drops clean, failed and throwing ones', async () => { + const groups = [group(1), group(2), group(3), group(4)]; + const status = { '/w1': DIRTY, '/w2': CLEAN, '/w3': { ok: false, error: 'boom' } }; + const { subagents, omitted } = await collectSubagentChanges(groups, (cwd) => ({ + status: async () => { if (cwd === '/w4') throw new Error('x'); return status[cwd]; }, + })); + assert.deepEqual(subagents, [{ + sessionId: 'sub:p:a1', agentId: 'a1', label: 'A1', branch: { head: 'b' }, files: DIRTY.files, totals: DIRTY.totals, + }]); + assert.equal(omitted, 0); +}); + +test('collectSubagentChanges: the cap counts groups with changes, so clean worktrees take no slot, and the rest are counted (mutation target: capping before git status)', async () => { + const groups = []; for (let i = 0; i < 20; i++) groups.push(group(i)); + const { subagents, omitted } = await collectSubagentChanges(groups, (cwd) => ({ + status: async () => (Number(cwd.slice(2)) < 6 ? CLEAN : DIRTY), + })); + assert.equal(subagents.length, 8); + assert.deepEqual(subagents.map((g) => g.agentId), ['a6', 'a7', 'a8', 'a9', 'a10', 'a11', 'a12', 'a13']); + assert.equal(omitted, 6); +}); + +test('collectSubagentChanges: at most a few git status run at once (mutation target: unbounded parallelism)', async () => { + const groups = []; for (let i = 0; i < 12; i++) groups.push(group(i)); + let inFlight = 0; let peak = 0; + await collectSubagentChanges(groups, () => ({ + status: async () => { + inFlight++; peak = Math.max(peak, inFlight); + await new Promise((r) => setImmediate(r)); + inFlight--; + return DIRTY; + }, + })); + assert.ok(peak > 1 && peak <= 3, 'peak ' + peak); +}); + +// --- a subagent target must belong to the parent's repository -------------- + +test('checkSubagentRepo: a worktree of the parent repository passes, another repository or an unanswerable one is refused (mutation target: skipping the check)', async () => { + const deps = (over) => listDeps([], {}, over); + const target = { ok: true, kind: 'local', cwd: WORKTREE, subagent: true }; + assert.deepEqual(await checkSubagentRepo(SUB_ID, target, deps({})), { ok: true }); + const other = await checkSubagentRepo(SUB_ID, target, deps({ readDotGit: async () => dotGitFor(WORKTREE, path.resolve('/x/.git')) })); + assert.equal(other.ok, false); + assert.equal(other.reason, 'other-repo'); + assert.equal((await checkSubagentRepo(SUB_ID, target, deps({ readDotGit: async () => null }))).ok, false); + assert.equal((await checkSubagentRepo(SUB_ID, target, deps({ readDotGit: async () => ({ file: false, content: '' }) }))).ok, false); + assert.equal((await checkSubagentRepo(SUB_ID, target, deps({ gitCommonDir: async () => null }))).ok, false); +}); + +test('checkSubagentRepo: a target that is not a distinct subagent worktree needs no git call or file read', async () => { + let asked = false; + const deps = listDeps([], {}, { + gitCommonDir: async () => { asked = true; return null; }, + readDotGit: async () => { asked = true; return null; }, + }); + const own = { ok: true, kind: 'local', cwd: '/anything' }; + assert.deepEqual(await checkSubagentRepo('s1', own, deps), { ok: true }); + const shared = { ok: true, kind: 'local', cwd: path.resolve('/repo'), subagent: true }; + assert.deepEqual(await checkSubagentRepo(SUB_ID, shared, deps), { ok: true }); + assert.equal(asked, false); + const refused = { ok: false, error: 'x' }; + assert.equal(await checkSubagentRepo(SUB_ID, refused, deps), refused); +});