Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .ai/contexts/changes-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<positive integer>` (`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:<parent>:<agent>` 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:<positive integer>` (`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:<parent>:<agent>` 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:<parent>:<agent>`, issue #303).** The target is read from the sidecar `<folder>/<parent>/subagents/agent-<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 `<parent common dir>/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).
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions docs/changes-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions git-changes-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"');
}
Expand All @@ -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 = {};
Expand Down
Loading
Loading