From b3e0f11503c81d8c336d87d4395a77c6e5dbf1e4 Mon Sep 17 00:00:00 2001 From: Arach Tchoupani Date: Sat, 6 Jun 2026 17:50:45 -0400 Subject: [PATCH] runtime: Repo Watch snapshot backend + broker route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the broker endpoint the native Repos section polls. Lifts the self-contained repo-watch module forward (the front end shipped without it, so the section had no data source): - packages/runtime/src/repo-watch — computes a worktree-state snapshot by shelling out to git per worktree (branch, churn, last commit, diff, agent/session attribution) - GET /v1/repo-watch/snapshot route on the broker daemon (force / includeTail / includeDiff / includeLastCommit), seeded with hints from the broker snapshot + tail discovery - ./repo-watch package export + index re-export No new dependencies. Runtime typecheck clean; repo-watch tests pass (6/6); verified live (finds the openscout project + its 3 worktrees). Note: lastTouchedAt isn't emitted yet — the native column stays empty until a follow-up computes it. --- docs/eng/sco-061-repo-watch-worktree-state.md | 179 +++ packages/runtime/package.json | 5 + packages/runtime/src/broker-daemon.ts | 28 + packages/runtime/src/index.ts | 1 + packages/runtime/src/repo-watch.test.ts | 288 +++++ packages/runtime/src/repo-watch/index.ts | 1091 +++++++++++++++++ 6 files changed, 1592 insertions(+) create mode 100644 docs/eng/sco-061-repo-watch-worktree-state.md create mode 100644 packages/runtime/src/repo-watch.test.ts create mode 100644 packages/runtime/src/repo-watch/index.ts diff --git a/docs/eng/sco-061-repo-watch-worktree-state.md b/docs/eng/sco-061-repo-watch-worktree-state.md new file mode 100644 index 00000000..019ac7bd --- /dev/null +++ b/docs/eng/sco-061-repo-watch-worktree-state.md @@ -0,0 +1,179 @@ +# SCO-061: Repo Watch Worktree State + +## 1. Status + +- **Status:** Draft +- **Owner:** OpenScout +- **Scope:** Local repository/worktree awareness for the native Mac app +- **Intent:** Give Scout a compact machine-local view of active Git work so the operator can understand what branches, worktrees, and diffs are alive. + +## 2. Summary + +Scout already has surfaces for communication, agents, and transcript activity. The missing local-operator view is repository state: which projects are active, which branches are moving, which worktrees are dirty, and which agents or sessions appear attached to that work. + +Repo Watch is the backend primitive for that view. It is a peer to Tail in posture: Tail observes harness transcripts; Repo Watch observes Git worktrees. It should be safe to poll, cheap enough for local use, and shaped for a native UI that groups state by project. + +This is not a repository management system. The first version should discover useful roots from existing Scout context and present state. Pinning, hiding, renaming, and deeper project curation can remain later agentic workflows or lightweight settings. + +## 3. Product Shape + +The Mac app should be able to render a **Repos** or **Worktrees** screen from one snapshot: + +- project groups such as `openscout` and `hudson` +- worktree rows with branch, path, upstream, ahead/behind, dirty state, changed-file preview, and attention reasons +- optional diff shortstats for staged and unstaged work when the client asks for enrichment +- agent/session presence inferred from Scout agents, endpoints, and Tail discovery +- quick actions such as open editor, open terminal, observe session, or message attached agent + +The screen answers: "What physical work is changing on this machine?" + +## 4. Discovery + +The initial backend discovers candidate paths from: + +1. Scout broker agents and endpoint `projectRoot` / `cwd` +2. Optional Tail-discovered process `cwd` when `includeTail=1` +3. Optional Tail-discovered transcript `cwd` when `includeTail=1` +4. Optional environment roots such as `OPENSCOUT_REPO_WATCH_ROOTS` + +Broker-derived paths are filtered for local operator usefulness. The fast path skips broad roots such as the home directory and common dev parent directory, skips temporary package directories, prioritizes active endpoint paths, and lets explicit environment roots override discovery. Defaults are intentionally bounded and biased toward breadth across projects before depth inside one large worktree set: `OPENSCOUT_REPO_WATCH_MAX_ROOTS`, `OPENSCOUT_REPO_WATCH_MAX_WORKTREES`, `OPENSCOUT_REPO_WATCH_MAX_FILES_PER_WORKTREE`, and `OPENSCOUT_REPO_WATCH_SCAN_BUDGET_MS` can tune the cap. + +Each candidate path is normalized through Git: + +```bash +git -C rev-parse --show-toplevel +git -C rev-parse --git-common-dir +git -C worktree list --porcelain +git -C status --porcelain=v2 --branch -unormal +``` + +Repo Watch should not scan arbitrary home directories by default. + +The default endpoint is a fast path. It skips optional diff and commit enrichment unless the client requests it: + +```bash +git -C diff --shortstat +git -C diff --cached --shortstat +git -C log -1 --format=%ct +``` + +## 5. Endpoint Contract + +First endpoint: + +```http +GET /v1/repo-watch/snapshot +GET /v1/repo-watch/snapshot?force=1 +GET /v1/repo-watch/snapshot?includeTail=1 +GET /v1/repo-watch/snapshot?includeDiff=1 +GET /v1/repo-watch/snapshot?includeLastCommit=1 +``` + +Response: + +```ts +type RepoWatchSnapshot = { + generatedAt: number; + projects: RepoWatchProject[]; + totals: { + projects: number; + worktrees: number; + dirtyWorktrees: number; + conflictedWorktrees: number; + attentionWorktrees: number; + attachedAgents: number; + attachedSessions: number; + }; + warnings: string[]; +}; + +type RepoWatchProject = { + id: string; + name: string; + root: string; + commonGitDir: string; + attention: RepoWatchAttentionLevel; + attentionReasons: string[]; + worktrees: RepoWatchWorktree[]; + stats: RepoWatchProjectStats; + hints: RepoWatchHintSummary[]; +}; + +type RepoWatchWorktree = { + id: string; + path: string; + name: string; + isBare: boolean; + branch: { + name: string | null; + upstream: string | null; + head: string | null; + detached: boolean; + ahead: number; + behind: number; + isMain: boolean; + diverged: boolean; + }; + status: { + clean: boolean; + staged: number; + unstaged: number; + untracked: number; + conflicts: number; + changedFiles: number; + files: { path: string; status: string }[]; + }; + diff: { + unstagedShortstat: string | null; + stagedShortstat: string | null; + }; + attention: RepoWatchAttentionLevel; + attentionReasons: string[]; + agents: RepoWatchAgentRef[]; + sessions: RepoWatchSessionRef[]; + lastCommitAt: number | null; // null unless includeLastCommit=1 + scannedAt: number; + error: string | null; +}; + +type RepoWatchAttentionLevel = "critical" | "attention" | "active" | "quiet" | "unknown"; +``` + +## 6. Attention Rules + +The first attention model is intentionally mechanical: + +- `critical`: merge conflicts or unmerged status +- `attention`: dirty `main` / `master`, diverged branch, or missing status because Git errored +- `active`: dirty worktree, ahead branch, behind branch, or attached live agent/session +- `quiet`: clean and no attached live hints +- `unknown`: repository was discovered but could not be scanned enough to classify + +The UI can sort by this rank without inventing product semantics. + +## 7. Native UI Assumptions + +The frontend can assume: + +- the snapshot is complete enough to render without follow-up calls +- paths are absolute local filesystem paths +- project/worktree ids are stable for the same local repository paths +- `files` is a small preview list, not a full diff browser +- `diff.*Shortstat` and `lastCommitAt` are nullable fast-path fields +- the backend may add fields without breaking existing clients +- deeper diffs, watch subscriptions, and repo actions are later additions + +## 8. Non-Goals + +- No automatic commits, merges, rebases, branch creation, or destructive cleanup. +- No repo registry UI in this slice. +- No claim that Scout owns the repository state. Git remains the source of truth. +- No import of harness transcripts into Scout-owned messages. +- No global filesystem crawler. + +## 9. Open Decisions + +- Should Repo Watch eventually emit live events, or is a fast snapshot enough for the Mac app? +- Should branch protection labels be configurable beyond `main` and `master`? +- Which file preview limit gives useful signal without turning the response into a diff payload? +- Should hidden worktrees be configured through settings, a repo-local file, or an agentic command? diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7cdb3c02..f01d7b56 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -13,6 +13,11 @@ "bun": "./src/index.ts", "default": "./dist/index.js" }, + "./repo-watch": { + "types": "./src/repo-watch/index.ts", + "bun": "./src/repo-watch/index.ts", + "default": "./dist/repo-watch/index.js" + }, "./setup": { "types": "./src/setup.ts", "bun": "./src/setup.ts", diff --git a/packages/runtime/src/broker-daemon.ts b/packages/runtime/src/broker-daemon.ts index 8180784c..85bbea9e 100644 --- a/packages/runtime/src/broker-daemon.ts +++ b/packages/runtime/src/broker-daemon.ts @@ -184,6 +184,11 @@ import { readRecentTranscriptEvents, type TailEvent, } from "./tail/index.js"; +import { + getRepoWatchSnapshot, + repoWatchHintsFromBrokerSnapshot, + repoWatchHintsFromTailDiscovery, +} from "./repo-watch/index.js"; import { isBrokerRunnableLocalAgentTransport, isDirectLocalAgentTransport, @@ -5581,6 +5586,29 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): return; } + if (method === "GET" && url.pathname === "/v1/repo-watch/snapshot") { + const force = url.searchParams.get("force") === "1" || url.searchParams.get("force") === "true"; + const includeTail = url.searchParams.get("includeTail") === "1" || url.searchParams.get("includeTail") === "true"; + const includeDiff = url.searchParams.get("includeDiff") === "1" || url.searchParams.get("includeDiff") === "true"; + const includeLastCommit = url.searchParams.get("includeLastCommit") === "1" + || url.searchParams.get("includeLastCommit") === "true"; + const snapshot = await brokerService.readSnapshot(); + const tailHints = includeTail + ? repoWatchHintsFromTailDiscovery(await getTailDiscovery(false)) + : []; + const repoSnapshot = await getRepoWatchSnapshot({ + force, + includeDiff, + includeLastCommit, + hints: [ + ...repoWatchHintsFromBrokerSnapshot(snapshot), + ...tailHints, + ], + }); + json(response, 200, repoSnapshot); + return; + } + if (method === "GET" && url.pathname === "/v1/tail/recent") { json(response, 200, await readTailRecentPayload(url)); return; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 119c94e3..ba7b1275 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -38,3 +38,4 @@ export * from "./session-attention.js"; export * from "./vantage-plan.js"; export * from "./conversations/index.js"; export * from "./knowledge/index.js"; +export * from "./repo-watch/index.js"; diff --git a/packages/runtime/src/repo-watch.test.ts b/packages/runtime/src/repo-watch.test.ts new file mode 100644 index 00000000..6c2da06a --- /dev/null +++ b/packages/runtime/src/repo-watch.test.ts @@ -0,0 +1,288 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + getRepoWatchSnapshot, + parseGitStatusPorcelainV2, + parseGitWorktreeList, + repoWatchHintsFromBrokerSnapshot, + repoWatchHintsFromTailDiscovery, +} from "./repo-watch/index.ts"; + +let tempRoot = ""; +const originalRepoWatchRoots = process.env.OPENSCOUT_REPO_WATCH_ROOTS; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} + +beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "openscout-repo-watch-")); + delete process.env.OPENSCOUT_REPO_WATCH_ROOTS; +}); + +afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); + if (originalRepoWatchRoots === undefined) delete process.env.OPENSCOUT_REPO_WATCH_ROOTS; + else process.env.OPENSCOUT_REPO_WATCH_ROOTS = originalRepoWatchRoots; +}); + +describe("repo-watch", () => { + test("parses Git worktree porcelain output", () => { + const parsed = parseGitWorktreeList([ + "worktree /Users/me/dev/openscout", + "HEAD abc123", + "branch refs/heads/main", + "", + "worktree /Users/me/dev/openscout-feature", + "HEAD def456", + "detached", + "", + ].join("\n")); + + expect(parsed).toEqual([ + { + path: "/Users/me/dev/openscout", + head: "abc123", + branch: "main", + detached: false, + bare: false, + }, + { + path: "/Users/me/dev/openscout-feature", + head: "def456", + branch: null, + detached: true, + bare: false, + }, + ]); + }); + + test("parses Git status porcelain v2 branch and dirty counts", () => { + const parsed = parseGitStatusPorcelainV2([ + "# branch.oid abc123", + "# branch.head feature/repo-watch", + "# branch.upstream origin/feature/repo-watch", + "# branch.ab +2 -1", + "1 .M N... 100644 100644 100644 abc abc src/index.ts", + "1 A. N... 000000 100644 100644 000 def src/new.ts", + "? scratch.md", + "u UU N... 100644 100644 100644 100644 a b c d conflicted.txt", + "", + ].join("\n")); + + expect(parsed.branch).toMatchObject({ + name: "feature/repo-watch", + upstream: "origin/feature/repo-watch", + ahead: 2, + behind: 1, + diverged: true, + }); + expect(parsed.status.clean).toBe(false); + expect(parsed.status.staged).toBe(1); + expect(parsed.status.unstaged).toBe(1); + expect(parsed.status.untracked).toBe(1); + expect(parsed.status.conflicts).toBe(1); + }); + + test("builds a snapshot from a real Git repository", async () => { + const repo = join(tempRoot, "demo"); + mkdirSync(repo, { recursive: true }); + git(repo, ["init", "-b", "main"]); + writeFileSync(join(repo, "README.md"), "hello\n", "utf8"); + git(repo, ["add", "README.md"]); + git(repo, ["-c", "user.email=scout@example.test", "-c", "user.name=Scout", "commit", "-m", "initial"]); + writeFileSync(join(repo, "README.md"), "hello\nworld\n", "utf8"); + writeFileSync(join(repo, "scratch.md"), "scratch\n", "utf8"); + + const snapshot = await getRepoWatchSnapshot({ + force: true, + cacheTtlMs: 0, + hints: [ + { + path: repo, + source: "endpoint", + agentId: "agent.codex", + agentName: "Codex", + agentState: "active", + sessionId: "session-1", + harness: "codex", + }, + ], + }); + + expect(snapshot.projects).toHaveLength(1); + expect(snapshot.totals).toMatchObject({ + projects: 1, + worktrees: 1, + dirtyWorktrees: 1, + attachedAgents: 1, + attachedSessions: 1, + }); + const worktree = snapshot.projects[0]!.worktrees[0]!; + expect(worktree.branch.name).toBe("main"); + expect(worktree.attention).toBe("attention"); + expect(worktree.attentionReasons).toContain("Dirty main"); + expect(worktree.status.unstaged).toBe(1); + expect(worktree.status.untracked).toBe(1); + expect(worktree.agents[0]?.id).toBe("agent.codex"); + expect(worktree.sessions[0]?.id).toBe("session-1"); + }); + + test("deduplicates Git root probes for repeated path hints", async () => { + const repo = join(tempRoot, "dedupe"); + mkdirSync(repo, { recursive: true }); + const calls: string[] = []; + + const snapshot = await getRepoWatchSnapshot({ + force: true, + cacheTtlMs: 0, + hints: [ + { path: repo, source: "endpoint", agentId: "agent.one" }, + { path: repo, source: "tail-transcript", sessionId: "session.one" }, + ], + git: async (_cwd, args) => { + calls.push(args.join(" ")); + if (args.join(" ") === "rev-parse --show-toplevel") return `${repo}\n`; + if (args.join(" ") === "rev-parse --git-common-dir") return ".git\n"; + if (args.join(" ") === "worktree list --porcelain") { + return [ + `worktree ${repo}`, + "HEAD abc123", + "branch refs/heads/feature", + "", + ].join("\n"); + } + if (args.join(" ") === "status --porcelain=v2 --branch -unormal") { + return [ + "# branch.oid abc123", + "# branch.head feature", + "", + ].join("\n"); + } + return ""; + }, + }); + + expect(calls.filter((call) => call === "rev-parse --show-toplevel")).toHaveLength(1); + expect(calls).not.toContain("diff --shortstat"); + expect(calls).not.toContain("log -1 --format=%ct"); + expect(snapshot.totals.attachedAgents).toBe(1); + expect(snapshot.totals.attachedSessions).toBe(1); + }); + + test("optionally enriches worktrees with diff and commit summaries", async () => { + const repo = join(tempRoot, "enrichment"); + mkdirSync(repo, { recursive: true }); + + const snapshot = await getRepoWatchSnapshot({ + force: true, + cacheTtlMs: 0, + includeDiff: true, + includeLastCommit: true, + hints: [{ path: repo, source: "environment" }], + git: async (_cwd, args) => { + if (args.join(" ") === "rev-parse --show-toplevel") return `${repo}\n`; + if (args.join(" ") === "rev-parse --git-common-dir") return ".git\n"; + if (args.join(" ") === "worktree list --porcelain") { + return [ + `worktree ${repo}`, + "HEAD abc123", + "branch refs/heads/feature", + "", + ].join("\n"); + } + if (args.join(" ") === "status --porcelain=v2 --branch -unormal") { + return [ + "# branch.oid abc123", + "# branch.head feature", + "", + ].join("\n"); + } + if (args.join(" ") === "diff --shortstat") return " 1 file changed, 2 insertions(+)\n"; + if (args.join(" ") === "diff --cached --shortstat") return ""; + if (args.join(" ") === "log -1 --format=%ct") return "1780460000\n"; + return ""; + }, + }); + + const worktree = snapshot.projects[0]!.worktrees[0]!; + expect(worktree.diff.unstagedShortstat).toBe("1 file changed, 2 insertions(+)"); + expect(worktree.diff.stagedShortstat).toBeNull(); + expect(worktree.lastCommitAt).toBe(1_780_460_000_000); + }); + + test("creates hints from broker and tail snapshots", () => { + const brokerHints = repoWatchHintsFromBrokerSnapshot({ + agents: { + "agent.one": { + displayName: "One", + metadata: { projectRoot: "/Users/example/project-one" }, + }, + "agent.loose": { + displayName: "Loose", + metadata: { projectRoot: "/Users/example/loose-project" }, + }, + }, + endpoints: { + "endpoint.one": { + id: "endpoint.one", + agentId: "agent.one", + projectRoot: "/Users/example/project-one", + state: "active", + sessionId: "session-one", + harness: "codex", + }, + "endpoint.offline": { + id: "endpoint.offline", + agentId: "agent.offline", + projectRoot: "/Users/example/offline-project", + state: "offline", + sessionId: "session-offline", + harness: "codex", + }, + }, + }); + const tailHints = repoWatchHintsFromTailDiscovery({ + generatedAt: 1, + processes: [{ + pid: 42, + ppid: 1, + command: "codex", + etime: "00:01", + cwd: "/tmp/project-two", + harness: "unattributed", + parentChain: [], + source: "codex", + }], + transcripts: [{ + source: "claude", + transcriptPath: "/tmp/transcript.jsonl", + sessionId: "session-two", + cwd: "/tmp/project-three", + project: "project-three", + harness: "scout-managed", + mtimeMs: 1, + size: 10, + }], + totals: { + total: 1, + scoutManaged: 1, + hudsonManaged: 0, + unattributed: 0, + transcripts: 1, + }, + }); + + expect(brokerHints.map((hint) => hint.source)).toEqual(["endpoint", "agent"]); + expect(brokerHints.map((hint) => hint.path)).not.toContain("/Users/example/offline-project"); + expect(tailHints.map((hint) => hint.source)).toEqual(["tail-process", "tail-transcript"]); + }); +}); diff --git a/packages/runtime/src/repo-watch/index.ts b/packages/runtime/src/repo-watch/index.ts new file mode 100644 index 00000000..dd75a88e --- /dev/null +++ b/packages/runtime/src/repo-watch/index.ts @@ -0,0 +1,1091 @@ +import { spawn } from "node:child_process"; +import { lstat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, relative, resolve } from "node:path"; + +import type { DiscoverySnapshot } from "../tail/index.js"; + +export type RepoWatchHintSource = + | "agent" + | "endpoint" + | "tail-process" + | "tail-transcript" + | "environment"; + +export type RepoWatchAttentionLevel = "critical" | "attention" | "active" | "quiet" | "unknown"; + +export type RepoWatchPathHint = { + path: string; + source: RepoWatchHintSource; + sourceLabel?: string; + agentId?: string; + agentName?: string; + agentState?: string; + sessionId?: string; + harness?: string; + runtimeSource?: string; +}; + +export type RepoWatchHintSummary = Omit & { + path: string; +}; + +export type RepoWatchChangedFile = { + path: string; + status: string; +}; + +export type RepoWatchStatusSummary = { + clean: boolean; + staged: number; + unstaged: number; + untracked: number; + conflicts: number; + changedFiles: number; + files: RepoWatchChangedFile[]; +}; + +export type RepoWatchBranchSummary = { + name: string | null; + upstream: string | null; + head: string | null; + detached: boolean; + ahead: number; + behind: number; + isMain: boolean; + diverged: boolean; +}; + +export type RepoWatchDiffSummary = { + unstagedShortstat: string | null; + stagedShortstat: string | null; +}; + +export type RepoWatchAgentRef = { + id: string; + name: string | null; + state: string | null; + harness: string | null; +}; + +export type RepoWatchSessionRef = { + id: string; + source: string | null; + harness: string | null; +}; + +export type RepoWatchWorktree = { + id: string; + path: string; + name: string; + isBare: boolean; + branch: RepoWatchBranchSummary; + status: RepoWatchStatusSummary; + diff: RepoWatchDiffSummary; + attention: RepoWatchAttentionLevel; + attentionReasons: string[]; + agents: RepoWatchAgentRef[]; + sessions: RepoWatchSessionRef[]; + hints: RepoWatchHintSummary[]; + lastCommitAt: number | null; + scannedAt: number; + error: string | null; +}; + +export type RepoWatchProjectStats = { + worktrees: number; + dirtyWorktrees: number; + conflictedWorktrees: number; + attachedAgents: number; + attachedSessions: number; + staged: number; + unstaged: number; + untracked: number; + conflicts: number; +}; + +export type RepoWatchProject = { + id: string; + name: string; + root: string; + commonGitDir: string; + attention: RepoWatchAttentionLevel; + attentionReasons: string[]; + worktrees: RepoWatchWorktree[]; + stats: RepoWatchProjectStats; + hints: RepoWatchHintSummary[]; +}; + +export type RepoWatchSnapshot = { + generatedAt: number; + projects: RepoWatchProject[]; + totals: { + projects: number; + worktrees: number; + dirtyWorktrees: number; + conflictedWorktrees: number; + attentionWorktrees: number; + attachedAgents: number; + attachedSessions: number; + }; + warnings: string[]; +}; + +type GitExec = (cwd: string, args: string[]) => Promise; + +export type RepoWatchSnapshotOptions = { + hints?: RepoWatchPathHint[]; + force?: boolean; + cacheTtlMs?: number; + scanBudgetMs?: number; + maxRoots?: number; + maxWorktrees?: number; + maxFilesPerWorktree?: number; + includeDiff?: boolean; + includeLastCommit?: boolean; + now?: () => number; + git?: GitExec; +}; + +type NormalizedHint = RepoWatchHintSummary; + +type GitRoot = { + topLevel: string; + commonGitDir: string; + hints: NormalizedHint[]; +}; + +type ParsedWorktree = { + path: string; + head: string | null; + branch: string | null; + detached: boolean; + bare: boolean; +}; + +type ParsedStatus = { + branch: RepoWatchBranchSummary; + status: RepoWatchStatusSummary; +}; + +const DEFAULT_CACHE_TTL_MS = 2_500; +const DEFAULT_MAX_ROOTS = 8; +const DEFAULT_MAX_WORKTREES = 4; +const DEFAULT_MAX_FILES_PER_WORKTREE = 12; +const DEFAULT_SCAN_BUDGET_MS = 4_000; +const GIT_TIMEOUT_MS = 650; +const GIT_MAX_BUFFER = 1024 * 1024; + +let cachedSnapshot: { signature: string; generatedAt: number; snapshot: RepoWatchSnapshot } | null = null; + +function readPositiveIntEnv(name: string, fallback: number): number { + const raw = Number.parseInt(process.env[name] ?? "", 10); + return Number.isFinite(raw) && raw > 0 ? raw : fallback; +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function metadataString(metadata: Record | undefined, key: string): string | null { + if (!metadata) return null; + return stringValue(metadata[key]); +} + +function expandHome(input: string): string { + if (input === "~") return homedir(); + if (input.startsWith("~/")) return resolve(homedir(), input.slice(2)); + return input; +} + +function normalizePath(input: string): string { + return resolve(expandHome(input.trim())); +} + +function isBroadLocalRootPath(path: string): boolean { + const home = normalizePath(homedir()); + return path === home + || path === resolve(home, "dev") + || path === resolve(home, "Developer") + || path === dirname(home) + || path === "/"; +} + +function isTempLocalPath(path: string): boolean { + const roots = [ + "/tmp", + "/private/tmp", + process.env.TMPDIR ? normalizePath(process.env.TMPDIR) : null, + ].filter((root): root is string => Boolean(root)); + return roots.some((root) => pathContains(root, path)); +} + +function shouldIncludeBrokerPath(path: string): boolean { + const normalized = normalizePath(path); + return !isBroadLocalRootPath(normalized) && !isTempLocalPath(normalized); +} + +function stateRank(state: string | undefined): number { + switch (state?.toLowerCase()) { + case "active": return 0; + case "idle": return 10; + case "waiting": return 20; + case "offline": return 200; + default: return 40; + } +} + +function sourceRank(source: RepoWatchHintSource): number { + switch (source) { + case "environment": return -100; + case "endpoint": return 0; + case "tail-process": return 10; + case "tail-transcript": return 20; + case "agent": return 50; + } +} + +function hintDiscoveryRank(hint: Pick): number { + let rank = sourceRank(hint.source) + stateRank(hint.agentState); + if (isBroadLocalRootPath(hint.path)) rank += 300; + if (isTempLocalPath(hint.path)) rank += 200; + return rank; +} + +function hashId(input: string): string { + let hash = 2166136261; + for (let index = 0; index < input.length; index++) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +function pathContains(parent: string, child: string): boolean { + const rel = relative(parent, child); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function uniqueBy(items: T[], keyFor: (item: T) => string): T[] { + const seen = new Set(); + const result: T[] = []; + for (const item of items) { + const key = keyFor(item); + if (seen.has(key)) continue; + seen.add(key); + result.push(item); + } + return result; +} + +async function defaultGit(cwd: string, args: string[]): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn("git", args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let settled = false; + + const killTimer = (setTimeout(() => { + terminate(); + fail(new Error(`git ${args.join(" ")} timed out after ${GIT_TIMEOUT_MS}ms`)); + }, GIT_TIMEOUT_MS)); + killTimer.unref?.(); + + function terminate(): void { + child.kill("SIGTERM"); + const hardKillTimer = setTimeout(() => child.kill("SIGKILL"), 250); + hardKillTimer.unref?.(); + } + + function cleanup(): void { + clearTimeout(killTimer); + } + + function fail(error: Error): void { + if (settled) return; + settled = true; + cleanup(); + reject(error); + } + + function succeed(output: string): void { + if (settled) return; + settled = true; + cleanup(); + resolvePromise(output); + } + + function append(kind: "stdout" | "stderr", chunk: unknown): void { + const text = typeof chunk === "string" ? chunk : String(chunk); + if (kind === "stdout") stdout += text; + else stderr += text; + if (stdout.length + stderr.length > GIT_MAX_BUFFER) { + terminate(); + fail(new Error(`git ${args.join(" ")} exceeded output limit`)); + } + } + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => append("stdout", chunk)); + child.stderr.on("data", (chunk) => append("stderr", chunk)); + child.on("error", (error) => fail(error)); + child.on("close", (code, signal) => { + if (code === 0) { + succeed(stdout); + return; + } + const detail = (stderr || `git exited with ${signal ?? code ?? "unknown status"}`).trim(); + fail(new Error(detail)); + }); + }); +} + +async function existingDirectoryForPath(path: string): Promise { + try { + const stats = await lstat(path); + if (stats.isDirectory()) return path; + if (stats.isFile()) return dirname(path); + return null; + } catch { + return null; + } +} + +function environmentHints(): RepoWatchPathHint[] { + const raw = process.env.OPENSCOUT_REPO_WATCH_ROOTS?.trim(); + if (!raw) return []; + return raw + .split(/[,:]/) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((path) => ({ + path, + source: "environment" as const, + sourceLabel: "OPENSCOUT_REPO_WATCH_ROOTS", + })); +} + +function normalizeHints(hints: RepoWatchPathHint[]): NormalizedHint[] { + const normalized = uniqueBy( + hints + .filter((hint) => hint.path.trim()) + .map((hint) => ({ + ...hint, + path: normalizePath(hint.path), + })), + (hint) => [ + hint.path, + hint.source, + hint.agentId ?? "", + hint.sessionId ?? "", + hint.runtimeSource ?? "", + hint.harness ?? "", + ].join("\u0000"), + ); + return normalized.sort((left, right) => { + const rankDelta = hintDiscoveryRank(left) - hintDiscoveryRank(right); + if (rankDelta !== 0) return rankDelta; + return left.path.localeCompare(right.path); + }); +} + +function groupHintsByPath(hints: NormalizedHint[]): Array<{ path: string; hints: NormalizedHint[] }> { + const groups = new Map(); + for (const hint of hints) { + const group = groups.get(hint.path); + if (group) { + group.push(hint); + } else { + groups.set(hint.path, [hint]); + } + } + return [...groups.entries()].map(([path, groupedHints]) => ({ path, hints: groupedHints })); +} + +function budgetExceeded(deadlineMs: number | null): boolean { + return deadlineMs !== null && Date.now() >= deadlineMs; +} + +async function discoverGitRoots(hints: NormalizedHint[], git: GitExec, maxRoots: number, deadlineMs: number | null): Promise<{ + roots: GitRoot[]; + warnings: string[]; +}> { + const warnings: string[] = []; + const roots = new Map(); + const groups = groupHintsByPath(hints); + let truncatedByBudget = false; + let truncatedByMax = false; + + for (const group of groups) { + if (budgetExceeded(deadlineMs)) { + truncatedByBudget = true; + break; + } + if (roots.size >= maxRoots) { + truncatedByMax = true; + break; + } + + const dir = await existingDirectoryForPath(group.path); + if (!dir) { + warnings.push(`Skipped missing repo-watch path: ${group.path}`); + continue; + } + + let topLevel: string; + try { + topLevel = normalizePath((await git(dir, ["rev-parse", "--show-toplevel"])).trim()); + } catch { + continue; + } + + let commonGitDir = topLevel; + try { + const rawCommon = (await git(topLevel, ["rev-parse", "--git-common-dir"])).trim(); + commonGitDir = normalizePath(isAbsolute(rawCommon) ? rawCommon : resolve(topLevel, rawCommon)); + } catch { + warnings.push(`Could not resolve Git common directory for ${topLevel}`); + } + + const existing = roots.get(commonGitDir); + if (existing) { + existing.hints.push(...group.hints); + } else if (roots.size < maxRoots) { + roots.set(commonGitDir, { topLevel, commonGitDir, hints: [...group.hints] }); + } + } + + if (truncatedByMax) { + warnings.push(`Repo Watch limited discovery to ${maxRoots} repositories.`); + } + if (truncatedByBudget) { + warnings.push("Repo Watch stopped discovery after reaching the scan budget."); + } + + return { + roots: [...roots.values()].map((root) => ({ + ...root, + hints: uniqueBy(root.hints, (hint) => [ + hint.path, + hint.source, + hint.agentId ?? "", + hint.sessionId ?? "", + ].join("\u0000")), + })), + warnings, + }; +} + +export function parseGitWorktreeList(output: string): ParsedWorktree[] { + const worktrees: ParsedWorktree[] = []; + let current: Partial | null = null; + + const flush = () => { + if (!current?.path) return; + worktrees.push({ + path: normalizePath(current.path), + head: current.head ?? null, + branch: current.branch ?? null, + detached: current.detached ?? current.branch == null, + bare: current.bare ?? false, + }); + current = null; + }; + + for (const line of output.split(/\r?\n/)) { + if (!line.trim()) { + flush(); + continue; + } + const [key, ...rest] = line.split(" "); + const value = rest.join(" ").trim(); + if (key === "worktree") { + flush(); + current = { path: value }; + } else if (current && key === "HEAD") { + current.head = value || null; + } else if (current && key === "branch") { + current.branch = value.replace(/^refs\/heads\//, "") || null; + } else if (current && key === "detached") { + current.detached = true; + } else if (current && key === "bare") { + current.bare = true; + } + } + flush(); + return worktrees; +} + +function blankStatus(): RepoWatchStatusSummary { + return { + clean: true, + staged: 0, + unstaged: 0, + untracked: 0, + conflicts: 0, + changedFiles: 0, + files: [], + }; +} + +function extractStatusPath(line: string): string { + if (line.startsWith("? ")) return line.slice(2).trim(); + if (line.startsWith("u ")) { + return line.split(" ").slice(10).join(" ").trim(); + } + if (line.startsWith("2 ")) { + const primary = line.split("\t")[0] ?? line; + return primary.split(" ").slice(9).join(" ").trim(); + } + if (line.startsWith("1 ")) { + return line.split(" ").slice(8).join(" ").trim(); + } + return ""; +} + +function pushChangedFile(status: RepoWatchStatusSummary, path: string, label: string, maxFiles: number): void { + status.changedFiles += 1; + if (status.files.length >= maxFiles) return; + status.files.push({ path: path || "unknown", status: label }); +} + +export function parseGitStatusPorcelainV2( + output: string, + options?: { maxFiles?: number }, +): ParsedStatus { + const maxFiles = options?.maxFiles ?? DEFAULT_MAX_FILES_PER_WORKTREE; + const status = blankStatus(); + let head: string | null = null; + let branchName: string | null = null; + let upstream: string | null = null; + let ahead = 0; + let behind = 0; + + for (const line of output.split(/\r?\n/)) { + if (!line) continue; + if (line.startsWith("# branch.oid ")) { + const value = line.slice("# branch.oid ".length).trim(); + head = value && value !== "(initial)" ? value : null; + continue; + } + if (line.startsWith("# branch.head ")) { + const value = line.slice("# branch.head ".length).trim(); + branchName = value && value !== "(detached)" ? value : null; + continue; + } + if (line.startsWith("# branch.upstream ")) { + upstream = line.slice("# branch.upstream ".length).trim() || null; + continue; + } + if (line.startsWith("# branch.ab ")) { + const match = line.match(/\+(\d+)\s+-(\d+)/); + ahead = match?.[1] ? Number.parseInt(match[1], 10) : 0; + behind = match?.[2] ? Number.parseInt(match[2], 10) : 0; + continue; + } + + if (line.startsWith("? ")) { + status.untracked += 1; + pushChangedFile(status, extractStatusPath(line), "untracked", maxFiles); + continue; + } + + if (line.startsWith("u ")) { + status.conflicts += 1; + pushChangedFile(status, extractStatusPath(line), "conflict", maxFiles); + continue; + } + + if (line.startsWith("1 ") || line.startsWith("2 ")) { + const xy = line.slice(2, 4); + const staged = xy[0] && xy[0] !== "."; + const unstaged = xy[1] && xy[1] !== "."; + if (staged) status.staged += 1; + if (unstaged) status.unstaged += 1; + const label = [ + staged ? "staged" : null, + unstaged ? "unstaged" : null, + ].filter(Boolean).join("+") || "changed"; + pushChangedFile(status, extractStatusPath(line), label, maxFiles); + } + } + + status.clean = status.staged === 0 + && status.unstaged === 0 + && status.untracked === 0 + && status.conflicts === 0; + + const isMain = branchName === "main" || branchName === "master"; + return { + branch: { + name: branchName, + upstream, + head, + detached: branchName == null, + ahead, + behind, + isMain, + diverged: ahead > 0 && behind > 0, + }, + status, + }; +} + +function attentionRank(level: RepoWatchAttentionLevel): number { + switch (level) { + case "critical": return 4; + case "attention": return 3; + case "active": return 2; + case "quiet": return 1; + case "unknown": return 0; + } +} + +function maxAttention(levels: RepoWatchAttentionLevel[]): RepoWatchAttentionLevel { + return levels.reduce((best, candidate) => ( + attentionRank(candidate) > attentionRank(best) ? candidate : best + ), "unknown"); +} + +function classifyWorktree(input: { + status: RepoWatchStatusSummary; + branch: RepoWatchBranchSummary; + agents: RepoWatchAgentRef[]; + sessions: RepoWatchSessionRef[]; + error: string | null; +}): { attention: RepoWatchAttentionLevel; reasons: string[] } { + const reasons: string[] = []; + if (input.error) { + return { attention: "attention", reasons: [input.error] }; + } + if (input.status.conflicts > 0) { + reasons.push(`${input.status.conflicts} conflicted file${input.status.conflicts === 1 ? "" : "s"}`); + return { attention: "critical", reasons }; + } + if (input.branch.isMain && !input.status.clean) { + reasons.push(`Dirty ${input.branch.name}`); + } + if (input.branch.diverged) { + reasons.push(`Diverged from ${input.branch.upstream ?? "upstream"}`); + } + if (reasons.length > 0) { + return { attention: "attention", reasons }; + } + if (!input.status.clean) { + reasons.push(`${input.status.changedFiles} changed file${input.status.changedFiles === 1 ? "" : "s"}`); + } + if (input.branch.ahead > 0) { + reasons.push(`${input.branch.ahead} ahead`); + } + if (input.branch.behind > 0) { + reasons.push(`${input.branch.behind} behind`); + } + if (input.agents.length > 0 || input.sessions.length > 0) { + reasons.push("Scout activity attached"); + } + if (reasons.length > 0) { + return { attention: "active", reasons }; + } + return { attention: "quiet", reasons: [] }; +} + +async function safeGit(git: GitExec, cwd: string, args: string[]): Promise { + try { + return await git(cwd, args); + } catch { + return null; + } +} + +function refsForHints(hints: NormalizedHint[]): { + agents: RepoWatchAgentRef[]; + sessions: RepoWatchSessionRef[]; +} { + const agents = uniqueBy( + hints + .filter((hint) => hint.agentId) + .map((hint) => ({ + id: hint.agentId!, + name: hint.agentName ?? null, + state: hint.agentState ?? null, + harness: hint.harness ?? hint.runtimeSource ?? null, + })), + (agent) => agent.id, + ); + + const sessions = uniqueBy( + hints + .filter((hint) => hint.sessionId) + .map((hint) => ({ + id: hint.sessionId!, + source: hint.runtimeSource ?? null, + harness: hint.harness ?? null, + })), + (session) => session.id, + ); + + return { agents, sessions }; +} + +async function scanWorktree( + worktree: ParsedWorktree, + hints: NormalizedHint[], + git: GitExec, + now: number, + options: { + maxFiles: number; + includeDiff: boolean; + includeLastCommit: boolean; + }, +): Promise { + const statusOutput = await safeGit(git, worktree.path, ["status", "--porcelain=v2", "--branch", "-unormal"]); + const parsed = statusOutput + ? parseGitStatusPorcelainV2(statusOutput, { maxFiles: options.maxFiles }) + : { + branch: { + name: worktree.branch, + upstream: null, + head: worktree.head, + detached: worktree.detached, + ahead: 0, + behind: 0, + isMain: worktree.branch === "main" || worktree.branch === "master", + diverged: false, + }, + status: blankStatus(), + }; + + const branch: RepoWatchBranchSummary = { + ...parsed.branch, + name: parsed.branch.name ?? worktree.branch, + head: parsed.branch.head ?? worktree.head, + detached: parsed.branch.name == null && worktree.branch == null, + isMain: (parsed.branch.name ?? worktree.branch) === "main" || (parsed.branch.name ?? worktree.branch) === "master", + }; + const [unstagedDiff, stagedDiff] = options.includeDiff + ? await Promise.all([ + safeGit(git, worktree.path, ["diff", "--shortstat"]), + safeGit(git, worktree.path, ["diff", "--cached", "--shortstat"]), + ]) + : [null, null]; + const lastCommitRaw = options.includeLastCommit + ? await safeGit(git, worktree.path, ["log", "-1", "--format=%ct"]) + : null; + const refs = refsForHints(hints); + const error = statusOutput ? null : "Could not read Git status"; + const classified = classifyWorktree({ + status: parsed.status, + branch, + agents: refs.agents, + sessions: refs.sessions, + error, + }); + const lastCommitSeconds = Number.parseInt(lastCommitRaw?.trim() ?? "", 10); + + return { + id: `worktree:${hashId(worktree.path)}`, + path: worktree.path, + name: basename(worktree.path) || worktree.path, + isBare: worktree.bare, + branch, + status: parsed.status, + diff: { + unstagedShortstat: unstagedDiff?.trim() || null, + stagedShortstat: stagedDiff?.trim() || null, + }, + attention: classified.attention, + attentionReasons: classified.reasons, + agents: refs.agents, + sessions: refs.sessions, + hints, + lastCommitAt: Number.isFinite(lastCommitSeconds) ? lastCommitSeconds * 1_000 : null, + scannedAt: now, + error, + }; +} + +function statsForProject(worktrees: RepoWatchWorktree[]): RepoWatchProjectStats { + const agentIds = new Set(); + const sessionIds = new Set(); + for (const worktree of worktrees) { + worktree.agents.forEach((agent) => agentIds.add(agent.id)); + worktree.sessions.forEach((session) => sessionIds.add(session.id)); + } + return { + worktrees: worktrees.length, + dirtyWorktrees: worktrees.filter((worktree) => !worktree.status.clean).length, + conflictedWorktrees: worktrees.filter((worktree) => worktree.status.conflicts > 0).length, + attachedAgents: agentIds.size, + attachedSessions: sessionIds.size, + staged: worktrees.reduce((sum, worktree) => sum + worktree.status.staged, 0), + unstaged: worktrees.reduce((sum, worktree) => sum + worktree.status.unstaged, 0), + untracked: worktrees.reduce((sum, worktree) => sum + worktree.status.untracked, 0), + conflicts: worktrees.reduce((sum, worktree) => sum + worktree.status.conflicts, 0), + }; +} + +async function scanProject( + root: GitRoot, + git: GitExec, + now: number, + maxWorktrees: number, + options: { + maxFiles: number; + includeDiff: boolean; + includeLastCommit: boolean; + deadlineMs: number | null; + }, +): Promise<{ project: RepoWatchProject; warnings: string[] }> { + const warnings: string[] = []; + const rawWorktrees = await safeGit(git, root.topLevel, ["worktree", "list", "--porcelain"]); + const parsedWorktrees = rawWorktrees + ? parseGitWorktreeList(rawWorktrees) + : [{ path: root.topLevel, head: null, branch: null, detached: false, bare: false }]; + const orderedWorktrees = [...parsedWorktrees].sort((left, right) => { + const leftRank = Math.min( + ...root.hints + .filter((hint) => pathContains(left.path, hint.path)) + .map((hint) => hintDiscoveryRank(hint)), + 100, + ); + const rightRank = Math.min( + ...root.hints + .filter((hint) => pathContains(right.path, hint.path)) + .map((hint) => hintDiscoveryRank(hint)), + 100, + ); + if (leftRank !== rightRank) return leftRank - rightRank; + return left.path.localeCompare(right.path); + }); + const limitedWorktrees = orderedWorktrees.slice(0, maxWorktrees); + if (parsedWorktrees.length > limitedWorktrees.length) { + warnings.push(`Repo Watch limited ${root.topLevel} to ${maxWorktrees} worktrees.`); + } + const worktrees: RepoWatchWorktree[] = []; + for (const worktree of limitedWorktrees) { + if (budgetExceeded(options.deadlineMs)) { + warnings.push(`Repo Watch stopped scanning ${root.topLevel} after reaching the scan budget.`); + break; + } + const matchedHints = root.hints.filter((hint) => pathContains(worktree.path, hint.path)); + const hints = matchedHints.length > 0 || worktree.path !== root.topLevel + ? matchedHints + : root.hints; + worktrees.push(await scanWorktree(worktree, hints, git, now, options)); + } + const stats = statsForProject(worktrees); + const attention = maxAttention(worktrees.map((worktree) => worktree.attention)); + const attentionReasons = uniqueBy( + worktrees.flatMap((worktree) => worktree.attentionReasons), + (reason) => reason, + ).slice(0, 6); + const rootPath = worktrees[0]?.path ?? root.topLevel; + + return { + project: { + id: `repo:${hashId(root.commonGitDir)}`, + name: basename(rootPath) || basename(root.commonGitDir) || rootPath, + root: rootPath, + commonGitDir: root.commonGitDir, + attention, + attentionReasons, + worktrees, + stats, + hints: root.hints, + }, + warnings, + }; +} + +function totalsForProjects(projects: RepoWatchProject[]): RepoWatchSnapshot["totals"] { + const agentIds = new Set(); + const sessionIds = new Set(); + let worktrees = 0; + let dirtyWorktrees = 0; + let conflictedWorktrees = 0; + let attentionWorktrees = 0; + for (const project of projects) { + worktrees += project.worktrees.length; + dirtyWorktrees += project.stats.dirtyWorktrees; + conflictedWorktrees += project.stats.conflictedWorktrees; + attentionWorktrees += project.worktrees.filter((worktree) => + worktree.attention === "critical" || worktree.attention === "attention", + ).length; + project.worktrees.forEach((worktree) => { + worktree.agents.forEach((agent) => agentIds.add(agent.id)); + worktree.sessions.forEach((session) => sessionIds.add(session.id)); + }); + } + return { + projects: projects.length, + worktrees, + dirtyWorktrees, + conflictedWorktrees, + attentionWorktrees, + attachedAgents: agentIds.size, + attachedSessions: sessionIds.size, + }; +} + +function snapshotSignature(hints: NormalizedHint[]): string { + return hints + .map((hint) => [ + hint.path, + hint.source, + hint.agentId ?? "", + hint.sessionId ?? "", + hint.runtimeSource ?? "", + ].join("\u0000")) + .sort() + .join("\u0001"); +} + +export async function getRepoWatchSnapshot(options: RepoWatchSnapshotOptions = {}): Promise { + const now = options.now?.() ?? Date.now(); + const cacheTtlMs = options.cacheTtlMs ?? DEFAULT_CACHE_TTL_MS; + const maxRoots = options.maxRoots ?? readPositiveIntEnv("OPENSCOUT_REPO_WATCH_MAX_ROOTS", DEFAULT_MAX_ROOTS); + const maxWorktrees = options.maxWorktrees ?? readPositiveIntEnv("OPENSCOUT_REPO_WATCH_MAX_WORKTREES", DEFAULT_MAX_WORKTREES); + const maxFiles = options.maxFilesPerWorktree ?? readPositiveIntEnv( + "OPENSCOUT_REPO_WATCH_MAX_FILES_PER_WORKTREE", + DEFAULT_MAX_FILES_PER_WORKTREE, + ); + const scanBudgetMs = options.scanBudgetMs ?? readPositiveIntEnv( + "OPENSCOUT_REPO_WATCH_SCAN_BUDGET_MS", + DEFAULT_SCAN_BUDGET_MS, + ); + const deadlineMs = scanBudgetMs > 0 ? Date.now() + scanBudgetMs : null; + const git = options.git ?? defaultGit; + const hints = normalizeHints([ + ...environmentHints(), + ...(options.hints ?? []), + ]); + const signature = snapshotSignature(hints); + + if (!options.force + && cachedSnapshot + && cachedSnapshot.signature === signature + && now - cachedSnapshot.generatedAt <= cacheTtlMs) { + return cachedSnapshot.snapshot; + } + + const discovered = await discoverGitRoots(hints, git, maxRoots, deadlineMs); + const projects: RepoWatchProject[] = []; + const warnings = [...discovered.warnings]; + for (const root of discovered.roots) { + if (budgetExceeded(deadlineMs)) { + warnings.push("Repo Watch stopped scanning repositories after reaching the scan budget."); + break; + } + const result = await scanProject(root, git, now, maxWorktrees, { + maxFiles, + includeDiff: options.includeDiff ?? false, + includeLastCommit: options.includeLastCommit ?? false, + deadlineMs, + }); + projects.push(result.project); + warnings.push(...result.warnings); + } + projects.sort((left, right) => { + const attentionDelta = attentionRank(right.attention) - attentionRank(left.attention); + if (attentionDelta !== 0) return attentionDelta; + return left.name.localeCompare(right.name); + }); + + const snapshot: RepoWatchSnapshot = { + generatedAt: now, + projects, + totals: totalsForProjects(projects), + warnings, + }; + cachedSnapshot = { signature, generatedAt: now, snapshot }; + return snapshot; +} + +export function repoWatchHintsFromBrokerSnapshot(snapshot: { + agents?: Record; + }>; + endpoints?: Record; + }>; +} | null | undefined): RepoWatchPathHint[] { + if (!snapshot) return []; + const hints: RepoWatchPathHint[] = []; + const agentsWithEndpointHints = new Set(); + for (const endpoint of Object.values(snapshot.endpoints ?? {})) { + const endpointState = endpoint.state?.toLowerCase(); + if (endpointState === "offline" || endpointState === "stale" || endpointState === "retired") continue; + const path = endpoint.projectRoot ?? endpoint.cwd ?? metadataString(endpoint.metadata, "projectRoot"); + if (!path || !shouldIncludeBrokerPath(path)) continue; + const agent = endpoint.agentId ? snapshot.agents?.[endpoint.agentId] : undefined; + if (endpoint.agentId) agentsWithEndpointHints.add(endpoint.agentId); + hints.push({ + path, + source: "endpoint", + sourceLabel: endpoint.id ? `endpoint:${endpoint.id}` : "endpoint", + agentId: endpoint.agentId, + agentName: agent?.displayName ?? agent?.handle ?? endpoint.agentId, + agentState: endpoint.state, + sessionId: endpoint.sessionId, + harness: endpoint.harness ?? endpoint.transport, + runtimeSource: endpoint.transport, + }); + } + for (const [agentId, agent] of Object.entries(snapshot.agents ?? {})) { + if (agentsWithEndpointHints.has(agentId)) continue; + const path = metadataString(agent.metadata, "projectRoot") + ?? metadataString(agent.metadata, "cwd") + ?? metadataString(agent.metadata, "workspaceRoot"); + if (!path || !shouldIncludeBrokerPath(path)) continue; + hints.push({ + path, + source: "agent", + sourceLabel: `agent:${agentId}`, + agentId, + agentName: agent.displayName ?? agent.handle ?? agentId, + }); + } + return hints; +} + +export function repoWatchHintsFromTailDiscovery(discovery: DiscoverySnapshot | null | undefined): RepoWatchPathHint[] { + if (!discovery) return []; + const hints: RepoWatchPathHint[] = []; + for (const process of discovery.processes ?? []) { + if (!process.cwd) continue; + hints.push({ + path: process.cwd, + source: "tail-process", + sourceLabel: `pid:${process.pid}`, + harness: process.harness, + runtimeSource: process.source, + }); + } + for (const transcript of discovery.transcripts ?? []) { + if (!transcript.cwd) continue; + hints.push({ + path: transcript.cwd, + source: "tail-transcript", + sourceLabel: transcript.transcriptPath, + sessionId: transcript.sessionId ?? undefined, + harness: transcript.harness, + runtimeSource: transcript.source, + }); + } + return hints; +}