From e04373e01ba9f4abf33d1165fa63c87a7823e231 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 02:31:50 +0900 Subject: [PATCH 01/51] Document and verify explicit workflow source fallback --- App/Tests/WorkflowCommandHandlerTests.swift | 26 +++++ App/Tests/WorkflowRunAdmissionTests.swift | 6 ++ .../WorkflowRuntimeCoordinatorTests.swift | 48 ++++++++++ .../000-plan.md | 1 + .../004-explicit-workflow-source.md | 95 +++++++++++++++++++ docs-ai/README.md | 2 +- docs/components/cli.md | 54 +++++++++-- docs/components/workflows.md | 6 ++ skills/prowl-cli/SKILL.md | 22 +++-- skills/prowl-workflow/SKILL.md | 18 +++- skills/prowl-workflow/references/runbook.md | 16 +++- 11 files changed, 271 insertions(+), 23 deletions(-) create mode 100644 docs-ai/073-codex-daemon-caller-identity/004-explicit-workflow-source.md diff --git a/App/Tests/WorkflowCommandHandlerTests.swift b/App/Tests/WorkflowCommandHandlerTests.swift index 7f20754a..2e6fb745 100644 --- a/App/Tests/WorkflowCommandHandlerTests.swift +++ b/App/Tests/WorkflowCommandHandlerTests.swift @@ -173,6 +173,32 @@ struct WorkflowCommandHandlerTests { #expect(response.error?.code == CLIErrorCode.workflowFailed) } + @Test func anExplicitSourceWithoutACallerSelectsAPaneWithoutClaimingItsIdentity() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + let snapshot = fixture.snapshot() + let handler = WorkflowCommandHandler { snapshot } + + // A focused pane is not evidence of who invoked the command. + for selector in [TargetSelector.none, .worktree("main")] { + let source = try handler.resolveSource(selector, snapshot: snapshot, callerPane: nil).get() + #expect(source.paneID == nil) + #expect(!source.paneIsCaller) + } + let explicit = try handler.resolveSource( + .pane(fixture.paneID.uuidString), snapshot: snapshot, callerPane: nil + ).get() + #expect(explicit.paneID == fixture.paneID) + #expect(!explicit.paneIsCaller) + + let missing = handler.resolveSource(.pane(UUID().uuidString), snapshot: snapshot, callerPane: nil) + guard case .failure(let refusal) = missing else { + Issue.record("A closed source must not fall back to the focused pane") + return + } + #expect(refusal.response.error?.code == CLIErrorCode.targetNotFound) + } + @Test func repositoryEnabledStateUsesTheResolvedRepositoryIdentity() async throws { let fixture = try Fixture() defer { fixture.cleanUp() } diff --git a/App/Tests/WorkflowRunAdmissionTests.swift b/App/Tests/WorkflowRunAdmissionTests.swift index c57c1aa7..5b9b7720 100644 --- a/App/Tests/WorkflowRunAdmissionTests.swift +++ b/App/Tests/WorkflowRunAdmissionTests.swift @@ -314,6 +314,12 @@ struct WorkflowRunAdmissionTests { #expect(admitted.callerRole == nil) #expect(admitted.session.run.selfInitiatedLine == nil) #expect(admitted.session.run.phase == .waitingForRole(role: "author", ordinal: 1)) + #expect(admitted.session.run.context.sourcePaneID == fixture.authorPane) + #expect(admitted.session.run.bindings["author"]?.pane?.surfaceID == fixture.authorPane) + let response = WorkflowRunPayload(run: admitted.session.run, callerRole: nil, includeSelfInitiated: true) + #expect(response.selfInitiated == nil) + let completion = response.activation?.expect.completion ?? [] + #expect(completion.isEmpty) } @Test func definitionSelectionCoversIdNameShadowingValidityAndEnabledState() throws { diff --git a/App/Tests/WorkflowRuntimeCoordinatorTests.swift b/App/Tests/WorkflowRuntimeCoordinatorTests.swift index aa215c14..db0b4491 100644 --- a/App/Tests/WorkflowRuntimeCoordinatorTests.swift +++ b/App/Tests/WorkflowRuntimeCoordinatorTests.swift @@ -166,6 +166,54 @@ struct WorkflowRuntimeCoordinatorTests { #expect(await fixture.coordinator.read(request, callerPane: Self.authorCaller).ok == false) } + @Test func tokenFallbackReturnsToTheBoundPaneAndRejectsThePreviousTaskToken() async throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + var session = try fixture.waitingSession() + try session.store.ensureLayout(runID: session.run.id) + let counter = WorkflowRunMachineTests.TokenCounter() + _ = counter.next() // The first activation already owns TOKEN-1. + var machine = session.machine(now: { Self.now }, makeToken: { counter.next() }) + let (brief, _) = machine.deliver( + ordinal: nil, selector: .token("TOKEN-1"), body: "## Scope\nx\n## Claims\ny", verdict: nil) + _ = try brief.get() + try session.store.writeDelivery( + runID: session.run.id, name: "brief", ordinal: 1, body: "## Scope\nx\n## Claims\ny") + _ = machine.apply(.deliveryPersisted(ordinal: 1)) + _ = machine.apply(.launched(ordinal: 2, pane: WorkflowRunMachineTests.reviewerPane, dispatchID: "dispatch-2")) + let (findings, _) = machine.deliver( + ordinal: nil, selector: .token("TOKEN-2"), body: "## Findings\nx\n## Verdict\nissues", verdict: "issues") + _ = try findings.get() + try session.store.writeDelivery( + runID: session.run.id, name: "findings", ordinal: 2, body: "## Findings\nx\n## Verdict\nissues") + _ = machine.apply(.deliveryPersisted(ordinal: 2)) + let effects = machine.apply(.roleIdle(ordinal: 3)) + #expect( + effects.contains { + if case .inject(_, let surfaceID, 3, let line, _) = $0 { + return surfaceID == WorkflowRunMachineTests.authorPane.surfaceID && line.contains("TOKEN-3") + } + return false + }) + _ = machine.apply(.injectionSucceeded(ordinal: 3, dispatchID: "dispatch-3")) + session.run = machine.run + fixture.sessions = [session] + + let oldRead = await fixture.coordinator.read( + .init(action: .read, invocation: 1, runID: session.run.id.uuidString, token: "TOKEN-1"), callerPane: nil) + #expect(oldRead.error?.code == CLIErrorCode.tokenInvalid) + let currentRead = await fixture.coordinator.read( + .init(action: .read, invocation: 3, runID: session.run.id.uuidString, token: "TOKEN-3"), callerPane: nil) + #expect(currentRead.ok) + let staleDelivery = await fixture.coordinator.deliver( + .init(action: .deliver, body: "late brief", token: "TOKEN-1"), callerPane: nil) + #expect(staleDelivery.error?.code == CLIErrorCode.tokenInvalid) + let wrongPane = await fixture.coordinator.deliver( + .init(action: .deliver, body: "wrong pane", token: "TOKEN-3"), callerPane: Self.strangerCaller) + #expect(wrongPane.error?.code == CLIErrorCode.stepNotExpecting) + #expect(fixture.sent.isEmpty) + } + @Test func completedTaskRemainsReadableUntilReassignmentOrHistoryRemoval() async throws { let fixture = try Fixture() defer { fixture.cleanUp() } diff --git a/docs-ai/073-codex-daemon-caller-identity/000-plan.md b/docs-ai/073-codex-daemon-caller-identity/000-plan.md index b41bbd3b..4ef6c5d6 100644 --- a/docs-ai/073-codex-daemon-caller-identity/000-plan.md +++ b/docs-ai/073-codex-daemon-caller-identity/000-plan.md @@ -114,3 +114,4 @@ The spike that validated the session-log mapping lives on branch - Updated 2026-09-29: daemon-mode Codex log detection reuses the thread mapper — see [002-daemon-log-detection.md](002-daemon-log-detection.md) - Updated 2026-10-08: Use the daemon binding for transcript resolution and fence selected-session changes — see [003-daemon-transcript-resolution.md](003-daemon-transcript-resolution.md). +- Updated 2026-10-18: Keep caller resolution fail-closed and use explicit source selection with step tokens for workflow recovery — see [004-explicit-workflow-source.md](004-explicit-workflow-source.md). diff --git a/docs-ai/073-codex-daemon-caller-identity/004-explicit-workflow-source.md b/docs-ai/073-codex-daemon-caller-identity/004-explicit-workflow-source.md new file mode 100644 index 00000000..30dba729 --- /dev/null +++ b/docs-ai/073-codex-daemon-caller-identity/004-explicit-workflow-source.md @@ -0,0 +1,95 @@ +# 073.004 — Explicit workflow source when caller resolution fails + +## Context + +On 2026-10-18, a live shared daemon had one vnode descriptor for which both public +vnode queries returned `0 / ENOENT`. `ProcessDetection.openFilePaths(pid:complete:)` +marked the inventory incomplete, so `CodexDaemonThreadMapper` refused all caller +bindings. Other open rollouts contained enough evidence to match the observed panes, +but that does not make a partial inventory safe. + +A private revoked-PTY experiment reproduced the error. It did not identify the live +descriptor or prove that every `ENOENT` means a revoked device. The public queries +do not return the vnode type on failure. FD numbers can be reused. Omitting the +newest thread's rollout can make the mapper bind an old thread to a pane that has +already switched threads. Keep the completeness guard. + +## Decision + +Use the existing explicit workflow source and step-token paths as a **workflow-only +fallback**. They complement caller resolution; they do not replace or repair it. + +| Boundary | Evidence and behavior | +| --- | --- | +| Start | An explicit `--pane ` selects the `current` role. Admission checks that the pane exists, has the required agent, and is not owned by another run or dispatch. | +| Caller identity | Selecting a target does not establish that the command came from that pane. Do not promote an explicit source to a verified caller. | +| First task | Without a verified caller, the run has no self-initiated handoff. A message waits for the selected pane to become idle, then Prowl types it there. If the selected pane is the agent issuing `run`, that agent must end its turn instead of polling. | +| Return routing | The run holds role-to-pane bindings. Later messages return to that bound pane, independently of focus and caller lookup. This guarantees routing to the selected pane, not discovery of the initiating agent. | +| Read and deliver | Use the exact token-prefixed commands supplied with each task. An unresolved caller can use the task token. A resolved conflicting pane cannot use it to override its identity. | +| Lifecycle | Delivery tokens name one activation. Read access is limited to the role's latest assigned invocation and its granted resources. Skip, cancellation, and revocation remove access. Normal completion can retain the last task's read access. | + +The pane must be selected independently, for example by the user in Active Agents, +or by a controller that created and retained the pane ID. Do not infer it from +inherited `PROWL_PANE_ID`, focus, cwd, or a heuristic session match in `prowl agents`. +If no reliable source is available, stop at source selection. The workflow token +arrives only after a run exists; it cannot bootstrap caller identity. + +## Change + +- Correct the bundled skills' blanket instruction to stop when caller is absent; + describe this narrow exception and the required end-of-turn handoff. +- Document explicit-source start, fixed return routing, token use, and the limits in + the CLI manual. Keep normal caller-based commands and the wire protocol unchanged. +- Add regression coverage for source selection without caller authority and stale + tokens after a role receives another task. Preserve the incomplete-inventory guard. +- Validate with two real panes on the existing failed daemon: external start and + start from the selected source itself, each with source → peer → source messages, + scoped reads, and accepted deliveries. Do not restart the daemon or the live app. + +## Alternatives + +- A new caller fallback based on detected session IDs would reuse heuristic evidence + and could assign commands to the wrong pane. It is not part of this design. +- Treating `--pane` as proof of self-identity would change an existing target selector + into an authority claim and could expose the selected role's task to another caller. +- A new workflow mode or token protocol is unnecessary: the existing engine already + supports explicit source selection and token-authorized participation. +- Profile `--no-daemon` remains useful for new sessions. It does not repair the + existing shared daemon or preserve that session's execution mode. + +## Verification + +The existing app and shared daemon were not restarted. Two manually started sessions +shared the failing daemon. Each task checked its own thread ID and the absence of +`data.caller`. The source and peer used different threads; every final message returned +to the source's original pane and thread. The observer remained in a third pane. + +| Live case | Result | +| --- | --- | +| External start (`DC569F68-772C-4631-8BFD-8895785FF46B`) | Source → peer → source; all three scoped task reads and deliveries succeeded. | +| Source-pane start (`A8DDDCBE-5F10-49D9-94F3-0D9045724840`) | `self_initiated` absent; the source ended its turn, received the first task, and completed the same three-step round trip. | +| Assigned resources (`4EA2038B-815F-4248-B4E3-9CBC3AB45A56`) | Same round trip; peer read the source's prior delivery through `resource-1`, and source read the peer's delivery through its own `resource-1`. Both verified the previous marker. | +| Stale token / wrong invocation | Scoped read and late delivery returned `TOKEN_INVALID`. | +| Unknown / omitted source | `TARGET_NOT_FOUND` / `SOURCE_REQUIRED`; neither started a run. | + +All three runs completed. Their logs record nine deliveries with `source=token`; +no manual or forced delivery was used. The fixture performs no repository work. +Local replay inputs and receipts are retained under `build/caller-debug/workflow-fallback/`. + +Automated regression coverage uses the existing source resolver, admission, run machine, +and coordinator. It checks that focus is not caller evidence, explicit selection does not +grant self-initiated authority or expose its token, returning messages target the bound +pane, and old tokens cannot read or deliver after reassignment. A valid token also cannot +override a resolved stranger pane. The five relevant Xcode test suites passed (68 tests). +`make check` and `make build-app` passed. Live acceptance exercised the existing runtime; +the rebuilt app was not installed into the running instance. + +No runtime Swift change or new protocol was needed. The implementation change is the +bundled operational guidance and its regression coverage, not a new identity algorithm. + +## Remaining limits + +The live descriptor's original type and the event that caused its failure remain unknown. +Caller-scoped signals and ordinary dispatch completion still require caller resolution. +Pane closure and agent replacement are lifecycle failures, not reasons to redirect a +workflow to a guessed pane. This change does not claim to fix all of issue #911. diff --git a/docs-ai/README.md b/docs-ai/README.md index f46711aa..444ed811 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -155,7 +155,7 @@ keep the old paths. Use this table to find a file that an old entry names: | 070 | [app-localization](070-app-localization/000-plan.md) | 2026-09-18 | Simplified Chinese UI from a String Catalog; translations sync at release time through the `sync-l10n` skill (nothing blocks everyday work); compiler-based coverage audit plus a baseline for unlocalized copy; living [glossary](070-app-localization/glossary.md); language choice stored only in the per-app `AppleLanguages` default | | 071 | [git-environment-recovery](071-git-environment-recovery/000-plan.md) | 2026-09-20 | Working Git selection, safe repository classification, recovery guidance, and plain-folder Shelf entry | | 072 | [workspace-editing](072-workspace-editing/000-plan.md) | 2026-09-25 | Edit a workspace after creation: metadata, member repositories (add, remove, reorder, roles) through one editor sheet reached from the sidebar, detail view, Settings, palette, and menu | -| 073 | [codex-daemon-caller-identity](073-codex-daemon-caller-identity/000-plan.md) | 2026-09-29 | CLI callers under Codex's shared app-server daemon: cut the ancestry at the daemon and map `CODEX_THREAD_ID` to the driving pane through the TUI session log; `prowl list` reports the caller; the same mapping restores daemon-mode Codex log detection and [transcript resolution](073-codex-daemon-caller-identity/003-daemon-transcript-resolution.md) | +| 073 | [codex-daemon-caller-identity](073-codex-daemon-caller-identity/000-plan.md) | 2026-09-29 | CLI callers under Codex's shared app-server daemon: cut the ancestry at the daemon and map `CODEX_THREAD_ID` to the driving pane through the TUI session log; `prowl list` reports the caller; daemon-mode log detection, [transcript resolution](073-codex-daemon-caller-identity/003-daemon-transcript-resolution.md), and [explicit workflow source fallback](073-codex-daemon-caller-identity/004-explicit-workflow-source.md) | | 074 | [project-restructure-and-tuist](074-project-restructure-and-tuist/000-plan.md) | 2026-10-03 | Tuist-generated macOS and iOS mirror projects (no `.xcodeproj` in Git), `supacode` → `Prowl` for targets, module, and types, and a root folder for each product (`App`, `CLI`, `Mirror`, `Shared`); no user-visible change | | 075 | [terminal-config-source](075-terminal-config-source/000-plan.md) | 2026-10-05 | Dedicated Ghostty config file for Prowl (#693) through one `GhosttyConfigSource`; CJK font fallback when the active config sets no fonts | | 076 | [devin-cli-support](076-devin-cli-support/000-plan.md) | 2026-10-06 | Process and native session detection, screen state, Profiles, and workflow integration | diff --git a/docs/components/cli.md b/docs/components/cli.md index 16272e32..02334551 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -111,12 +111,11 @@ different pane reports the pane its server started in. A value that matches no your pane (see [Transport & app launch](#transport--app-launch)). A match proves the pane exists, not that you run in it: trust the value only when your process ancestry reaches the pane's shell — under tmux/screen or a detached wrapper it names the pane the -server started in, so identify your pane by other means (`prowl agents --json` for the -pane hosting your agent session, a unique `pane.cwd`) and pass it explicitly. Keep every -step that depends on knowing yourself inside the success branch. When it is unset or -matches nothing, stop rather than guess: `pane.cwd` only narrows the candidates — several panes -usually share one cwd — and may stand in for you only when the match is unique; never -assume the *focused* pane is you. Prowl itself never trusts the variable for +server started in. For commands that accept a target, select the pane independently, +such as through a user's choice or a controller's recorded pane-creation result, and +pass it explicitly. Keep every step that depends on knowing yourself inside the success +branch. A cwd or a heuristic session match can narrow candidates but does not establish +caller identity, even if unique. Never assume the *focused* pane is you. Prowl itself never trusts the variable for attribution; commands that need the calling pane (`agents signal`) resolve it from the caller's process ancestry. @@ -130,7 +129,8 @@ and instead maps `CODEX_THREAD_ID` to the pane whose Codex TUI submitted to that `CODEX_TUI_RECORD_SESSION=1` and a pane-owned `CODEX_TUI_SESSION_LOG_PATH`. Codex writes the submitted prompts there (mode 0600); Prowl deletes the file once the pane's close can no longer be undone. A pane whose Codex has not submitted anything yet, or whose shell -overrides those variables, cannot be mapped, and caller-scoped commands fail with +overrides those variables, cannot be mapped. An incomplete daemon file inventory can also +prevent mapping of sessions that previously worked. Caller-scoped commands then fail with `SOURCE_REQUIRED`. Session logs larger than 64 MiB are not mapped. Restarting Codex in the pane resets its session log; its first indexed submit can establish the mapping again. @@ -138,8 +138,8 @@ Codex launched from an Agent Profile, or with `--no-daemon`, runs commands insid and is not affected. For Codex 0.156 and later, Prowl adds `--no-daemon` to every interactive profile launch, also when it cannot prepare its managed notifier; it skips the flag only when the login shell or `codex --version` does not answer within one second. -`prowl workflow read` and `deliver` accept the step token in place of a mapped pane (see -[Workflows](workflows.md)). +`prowl workflow read` and `deliver` accept the step token in place of a mapped pane; +an explicit source can start the run (see [workflow fallback](#workflow-fallback-when-caller-identity-is-unavailable)). ## Commands @@ -591,7 +591,10 @@ prowl workflow schema [--action] [--json] # workflow or execution root is closed, moved, or deleted. See [history retention](workflows.md). - `read [resource-id] --run --invocation ` retrieves the assigned instruction (default) or a resource ID returned with it. Prowl checks the caller pane, - run, and invocation against the current task assignment. Reads need no token. + run, and invocation against the current task assignment. A resolved assigned pane needs + no token. When caller resolution is unavailable, use the supplied `PROWL_WORKFLOW_TOKEN=` + prefix (or `--token`) with the exact run and invocation. A token cannot override a + resolved conflicting pane. Normal completion keeps the last task readable until reassignment, history cleanup, or app exit; cancellation, Skip, and activation revocation revoke access. `--json` returns `body`, `encoding` (`utf-8` or `base64`), `resources`, `invocation`, `offset`, `next_offset`, and `total_bytes`. @@ -680,6 +683,37 @@ as a workflow run. Poll the returned run ID with `workflow status` and inspect i `validate` accepts the bundle directory, not its `workflow.yaml`. Loose YAML files are not workflow bundles. See [Workflows](workflows.md#script-actions-and-bundles) for approval and results. +### Workflow fallback when caller identity is unavailable + +An absent `.data.caller` does not prevent an explicitly targeted workflow. Select the +source independently, such as through the user's pane choice or a controller's recorded +`create tab` result, then pass its UUID: + +```bash +prowl workflow run prowl.review-loop --pane --role reviewer="Pi Reviewer" --json +``` + +Do not infer the source from inherited `PROWL_PANE_ID`, the focused pane, cwd, or a +heuristic session match in `prowl agents`. If the source is not known, select it first; +the step token cannot identify an initiating pane before a run exists. + +Check `.data.bindings` and `.data.self_initiated` in the response. If `self_initiated` +is present, follow its task immediately. Otherwise Prowl delivers the first message +when its target becomes idle. **If the selected source is your own pane, report the +run ID and end your turn.** Polling or waiting in that turn can prevent the first +task from arriving. An external observer can use `workflow status `. + +Use the exact token-prefixed `workflow read` and `workflow deliver` commands in each +assigned task. They work when the caller is unresolved; `Delivered` (JSON +`delivery.state == "delivered"`) confirms acceptance. Tokens do not authorize a +different task or override a known conflicting caller. Do not substitute manual +`--run --step --force` delivery for this protocol. + +Later messages return to the fixed role-to-pane bindings, not to the focused pane. +This route preserves delivery to the selected source while it remains available; +it does not prove that source was the invoking agent or repair `.data.caller`. +Caller-scoped signals and ordinary dispatch completion still need resolved identity. + ### Built-in Review Loop workflow ```bash diff --git a/docs/components/workflows.md b/docs/components/workflows.md index fdffe5bb..341e404d 100644 --- a/docs/components/workflows.md +++ b/docs/components/workflows.md @@ -93,6 +93,12 @@ rounds. The initiating agent follows the returned `self_initiated.line` to deliv its scope, plan, and verification brief. Local changes and existing PRs are both supported. `focus` adds an optional single-line review instruction. +If caller identity is unavailable, an independently selected `--pane ` can +serve as the source. There is no `self_initiated` task in that case: if you selected +your own pane, end your turn so Prowl can deliver the first message. Follow the +task's token-prefixed read and delivery commands. Later messages return to that +same bound pane. See [the CLI fallback procedure](cli.md#workflow-fallback-when-caller-identity-is-unavailable). + Each round includes a reviewer report and main's disposition. Review focuses on material correctness, plan gaps, architecture, and realistic UX regressions. Every report records which files or areas the reviewer inspected. From round 2 on, the diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index d3e37f97..d230093f 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -56,7 +56,16 @@ fi The variable is inherited, not verified: it is missing after `sudo`/`ssh`/containers and can name the wrong pane inside a tmux/screen session attached from elsewhere. A set value that matches no `pane.id` usually means `prowl` is talking to a different Prowl instance than the one hosting your pane (two apps running; see `PROWL_CLI_SOCKET` under Pitfalls). A match only proves that pane exists, not that you are running in it: trust the value only when your process ancestry reaches the pane's shell (no tmux/screen server or detached wrapper in between); under tmux/screen or a detached wrapper, identify your pane by other means — `prowl agents --json` for the pane hosting your own agent session, or a unique `pane.cwd` — and pass it explicitly. If it is unset or matches nothing, stop rather than guess: `pane.cwd` only narrows the candidates — several panes usually share one cwd — and may stand in for you only when the match is unique. Never assume the focused pane is you — `open` and `focus` move focus, and the user may be looking anywhere. -Inside Codex, do not trust `PROWL_PANE_ID`. Codex 0.157+ runs the commands of a `codex` typed by hand in a shared background daemon that keeps the environment of whichever terminal last started it, so `PROWL_PANE_ID`, `PROWL_WORKTREE_PATH`, and `PROWL_ROOT_PATH` there can name another live pane or a closed one. `prowl list --json` reports `.data.caller` — the pane Prowl resolved from your `CODEX_THREAD_ID` — and omits it when your pane cannot be mapped yet (for example before your first message in that Codex session): then stop. Caller-scoped commands (`agents signal`, `agents dispatch-complete`, `workflow deliver`) resolve the same way on the server, so they need nothing from you. +Inside Codex, do not trust `PROWL_PANE_ID`. Codex 0.157+ runs the commands of a `codex` typed by hand in a shared background daemon that keeps the environment of whichever terminal last started it, so `PROWL_PANE_ID`, `PROWL_WORKTREE_PATH`, and `PROWL_ROOT_PATH` there can name another live pane or a closed one. `prowl list --json` reports `.data.caller` — the pane Prowl resolved from your `CODEX_THREAD_ID` — and omits it when the mapping is unavailable. Do not guess your pane from focus, cwd, or a detected session entry. Caller-scoped commands such as `agents signal` and `agents dispatch-complete` resolve identity on the server; stop if that identity is unavailable. + +Workflows have a separate, limited fallback: start with `workflow run --pane ` +only when that source was independently selected (for example by the user or by a controller +that created the pane). Later messages return to the run's fixed role bindings. This does +not establish caller identity. Inspect `data.self_initiated`: follow it if present; if absent +and the selected pane is yours, **end your turn** so Prowl can message the idle pane. Do not +poll or hold that turn open. Use each task's exact token-prefixed `workflow read` and +`workflow deliver` commands; they work with an unresolved caller. Use `workflow status ` +for inspection from another pane. See the `prowl-workflow` skill for the full protocol. ## Safe Default Workflow @@ -165,13 +174,14 @@ run="$(prowl workflow run review --role reviewer=Codex --input max_rounds=3 --js printf '%s\n' "$run" | jq -r '.data.id, .data.status.state, .data.self_initiated.line' ``` -A workflow with a `current` role runs from the pane you call it in (that pane becomes the -role; `SOURCE_REQUIRED` outside a pane, `PANE_BUSY` when it already belongs to a run). Launch +A workflow with a `current` role uses the resolved caller or an explicitly selected source +pane (`SOURCE_REQUIRED` without either, `PANE_BUSY` when it already belongs to a run). Launch roles freeze a Profile before anything happens: `--role =`, else the remembered, suggested, or Recommended one (`PROFILE_NOT_FOUND` when nothing qualifies); -`pick` roles need `--role =pN`. When the first step messages your own pane, nothing is -typed: `.data.self_initiated.line` is the instruction to follow now, and its completion command -delivers your output. Every step Prowl types into a pane ends with the exact command that +`pick` roles need `--role =pN`. When `.data.self_initiated.line` is present, nothing is +typed: it is the instruction to follow now, and its completion command delivers your output. +Without it, a selected source that is your own pane must become idle before receiving a +message: end your turn. Every step Prowl types into a pane ends with the exact command that completes it — run it with the output on stdin when your work for that step is fully done: ```bash diff --git a/skills/prowl-workflow/SKILL.md b/skills/prowl-workflow/SKILL.md index a7abf410..5b99cb09 100644 --- a/skills/prowl-workflow/SKILL.md +++ b/skills/prowl-workflow/SKILL.md @@ -68,7 +68,7 @@ saved packet and continues the task. A completed run confirms save/launch, not t ## Built-in Review Loop Use `prowl workflow run prowl.review-loop --role reviewer= --json` from the -implementing agent. Follow the returned `self_initiated.line` to deliver the scope, +implementing agent. When present, follow the returned `self_initiated.line` to deliver the scope, plan, and verification brief. The reviewer opens in a right split in the same tab. Optional inputs: `min_rounds=2`, `max_rounds=4` (each 1–30, minimum must not exceed maximum), and `focus=`. Any qualifying Profile can review. @@ -104,6 +104,15 @@ prowl workflow cancel [--json] `self_initiated` (`.data.self_initiated` with `--json`). If present, follow its `line` or `prompt_path` and completion command yourself; Prowl does not type that first task back into the same pane. Waiting for another message would leave your own step unfinished. +- If caller resolution is unavailable, pass `--pane ` only for an independently + selected source (the user chose it, or a controller created and retained its ID). Do not + infer identity from `PROWL_PANE_ID`, focus, cwd, or `prowl agents` session matching. + Explicit source selection fixes where the `current` role receives messages; it does not + prove who issued `run`. Without a verified caller, `self_initiated` is absent and Prowl + waits for the selected pane to become idle. If that pane is yours, **end your turn after + reporting the run ID**; do not poll or wait in a tool. Process the next `[Prowl]` task + with its exact token-prefixed read and delivery commands. This fallback complements + caller resolution and does not enable caller-scoped signals or ordinary dispatch receipts. - The run is asynchronous: `run` returns the run id and frozen bindings; poll `prowl workflow status --json` (`.data.status.state` is `running`, `needs_attention`, or a terminal state; `.data.finished_at` appears when it ended) or read @@ -136,8 +145,11 @@ looks like this: output file exists. A provisional delivery waits for the user's decision; **Ask again** reopens delivery so you can correct it. Do not blindly resubmit or invent a replacement token; [runbook](references/runbook.md) explains the states. -4. Lost? `prowl workflow status` (no arguments) answers "who am I": this pane's run, role, - awaited step, its requirements and completion command. +4. Lost? With a resolved caller, `prowl workflow status` (no arguments) answers "who am I": + this pane's run, role, awaited step, its requirements and completion command. Without + caller identity, use `status ` to inspect progress; it does not reveal task tokens. + Keep the token-prefixed commands from the assigned task. An observer can poll progress; + a source waiting for its first task must end its turn as described above. 5. Never use `prowl agents dispatch-complete` for a workflow activation — it is rejected with `WORKFLOW_DELIVERY_REQUIRED` naming the correct command. 6. A launched participant finds the same contract in its kickoff prompt ("Prowl workflow diff --git a/skills/prowl-workflow/references/runbook.md b/skills/prowl-workflow/references/runbook.md index f64ab5a9..49e302aa 100644 --- a/skills/prowl-workflow/references/runbook.md +++ b/skills/prowl-workflow/references/runbook.md @@ -29,6 +29,13 @@ response returns the task directly in `self_initiated` (`.data.self_initiated` i `line`, optional `prompt_path`, and `completion`. The agent that invoked `run` must perform it and deliver; there is no separate injected first message to wait for. +If caller resolution fails, an independently selected `--pane ` still binds the +`current` role. It does not establish caller identity, so no `self_initiated` task is +returned. If the selected pane is the invoking agent's own pane, that agent must end +its turn after `run` returns. Prowl then messages the idle pane. Do not keep the turn +busy by polling; an observer can inspect `status ` from another pane. Subsequent +messages follow the frozen bindings even if focus changes. + ## While it runs - Steps execute strictly in order; one step is active at a time. A `message` step injects @@ -129,15 +136,18 @@ Every awaited step mints a fresh token for its activation; Skip/Cancel/Relaunch the kickoff prompt's protocol block spells the bare `prowl workflow deliver [--verdict v] -`. - **Messaged panes** (`current`/`pick`): the token rides the typed line as an environment prefix — the command in the `[Prowl] …` line is complete and directly executable. -- Delivery requires the caller pane and the token to agree; a stale, duplicated, or - token-less delivery is rejected instead of misattributed. +- With a resolved caller, delivery requires its pane and token to agree. Without one, + the supplied step token identifies the activation for delivery and authorizes scoped + reads. It never overrides a resolved conflicting pane. A stale, duplicated, or + token-less delivery is rejected instead of misattributed. Explicit `status ` + without caller identity does not reveal completion tokens. ## Common errors | Error | Meaning / fix | | --- | --- | | `WORKFLOW_NOT_FOUND` / `WORKFLOW_INVALID` | wrong id, or the file fails validation — run `prowl workflow validate` on it | -| `SOURCE_REQUIRED` | the workflow has a `current` role and the call wasn't made from a pane — pass a source | +| `SOURCE_REQUIRED` | Prowl could not resolve the caller: for `run`, pass an independently selected source pane; for `read`/`deliver`, use the assigned task's token; for `status`, pass the run ID. Do not infer a pane from focus or inherited environment variables. | | `INVALID_ARGUMENT` | bad `--input` value, unknown/duplicate `--role`, or a `--skip` on a step another step depends on (the message names it) | | `PANE_BUSY` | the pane chosen for a `pick` role already belongs to a run | | `DISPATCH_PENDING` | a `pick` pane still holds a pending `prowl agents dispatch`; complete or abandon it first | -- 2.54.0 From 8a15bff4304d2c69d5b3c5a41cdaf9f034b91d54 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 10:24:11 +0900 Subject: [PATCH 02/51] Remove upstream sync tooling and instructions The fork no longer tracks supabitapp/supacode. Remove the upstream review skill, the sync script, the upstream PR hook, and the upstream steps in AGENTS.md and the release runbook. Mark entry 017 and its ledger as retired history. --- .claude/hooks/block-upstream-pr.sh | 23 -------- .claude/settings.json | 15 ----- .../skills/check-upstream-changes/SKILL.md | 48 ---------------- .claude/skills/write-ai-doc/SKILL.md | 8 +-- AGENTS.md | 4 +- .../001-action.md | 11 ++-- .../release-runbook.md | 38 ++----------- docs-ai/017-upstream-sync-process/000-plan.md | 2 +- .../017-upstream-sync-process/001-action.md | 17 +++--- .../upstream-ledger.md | 2 +- docs-ai/README.md | 5 +- scripts/check_legacy_naming.py | 2 +- scripts/sync-upstream-main.sh | 56 ------------------- 13 files changed, 28 insertions(+), 203 deletions(-) delete mode 100755 .claude/hooks/block-upstream-pr.sh delete mode 100644 .claude/settings.json delete mode 100644 .claude/skills/check-upstream-changes/SKILL.md delete mode 100755 scripts/sync-upstream-main.sh diff --git a/.claude/hooks/block-upstream-pr.sh b/.claude/hooks/block-upstream-pr.sh deleted file mode 100755 index b31e3d7b..00000000 --- a/.claude/hooks/block-upstream-pr.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/bash -# PreToolUse hook: block gh pr create unless it explicitly targets the fork. -# Exit 2 = block the command, exit 0 = allow. - -INPUT=$(cat) -COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') - -# Only inspect gh pr create commands -if echo "$COMMAND" | grep -qE 'gh\s+pr\s+create'; then - # Allow if explicitly targeting the fork (onevcat/Prowl) - if echo "$COMMAND" | grep -qE '(--repo|--repo=|-R)\s*onevcat/Prowl'; then - exit 0 - fi - cat <&2 -BLOCKED: gh pr create must explicitly target the fork. - -Use: gh pr create --repo onevcat/Prowl ... -Never target upstream (supabitapp/supacode). -EOF - exit 2 -fi - -exit 0 diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 389e25e1..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "hooks": { - "PreToolUse": [ - { - "matcher": "Bash", - "hooks": [ - { - "type": "command", - "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-upstream-pr.sh" - } - ] - } - ] - } -} diff --git a/.claude/skills/check-upstream-changes/SKILL.md b/.claude/skills/check-upstream-changes/SKILL.md deleted file mode 100644 index ae265576..00000000 --- a/.claude/skills/check-upstream-changes/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: check-upstream-changes -description: Check upstream (supabitapp/supacode) for new changes since the last reviewed baseline. ---- - -# Check Upstream Changes - -Check upstream (supabitapp/supacode) for new changes since the last reviewed baseline. - -Follow these steps: - -1. Read `docs-ai/017-upstream-sync-process/upstream-ledger.md` and extract the **Upstream Baseline** commit hash and date. -2. Fetch the upstream remote: - ```bash - git fetch upstream main --quiet - ``` -3. List all commits on `upstream/main` that are newer than the baseline commit: - ```bash - git log --oneline ..upstream/main - ``` - If there are no new commits, report "No new upstream changes since ()." and stop. -4. For each new commit (or group of related commits), produce a one-line summary including: - - Commit hash (short) - - PR number if visible in the commit message - - Brief description of the change - - Whether it might conflict with or overlap existing fork customizations (check `docs-ai/017-upstream-sync-process/upstream-ledger.md` Old Log for context) -5. Categorize commits into: - - **Needs attention** — changes that may conflict with fork patches or require manual review - - **Safe to merge** — additive features, docs, version bumps, or fixes with no fork overlap -6. Present the briefing in this format: - - ``` - ## Upstream Changes Briefing - Baseline: () - Latest upstream: () - New commits: - - ### Needs Attention - - `` — - - ### Safe to Merge - - `` - - ### Recommended Next Step - - ``` - -7. Do NOT modify any files or run sync. This command is read-only reconnaissance. diff --git a/.claude/skills/write-ai-doc/SKILL.md b/.claude/skills/write-ai-doc/SKILL.md index ec1cb82b..9db3faf8 100644 --- a/.claude/skills/write-ai-doc/SKILL.md +++ b/.claude/skills/write-ai-doc/SKILL.md @@ -22,8 +22,7 @@ Create a new entry only when the work is either: Do **not** create an entry merely because the work is detailed, takes time, or produces useful findings. Skip reviews, code or post-release audits, routine research/investigations and -debugging, status reports, test runs, pure formatting or dependency bumps, routine upstream -ports already recorded in the upstream ledger (`docs-ai/017-upstream-sync-process/upstream-ledger.md`), and docs-only changes. A user may explicitly request a record for an otherwise +debugging, status reports, test runs, pure formatting or dependency bumps, and docs-only changes. A user may explicitly request a record for an otherwise non-qualifying task. When in doubt, do not write one. ## Workflow @@ -123,12 +122,13 @@ What was done. | ## Refs: PR #x | ## Current state (optional) 40–120 lines, actions 30–100 — long enough to be useful, short enough to be read. - Every repo-relative file path you write must exist (verify with Glob/Grep before writing). Facts you can't verify belong under **Open questions**, not in prose. -- Reference fork PRs as `#123`, upstream PRs as `upstream #123`, files as inline code. +- Reference fork PRs as `#123`, files as inline code. Older entries cite `upstream #123` for + `supabitapp/supacode` PRs. Cross-link sibling entries with relative links. - `docs-ai/` is the curated home for fork history plus fork-internal product, design, and operational docs. It is not a task journal. Numbered files are immutable history; **non-numbered** files inside an entry folder - (e.g. `001-.../release-runbook.md`, `017-.../upstream-ledger.md`, + (e.g. `001-.../release-runbook.md`, `013-prowl-cli/contracts/`, `020-observability/runbook.md`) are living documents — update them in place when the process/contract they describe changes, and link them instead of duplicating their content. diff --git a/AGENTS.md b/AGENTS.md index 3fb42fe8..3fa9708b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -This fork is primarily for onevcat-specific customizations; before doing any release work, read `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md` (and the upstream ledger `docs-ai/017-upstream-sync-process/upstream-ledger.md`) for fork publishing guidance. Fork history and design decisions are recorded under `docs-ai/` (see `docs-ai/README.md`). +This fork is primarily for onevcat-specific customizations; before doing any release work, read `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md` for fork publishing guidance. Fork history and design decisions are recorded under `docs-ai/` (see `docs-ai/README.md`). ## Build Commands @@ -175,7 +175,7 @@ Reducer ← .terminalEvent(Event) ← AsyncStream - Automatically commit your changes and your changes only. Do not use `git add .` - Before you go on your task, check the current git branch name, if it's something generic like an animal name, name it accordingly. Do not do this for main branch - After implementing an execplan, always submit a PR if you're not in the main branch -- PRs must target `onevcat/Prowl` (this fork), never the upstream `supabitapp/supacode`, unless explicitly requested. +- PRs must target `onevcat/Prowl`. - Fork releases must be notarized. Never publish non-notarized releases (`ENABLE_NOTARIZATION=0` is forbidden). ## Submodules diff --git a/docs-ai/001-fork-bootstrap-and-release-pipeline/001-action.md b/docs-ai/001-fork-bootstrap-and-release-pipeline/001-action.md index 165cbd70..d28447da 100644 --- a/docs-ai/001-fork-bootstrap-and-release-pipeline/001-action.md +++ b/docs-ai/001-fork-bootstrap-and-release-pipeline/001-action.md @@ -19,6 +19,7 @@ | 2026-03-26 | Homebrew cask automation from published releases | #73, #74, #75 — see [002](002-homebrew-cask-automation.md) | | 2026-03-27 | Hardened `install-dev-build`/`install-release`: `set -euo pipefail`, strict destination guards, `trash` instead of `rm -rf` | #78 | | 2026-04-05 | Sparkle appcast served from GitHub Releases; Prowl-Site appcast push removed | #154 — see [003](003-appcast-from-github-releases.md) | +| 2026-10-18 | Upstream sync retired: `upstream` remote, `sync-upstream-main.sh`, and the runbook's sync sections removed — see [017](../017-upstream-sync-process/001-action.md) | this PR | ## Outcome & current state (as of 2026-07-12) @@ -32,7 +33,7 @@ - `scripts/release-notes.sh` — gathers commits + PR descriptions since the previous tag and generates notes via the `claude` CLI, falling back to GitHub auto-notes; enforces `### New/Fixed/Improved` headings. -- `scripts/sync-upstream-main.sh` — sync automation as designed. +- `scripts/sync-upstream-main.sh` was removed on 2026-10-18, when upstream sync was retired. `release-to-fork.sh` (deprecated legacy pipeline) was removed in the 2026-07 docs-ai migration; see git history. - `supacode/Info.plist` — `SUFeedURL` = `https://github.com/onevcat/Prowl/releases/latest/download/appcast.xml`, @@ -49,9 +50,8 @@ ("Tip channel is no longer published separately"); `UpdateChannel` in `supacode/Features/Settings/Models/UpdateChannel.swift` still declares `case tip`. - The `/sync-upstream` command no longer exists: it was removed in the commands→skills - migration (`01d4e04f`, 2026-04-08). Upstream review is handled by the - `.claude/skills/check-upstream-changes` skill; the merge runbook stays in - `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md`. + migration (`01d4e04f`, 2026-04-08). Since 2026-10-18 the fork no longer syncs from + upstream: the `check-upstream-changes` skill and the runbook's merge steps are removed. - The pipeline's shape later constrained other decisions: the 2026-04-20 upstream review (`docs-ai/017-upstream-sync-process/upstream-ledger.md`) skipped upstream's Tuist migration partly because it would force rewriting the `/release` skill, notarization flow, and appcast generation. @@ -68,9 +68,6 @@ ## Open questions -- `docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md` still says "use the `/sync-upstream` command", - but that command was removed in `01d4e04f` (2026-04-08). The living doc looks stale on - this point (left untouched by this backfill). - The `Makefile` `bump-and-release` target (inherited from upstream, `e30b747b`) pushes a tag and creates a GitHub Release with generated notes but no notarized artifacts — it bypasses `release.sh` entirely, conflicts with the notarized-only policy, and its diff --git a/docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md b/docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md index d46d3f45..372377f4 100644 --- a/docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md +++ b/docs-ai/001-fork-bootstrap-and-release-pipeline/release-runbook.md @@ -1,10 +1,10 @@ -# Fork Sync and Public Release Workflow +# Public Release Workflow > Living document of entry 001. Migrated from `doc-onevcat/fork-sync-and-release.md` on 2026-07-12; update in place. ## Goal -Keep `onevcat/Prowl` close to `supabitapp/supacode` while preserving local customizations, and publish public releases with Sparkle auto-update support. +Publish public releases of `onevcat/Prowl` with Sparkle auto-update support. The repository no longer syncs from `supabitapp/supacode`; see entry 017. ## Release Infrastructure @@ -34,36 +34,9 @@ the SwiftPM executable and its matching dSYM remain available for symbolication. ## Branch Strategy -- `upstream/main`: source of truth (read-only remote branch) -- `main` (origin): integration branch with custom patches +- `main` (origin): integration branch - Feature branches as needed -## One-Time Setup - -```bash -git fetch origin --prune -git fetch upstream --prune -git config rerere.enabled true -git config rerere.autoupdate true -``` - -## Upstream Sync Runbook - -```bash -git switch main -git fetch origin --prune -git fetch upstream --prune -git merge --ff-only origin/main -git merge --no-ff upstream/main -make build-app -make test -git push origin main -``` - -Or use the `/sync-upstream` command which automates these steps. - -If conflicts happen, resolve once, commit, and `rerere` will likely auto-apply next time. - ## Ghostty Submodule Sync Prowl carries a small Ghostty fork patch for embedded APIs. Before changing or upgrading `ThirdParty/ghostty`, read @@ -235,13 +208,10 @@ SKIP_SENTRY=1 ./scripts/release.sh ## Helper Scripts -- `scripts/sync-upstream-main.sh` — upstream sync automation - `scripts/release.sh` — full public release pipeline - `release-to-fork.sh` — (deprecated) legacy personal release script; removed in the 2026-07 docs-ai migration ## Common Pitfalls -- `git fetch origin upstream --prune` is invalid. Use two separate fetch commands. -- Prefer `git merge --ff-only origin/main` in scripted sync flow. -- Keep working tree clean before sync or release. +- Keep the working tree clean before a release. - Fork releases must be notarized. Non-notarized publishing is forbidden. diff --git a/docs-ai/017-upstream-sync-process/000-plan.md b/docs-ai/017-upstream-sync-process/000-plan.md index cbd14102..89ffe631 100644 --- a/docs-ai/017-upstream-sync-process/000-plan.md +++ b/docs-ai/017-upstream-sync-process/000-plan.md @@ -2,7 +2,7 @@ | | | | --- | --- | -| **Status** | Implemented (retrospective) | +| **Status** | Retired (2026-10-18) | | **Anchor date** | 2026-04-08 | | **Documented** | 2026-07-12 (backfilled) | | **Primary PRs** | #193 (process establishment); review rounds landed as #268, #547, #549 and direct docs commits `013be91e`, `2948302e` | diff --git a/docs-ai/017-upstream-sync-process/001-action.md b/docs-ai/017-upstream-sync-process/001-action.md index 29e4b518..51cb49d2 100644 --- a/docs-ai/017-upstream-sync-process/001-action.md +++ b/docs-ai/017-upstream-sync-process/001-action.md @@ -16,19 +16,20 @@ this log records the rounds themselves and their headline decisions. | 2026-06-09 | **Round 4** — 55 commits through `1d888dbc` (post-v0.10.2, upstream v0.9.0→v0.10.2). Ports as fork PRs #414–#425 (perf wave #414–#417, gh JSON noise #418, merge-queue state #425, worktree name/parent override #424, …). Headline skips: the whole **zmx terminal-persistence track** (fork keeps its own layout persistence, see [014](../014-terminal-layout-persistence/000-plan.md)) and **hook-driven agent integrations** (upstream settings/hook modules absent in fork). Entry adds two new ledger sections: "Not yet ported — re-evaluate next round" and a full upstream commit inventory | commit `2948302e` | | 2026-07-06 – 07-09 | **Round 5** — 70 commits through `bcbc4059` (post-v0.10.5, upstream v0.10.3→v0.10.5). First plan-doc-first round: per-commit verdicts written up before porting (PR #547, kept verbatim as [batch-2026-07-06-post-v0.10.5.md](batch-2026-07-06-post-v0.10.5.md)); ports #541–#546; **remote SSH track deferred → Linear CLAW-98**, **searchable base-ref filter deferred → Linear CLAW-99**; baseline advanced after the port PRs merged | PRs #547, #541–#546, #549 (`2197d13e`) — see [002](002-plan-doc-batch-workflow.md) | | 2026-07-12 | `/check-upstream-changes` retargeted to read the ledger at `docs-ai/017-upstream-sync-process/upstream-ledger.md` as part of the docs-ai migration | branch `docs/ai-docs-backfill` | +| 2026-10-18 | **Process retired**: the fork no longer tracks upstream. Removed the `upstream` remote, `/check-upstream-changes`, `scripts/sync-upstream-main.sh`, the upstream-PR hook, and the upstream steps in the release runbook and `AGENTS.md`. The ledger stays as history | this PR | -## Outcome & current state (as of 2026-07-12) +## Outcome & current state (as of 2026-10-18) -- Current baseline: `bcbc4059` (post-v0.10.5, 2026-07-06), recorded in the ledger's +- The process is retired. The fork no longer tracks `supabitapp/supacode` and has no + `upstream` remote. Do not run new review rounds or update the ledger. +- Last baseline: `bcbc4059` (post-v0.10.5, 2026-07-06), recorded in the ledger's Upstream Baseline table. -- The ledger is [upstream-ledger.md](upstream-ledger.md) in this folder (living, +- The ledger is [upstream-ledger.md](upstream-ledger.md) in this folder (history only, non-numbered; migrating from the fork's old change-log location in the docs-ai migration). Its "Old Log" tail preserves the retired per-commit table. -- `.claude/skills/check-upstream-changes/SKILL.md` exists and already reads the - baseline from the `docs-ai/017-upstream-sync-process/upstream-ledger.md` path. -- The `upstream` remote points at `supabitapp/supacode`; no upstream merge commit - exists after `db6d189b` (2026-03-23) — all later upstream adoption is via fork PRs - listed in the ledger's port tables. +- `.claude/skills/check-upstream-changes/` and `scripts/sync-upstream-main.sh` are removed. +- No upstream merge commit exists after `db6d189b` (2026-03-23) — all later upstream + adoption is via fork PRs listed in the ledger's port tables. - Five review rounds are on record: 2026-04-08 (v0.8.0), 2026-04-20 (v0.8.1), 2026-05-08 (post-v0.8.5), 2026-06-09 (post-v0.10.2), 2026-07-09 (post-v0.10.5). diff --git a/docs-ai/017-upstream-sync-process/upstream-ledger.md b/docs-ai/017-upstream-sync-process/upstream-ledger.md index cf62c91c..ef6ef824 100644 --- a/docs-ai/017-upstream-sync-process/upstream-ledger.md +++ b/docs-ai/017-upstream-sync-process/upstream-ledger.md @@ -1,6 +1,6 @@ # Fork Change Log -> Living document of entry 017. Migrated from `doc-onevcat/change-list.md` on 2026-07-12; update in place. +> Retired on 2026-10-18. The repository no longer tracks `supabitapp/supacode`, so this ledger is history only; do not update it. ## Upstream Baseline diff --git a/docs-ai/README.md b/docs-ai/README.md index f46711aa..1fa3c459 100644 --- a/docs-ai/README.md +++ b/docs-ai/README.md @@ -35,11 +35,10 @@ each is marked as such. Living documents hosted here: -- `001-fork-bootstrap-and-release-pipeline/release-runbook.md` — fork sync & release runbook +- `001-fork-bootstrap-and-release-pipeline/release-runbook.md` — release runbook - `007-ghostty-embedding-integration/ghostty-fork-sync.md` — Ghostty fork/submodule upgrade runbook - `012-keybinding-system/architecture.md` — keybinding system reference - `013-prowl-cli/contracts/` — normative CLI contracts -- `017-upstream-sync-process/upstream-ledger.md` — upstream review ledger (baseline + decisions) - `020-observability/runbook.md` — observability/diagnostics runbook - `063-agent-workflows/dsl-spec.md` — Agent Workflow DSL (`prowl.workflow/v1`) normative spec - `063-agent-workflows/release-plan.md` — release/order plan shared by 063 and 064 @@ -99,7 +98,7 @@ keep the old paths. Use this table to find a file that an old entry names: | 014 | [terminal-layout-persistence](014-terminal-layout-persistence/000-plan.md) | 2026-03-31 | Layout snapshot save/restore; font-size persistence; launch races | | 015 | [repositories-feature-refactor](015-repositories-feature-refactor/000-plan.md) | 2026-04-03 | TCA decomposition of RepositoriesFeature and later code-health splits | | 016 | [dev-build-and-ci-workflow](016-dev-build-and-ci-workflow/000-plan.md) | 2026-04-04 | Build/test tooling, CI parallelism, Debug identity, incremental caching, Shared module boundary, CAS fallback and cache save scope, test hang watchdog | -| 017 | [upstream-sync-process](017-upstream-sync-process/000-plan.md) | 2026-04-08 | Upstream review discipline, baselines, batch decisions | +| 017 | [upstream-sync-process](017-upstream-sync-process/000-plan.md) | 2026-04-08 | Upstream review discipline, baselines, batch decisions (retired 2026-10-18) | | 018 | [archived-worktrees](018-archived-worktrees/000-plan.md) | 2026-04-09 | Archived worktree discoverability and auto-delete | | 019 | [worktree-creation-and-lifecycle](019-worktree-creation-and-lifecycle/000-plan.md) | 2026-04-12 | Creation/merge flows, safe deletion, Add-to-Prowl redesign | | 020 | [observability](020-observability/000-plan.md) | 2026-04-18 | Sentry + PostHog wiring; the App-Hang enable→tune→remove arc | diff --git a/scripts/check_legacy_naming.py b/scripts/check_legacy_naming.py index 4533b18d..c0b0bc0f 100755 --- a/scripts/check_legacy_naming.py +++ b/scripts/check_legacy_naming.py @@ -24,7 +24,7 @@ ALLOWED = ( r"`supacode(?:/\.\.\.)?`", # the bare old name in prose that explains this rule ) # History and third-party content is not maintained text. -EXCLUDED_PREFIXES = ("docs-ai/", "ThirdParty/", ".claude/skills/check-upstream-changes/") +EXCLUDED_PREFIXES = ("docs-ai/", "ThirdParty/") EXCLUDED_FILES = ("CHANGELOG.md", "scripts/check_legacy_naming.py", "scripts/test_legacy_naming.py") TEXT_SUFFIXES = { ".swift", ".md", ".json", ".yaml", ".yml", ".py", ".sh", ".awk", ".toml", ".xcconfig", ".plist", diff --git a/scripts/sync-upstream-main.sh b/scripts/sync-upstream-main.sh deleted file mode 100755 index ada15be4..00000000 --- a/scripts/sync-upstream-main.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -TARGET_BRANCH="${1:-main}" - -die() { - echo "[error] $*" >&2 - exit 1 -} - -ensure_remote_exists() { - local remote_name="$1" - git remote get-url "${remote_name}" >/dev/null 2>&1 || die "missing git remote: ${remote_name}" -} - -ensure_clean_worktree() { - if ! git diff --quiet || ! git diff --cached --quiet; then - die "working tree is not clean. commit or stash your changes before syncing." - fi -} - -ensure_remote_branch_exists() { - local remote_name="$1" - local branch_name="$2" - git show-ref --verify --quiet "refs/remotes/${remote_name}/${branch_name}" || \ - die "remote branch not found: ${remote_name}/${branch_name}" -} - -echo "[sync] preflight checks" -ensure_clean_worktree -ensure_remote_exists origin -ensure_remote_exists upstream - -echo "[sync] fetch remotes" -git fetch origin --prune -git fetch upstream --prune -ensure_remote_branch_exists origin "${TARGET_BRANCH}" -ensure_remote_branch_exists upstream "main" - -echo "[sync] switch to ${TARGET_BRANCH}" -git switch "${TARGET_BRANCH}" - -echo "[sync] fast-forward from origin/${TARGET_BRANCH} (deterministic merge-based flow)" -git merge --ff-only "origin/${TARGET_BRANCH}" - -echo "[sync] merge upstream/main into ${TARGET_BRANCH}" -git merge --no-ff upstream/main - -echo "[sync] verify build" -make build-app - -echo -echo "[done] upstream merged into ${TARGET_BRANCH}" -echo "Next recommended steps:" -echo " 1) make test" -echo " 2) git push origin ${TARGET_BRANCH}" -- 2.54.0 From b2dcce352934f9f1d634b53c15684706043bbd4d Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 17 Oct 2026 21:51:31 +0900 Subject: [PATCH 03/51] Add the remote probe, connection owner and command contracts Implement docs-ai 082 C1/C3/C4/C5/C6 without wiring them into the app: a POSIX sh probe and its framed-record parser, the per-host connection owner with coalesced scheduling, backoff and side-channel overrides, a posix_spawn runner with deadline and output cap, the snapshot freshness and presence rule, structured remote commands, and host profiles. --- .../RemoteHost/RemoteCommandRunner.swift | 211 ++++++++++++++ .../RemoteHost/RemoteConnectionOwner.swift | 220 ++++++++++++++ .../RemoteHost/RemoteConnectionRegistry.swift | 44 +++ .../RemoteHost/RemoteProbeParser.swift | 183 ++++++++++++ .../RemoteHost/RemoteProbeReport.swift | 64 ++++ .../RemoteHost/RemoteProbeScript.swift | 168 +++++++++++ .../RemoteHost/RemoteProbeSnapshot.swift | 102 +++++++ .../RemoteHost/RemoteShellCommand.swift | 70 +++++ .../RemoteHost/SSHConnectionRecipe.swift | 109 +++++++ App/Tests/RemoteCommandRunnerTests.swift | 71 +++++ App/Tests/RemoteConnectionLiveTests.swift | 42 +++ App/Tests/RemoteConnectionOwnerTests.swift | 275 ++++++++++++++++++ App/Tests/RemoteProbeParserTests.swift | 167 +++++++++++ App/Tests/RemoteShellCommandTests.swift | 69 +++++ docs-ai/082-ssh-remote-agents/001-action.md | 74 ++++- 15 files changed, 1868 insertions(+), 1 deletion(-) create mode 100644 App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift create mode 100644 App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift create mode 100644 App/Sources/Infrastructure/RemoteHost/RemoteConnectionRegistry.swift create mode 100644 App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift create mode 100644 App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift create mode 100644 App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift create mode 100644 App/Sources/Infrastructure/RemoteHost/RemoteProbeSnapshot.swift create mode 100644 App/Sources/Infrastructure/RemoteHost/RemoteShellCommand.swift create mode 100644 App/Sources/Infrastructure/RemoteHost/SSHConnectionRecipe.swift create mode 100644 App/Tests/RemoteCommandRunnerTests.swift create mode 100644 App/Tests/RemoteConnectionLiveTests.swift create mode 100644 App/Tests/RemoteConnectionOwnerTests.swift create mode 100644 App/Tests/RemoteProbeParserTests.swift create mode 100644 App/Tests/RemoteShellCommandTests.swift diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift b/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift new file mode 100644 index 00000000..c8ecb4bb --- /dev/null +++ b/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift @@ -0,0 +1,211 @@ +import Darwin +import Foundation + +/// Budgets for one remote command (docs-ai 082, C4). +nonisolated struct RemoteCommandLimits: Equatable, Sendable { + /// The whole command, including connection setup. + var deadline: TimeInterval = 5 + var outputBytes = 256 * 1024 + var errorBytes = 16 * 1024 +} + +nonisolated struct RemoteCommandResult: Equatable, Sendable { + let exitCode: Int32 + let output: Data + /// The output reached `RemoteCommandLimits.outputBytes`; the command was stopped. + let outputTruncated: Bool + let errorOutput: String +} + +nonisolated enum RemoteCommandOutcome: Equatable, Sendable { + case exited(RemoteCommandResult) + case timedOut + case launchFailed(String) +} + +/// Runs one local command (`ssh …`) with stdin input and the C4 budgets, off the main +/// actor. The child runs in its own process group, so a stop also reaches what it spawned +/// (a `ProxyCommand`); a cancelled caller stops it, and it is always reaped. +/// +/// Waiting, reading, and the deadline start on dedicated threads before the first +/// suspension and never wait for a shared pool: a busy app must not turn a finished probe +/// into a timeout. +nonisolated struct RemoteCommandRunner: Sendable { + var run: + @Sendable (_ executable: URL, _ arguments: [String], _ input: Data, _ limits: RemoteCommandLimits) async -> + RemoteCommandOutcome + + static let live = RemoteCommandRunner { executable, arguments, input, limits in + await RemoteProcess.run(executable: executable, arguments: arguments, input: input, limits: limits) + } + + /// Grace period between SIGTERM and SIGKILL. + static let killGrace: TimeInterval = 1 +} + +private nonisolated final class RemoteProcess: @unchecked Sendable { + private let lock = NSLock() + private let processGroup: pid_t + private var exited = false + private var timedOut = false + private var status: Int32? + private var output: (Data, Bool)? + private var errorOutput: Data? + private var continuation: CheckedContinuation? + + private init(processGroup: pid_t) { + self.processGroup = processGroup + } + + /// SIGTERM to the group, SIGKILL after the grace period if it is still there. + func stop() { + guard lock.withLock({ !exited }) else { return } + kill(-processGroup, SIGTERM) + DispatchQueue(label: "com.onevcat.prowl.remote-command.kill") + .asyncAfter(deadline: .now() + RemoteCommandRunner.killGrace) { [self] in + if lock.withLock({ !exited }) { kill(-processGroup, SIGKILL) } + } + } + + private func expire() { + let expired = lock.withLock { + guard !exited else { return false } + timedOut = true + return true + } + if expired { stop() } + } + + /// Each part arrives from its own thread; the last one resumes the caller. + private func finish(_ update: (RemoteProcess) -> Void) { + let ready: (CheckedContinuation, RemoteCommandOutcome)? = lock.withLock { + update(self) + guard let status, let output, let errorOutput, let continuation else { return nil } + self.continuation = nil + let outcome: RemoteCommandOutcome = + timedOut + ? .timedOut + : .exited( + RemoteCommandResult( + exitCode: status, output: output.0, outputTruncated: output.1, + errorOutput: String(bytes: errorOutput, encoding: .utf8) ?? "")) + return (continuation, outcome) + } + if let (continuation, outcome) = ready { continuation.resume(returning: outcome) } + } + + static func run(executable: URL, arguments: [String], input: Data, limits: RemoteCommandLimits) async + -> RemoteCommandOutcome + { + let pipes = Pipes() + let pid: pid_t + switch spawn(executable: executable, arguments: arguments, pipes: pipes) { + case .success(let spawned): pid = spawned + case .failure(let message): return .launchFailed(message) + } + try? pipes.input.fileHandleForReading.close() + try? pipes.output.fileHandleForWriting.close() + try? pipes.error.fileHandleForWriting.close() + let process = RemoteProcess(processGroup: pid) + // Every thread starts here, before the first suspension, so the exit is recorded + // when it happens even while the caller's executor is busy. + process.start(pid: pid, pipes: pipes, input: input, limits: limits) + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + process.finish { $0.continuation = continuation } + } + } onCancel: { + process.stop() + } + } + + private func start(pid: pid_t, pipes: Pipes, input: Data, limits: RemoteCommandLimits) { + Thread.detachNewThread { + let handle = pipes.input.fileHandleForWriting + try? handle.write(contentsOf: input) + try? handle.close() + } + Thread.detachNewThread { [self] in + let output = Self.read(pipes.output.fileHandleForReading, limit: limits.outputBytes) { self.stop() } + finish { $0.output = output } + } + Thread.detachNewThread { [self] in + let (errorOutput, _) = Self.read(pipes.error.fileHandleForReading, limit: limits.errorBytes, onLimit: nil) + finish { $0.errorOutput = errorOutput } + } + Thread.detachNewThread { [self] in + var status: Int32 = 0 + while waitpid(pid, &status, 0) == -1, errno == EINTR {} + let signal = status & 0x7F + // A signal death reports 128 + the signal number, as a shell does. + let code = signal == 0 ? (status >> 8) & 0xFF : 128 + signal + finish { + $0.exited = true + $0.status = code + } + } + DispatchQueue(label: "com.onevcat.prowl.remote-command.deadline") + .asyncAfter(deadline: .now() + limits.deadline) { [self] in expire() } + } + + private struct Pipes: Sendable { + let input = Pipe() + let output = Pipe() + let error = Pipe() + } + + private enum Spawned { + case success(pid_t) + case failure(String) + } + + private static func spawn(executable: URL, arguments: [String], pipes: Pipes) -> Spawned { + var actions: posix_spawn_file_actions_t? + posix_spawn_file_actions_init(&actions) + defer { posix_spawn_file_actions_destroy(&actions) } + posix_spawn_file_actions_adddup2(&actions, pipes.input.fileHandleForReading.fileDescriptor, 0) + posix_spawn_file_actions_adddup2(&actions, pipes.output.fileHandleForWriting.fileDescriptor, 1) + posix_spawn_file_actions_adddup2(&actions, pipes.error.fileHandleForWriting.fileDescriptor, 2) + var attributes: posix_spawnattr_t? + posix_spawnattr_init(&attributes) + defer { posix_spawnattr_destroy(&attributes) } + // A new process group, default signal handling, and no descriptor besides the pipes. + posix_spawnattr_setpgroup(&attributes, 0) + var defaultSignals = sigset_t() + sigfillset(&defaultSignals) + posix_spawnattr_setsigdefault(&attributes, &defaultSignals) + var noSignals = sigset_t() + sigemptyset(&noSignals) + posix_spawnattr_setsigmask(&attributes, &noSignals) + let flags = POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_SETSIGDEF | POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_CLOEXEC_DEFAULT + posix_spawnattr_setflags(&attributes, Int16(flags)) + + let path = executable.path(percentEncoded: false) + let argv = ([path] + arguments).map { strdup($0) } + [nil] + defer { for pointer in argv { free(pointer) } } + var pid: pid_t = 0 + let result = posix_spawn(&pid, path, &actions, &attributes, argv, environ) + guard result == 0 else { return .failure(String(cString: strerror(result))) } + return .success(pid) + } + + /// Reads until EOF, keeping at most `limit` bytes. Reaching the limit calls `onLimit` + /// once and keeps draining, so the writer never blocks on a full pipe. + private static func read(_ handle: FileHandle, limit: Int, onLimit: (() -> Void)?) -> (Data, Bool) { + var data = Data() + var truncated = false + while let chunk = try? handle.read(upToCount: 64 * 1024), !chunk.isEmpty { + guard !truncated else { continue } + let room = limit - data.count + if chunk.count > room { + data.append(chunk.prefix(room)) + truncated = true + onLimit?() + } else { + data.append(chunk) + } + } + try? handle.close() + return (data, truncated) + } +} diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift b/App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift new file mode 100644 index 00000000..f44354e7 --- /dev/null +++ b/App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift @@ -0,0 +1,220 @@ +import Foundation + +/// The single owner of one host's side channel (docs-ai 082, C4). +/// +/// Panes subscribe and ask for refreshes; the owner runs at most one probe at a time and +/// publishes every snapshot to every subscriber. A request while idle runs at once; the +/// requests that arrive while a probe runs coalesce into one refresh that runs right after +/// it, so continuous screen output neither floods the host nor postpones the refresh. After +/// a failure the next run waits a jittered, growing delay. All work runs off the main +/// actor; the side channel is `ssh` with Prowl's own ControlMaster and the overrides of +/// `SSHSideChannel`. +actor RemoteConnectionOwner { + struct Configuration: Sendable { + var sshExecutable = URL(filePath: "/usr/bin/ssh") + var controlDirectory: String + var controlPersistSeconds = 60 + var limits = RemoteCommandLimits() + var backoff = Backoff() + + init(controlDirectory: String = RemoteConnectionOwner.defaultControlDirectory()) { + self.controlDirectory = controlDirectory + } + } + + struct Backoff: Sendable { + var initial: TimeInterval = 0.5 + var maximum: TimeInterval = 8 + /// The delay is multiplied by a factor in `1 - jitter ... 1 + jitter`. + var jitter = 0.2 + + func delay(afterFailures failures: Int, random: Double) -> TimeInterval { + guard failures > 0 else { return 0 } + let base = min(maximum, initial * pow(2, Double(failures - 1))) + return base * (1 - jitter + 2 * jitter * random) + } + } + + nonisolated let connection: ConnectionKey + nonisolated let recipe: SSHConnectionRecipe + private let configuration: Configuration + private let runner: RemoteCommandRunner + private let now: @Sendable () -> TimeInterval + private let sleep: @Sendable (TimeInterval) async throws -> Void + private let random: @Sendable () -> Double + + private var subscribers: [UUID: AsyncStream.Continuation] = [:] + private var running: Task? + private var refreshPending = false + private var sequence: UInt64 = 0 + private var connectionEpoch: UInt64 = 0 + private var consecutiveFailures = 0 + private(set) var latest: RemoteProbeSnapshot? + + init( + connection: ConnectionKey, + recipe: SSHConnectionRecipe, + configuration: Configuration = Configuration(), + runner: RemoteCommandRunner = .live, + now: @escaping @Sendable () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + sleep: @escaping @Sendable (TimeInterval) async throws -> Void = { try await Task.sleep(for: .seconds($0)) }, + random: @escaping @Sendable () -> Double = { Double.random(in: 0...1) } + ) { + self.connection = connection + self.recipe = recipe + self.configuration = configuration + self.runner = runner + self.now = now + self.sleep = sleep + self.random = random + } + + /// A stream of every snapshot published after the call; the latest one, if any, is + /// delivered first. Finishing the stream's consumer does not stop the owner; call + /// `unsubscribe`. + func subscribe() -> (id: UUID, snapshots: AsyncStream) { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + if let latest { continuation.yield(latest) } + subscribers[id] = continuation + return (id, stream) + } + + func unsubscribe(_ id: UUID) { + subscribers.removeValue(forKey: id)?.finish() + } + + var subscriberCount: Int { subscribers.count } + + /// Asks for a fresh snapshot: now when idle, otherwise once after the running probe. + func requestRefresh() { + if running != nil { + refreshPending = true + return + } + running = Task { await self.drain() } + } + + /// Waits until no probe is running or pending. For tests and orderly shutdown. + func waitUntilIdle() async { + while let running { + await running.value + } + } + + /// Stops the probe in flight, finishes every subscription, and closes the control + /// master, so no `ssh` process outlives the owner for longer than its own exit. + func shutdown() async { + refreshPending = false + running?.cancel() + await waitUntilIdle() + for continuation in subscribers.values { continuation.finish() } + subscribers.removeAll() + _ = await runner.run( + configuration.sshExecutable, + ["-o", "ControlPath=\(controlPath)", "-O", "exit", "--", recipe.destination], + Data(), + RemoteCommandLimits(deadline: 2, outputBytes: 1024, errorBytes: 1024)) + } + + nonisolated static func defaultControlDirectory() -> String { + FileManager.default.temporaryDirectory.appending(path: "prowl-ssh", directoryHint: .isDirectory) + .path(percentEncoded: false) + } + + var controlPath: String { + SSHSideChannel.controlPath(for: connection, directory: configuration.controlDirectory) + } + + private func drain() async { + repeat { + refreshPending = false + let delay = configuration.backoff.delay(afterFailures: consecutiveFailures, random: random()) + if delay > 0 { + do { try await sleep(delay) } catch { break } + } + guard !Task.isCancelled else { break } + let snapshot = await probe() + guard !Task.isCancelled else { break } + publish(snapshot) + } while refreshPending + running = nil + } + + private func probe() async -> RemoteProbeSnapshot { + sequence &+= 1 + let sequence = sequence + let startedAt = now() + prepareControlDirectory() + let arguments = SSHSideChannel.scriptArguments( + recipe: recipe, controlPath: controlPath, persist: configuration.controlPersistSeconds) + let outcome = await runner.run( + configuration.sshExecutable, arguments, Data(RemoteProbeScript.text.utf8), configuration.limits) + let (result, report) = Self.interpret(outcome) + switch result { + case .complete, .partial: + consecutiveFailures = 0 + case .failed, .timedOut: + // The next success runs on a new connection; its records start a new epoch. + if consecutiveFailures == 0 { connectionEpoch &+= 1 } + consecutiveFailures += 1 + } + return RemoteProbeSnapshot( + connection: connection, connectionEpoch: connectionEpoch, sequence: sequence, startedAt: startedAt, + finishedAt: now(), outcome: result, report: report) + } + + private func publish(_ snapshot: RemoteProbeSnapshot) { + latest = snapshot + for continuation in subscribers.values { continuation.yield(snapshot) } + } + + private func prepareControlDirectory() { + try? FileManager.default.createDirectory( + atPath: configuration.controlDirectory, withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700]) + } + + static func interpret(_ outcome: RemoteCommandOutcome) -> (RemoteProbeSnapshot.Outcome, RemoteProbeReport?) { + switch outcome { + case .timedOut: + return (.timedOut, nil) + case .launchFailed(let reason): + return (.failed(.launch(reason)), nil) + case .exited(let result): + guard result.exitCode == 0 else { return (.failed(failure(result)), nil) } + switch RemoteProbeParser.parse(result.output) { + case .complete(let report): + return result.outputTruncated ? (.partial, report) : (.complete, report) + case .partial(let report): + return (.partial, report) + case .unrecognized: + return (.failed(.unrecognizedOutput), nil) + } + } + } + + /// `ssh` exits 255 for its own errors and prints them on stderr. + static func failure(_ result: RemoteCommandResult) -> RemoteProbeFailure { + let message = result.errorOutput.lowercased() + if result.exitCode == 255, + let match = failureMessages.first(where: { pair in pair.fragments.contains(where: message.contains) }) + { + return match.failure + } + let lastLine = result.errorOutput.split(whereSeparator: \.isNewline).last.map(String.init) ?? "" + return .exit(result.exitCode, lastLine) + } + + private static let failureMessages: [(fragments: [String], failure: RemoteProbeFailure)] = [ + (["host key verification failed", "remote host identification has changed"], .hostKey), + (["permission denied", "too many authentication failures"], .authentication), + (["could not resolve hostname", "nodename nor servname"], .unresolvableHost), + ( + [ + "connection refused", "timed out", "no route to host", "network is unreachable", "connection closed", + "broken pipe", + ], .unreachable + ), + ] +} diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteConnectionRegistry.swift b/App/Sources/Infrastructure/RemoteHost/RemoteConnectionRegistry.swift new file mode 100644 index 00000000..9e96c0df --- /dev/null +++ b/App/Sources/Infrastructure/RemoteHost/RemoteConnectionRegistry.swift @@ -0,0 +1,44 @@ +import Foundation + +/// Hands out one `RemoteConnectionOwner` per connection key (docs-ai 082, C4), so panes and +/// folders that reach the same effective target share one side channel and one probe. +actor RemoteConnectionRegistry { + static let shared = RemoteConnectionRegistry() + + private let configuration: RemoteConnectionOwner.Configuration + private let runner: RemoteCommandRunner + private var owners: [ConnectionKey: RemoteConnectionOwner] = [:] + + init( + configuration: RemoteConnectionOwner.Configuration = RemoteConnectionOwner.Configuration(), + runner: RemoteCommandRunner = .live + ) { + self.configuration = configuration + self.runner = runner + } + + /// The effective target of `recipe` from `ssh -G`; nil when `ssh` rejects the options. + func resolve(_ recipe: SSHConnectionRecipe) async -> ConnectionKey? { + let outcome = await runner.run( + configuration.sshExecutable, SSHSideChannel.resolveArguments(recipe: recipe), Data(), + RemoteCommandLimits(deadline: 3, outputBytes: 64 * 1024, errorBytes: 4 * 1024)) + guard case .exited(let result) = outcome, result.exitCode == 0 else { return nil } + return ConnectionKey( + effectiveConfiguration: String(bytes: result.output, encoding: .utf8) ?? "", configFile: recipe.configFile) + } + + /// The owner for `key`; the first recipe that reached the key is the one it connects with. + func owner(for key: ConnectionKey, recipe: SSHConnectionRecipe) -> RemoteConnectionOwner { + if let owner = owners[key] { return owner } + let owner = RemoteConnectionOwner(connection: key, recipe: recipe, configuration: configuration, runner: runner) + owners[key] = owner + return owner + } + + /// Shuts the owner down once nothing subscribes to it any more. + func releaseIfUnused(_ key: ConnectionKey) async { + guard let owner = owners[key], await owner.subscriberCount == 0 else { return } + owners.removeValue(forKey: key) + await owner.shutdown() + } +} diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift new file mode 100644 index 00000000..238920d6 --- /dev/null +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift @@ -0,0 +1,183 @@ +import Foundation + +/// Parses the framed output of `RemoteProbeScript` (docs-ai 082, C6). +/// +/// Only a report with a matching header, the host record, well-formed records, and an +/// `END` record whose counts match is complete. Anything else is partial, and a partial +/// report never proves that a process is gone. +nonisolated enum RemoteProbeParser { + enum Result: Equatable, Sendable { + case complete(RemoteProbeReport) + case partial(RemoteProbeReport?) + case unrecognized + } + + static let formatVersion = "1" + + static func parse(_ text: String) -> Result { + parse(Data(text.utf8)) + } + + /// Parses raw output. Fields are decoded one by one, so a process whose argv is not + /// UTF-8 garbles only its own field instead of voiding the snapshot. + static func parse(_ data: Data) -> Result { + var lines = data.split(separator: UInt8(ascii: "\n"), omittingEmptySubsequences: false) + if lines.last?.isEmpty == true { lines.removeLast() } + guard lines.first.map(fields) == ["PROWL-PROBE", formatVersion] else { return .unrecognized } + var builder = ReportBuilder() + for line in lines.dropFirst() where !builder.apply(fields(line)) { + builder.malformed = true + } + return builder.result() + } + + /// Splits a record on TAB and reverses the field escaping. + static func fields(_ line: Data) -> [String] { + line.split(separator: UInt8(ascii: "\t"), omittingEmptySubsequences: false).map { decode(unescape($0)) } + } + + /// Reverses `%XX`; a malformed escape stays literal. + static func unescape(_ field: Data) -> Data { + guard field.contains(UInt8(ascii: "%")) else { return field } + var bytes = Data() + var iterator = field[...] + while let byte = iterator.popFirst() { + if byte == UInt8(ascii: "%"), iterator.count >= 2, + let hex = String(bytes: iterator.prefix(2), encoding: .ascii), let value = UInt8(hex, radix: 16) + { + bytes.append(value) + iterator = iterator.dropFirst(2) + } else { + bytes.append(byte) + } + } + return bytes + } + + static func unescape(_ field: String) -> String { + decode(unescape(Data(field.utf8))) + } + + /// UTF-8 when valid; otherwise Latin-1, which maps every byte, so the record survives. + private static func decode(_ bytes: Data) -> String { + String(bytes: bytes, encoding: .utf8) ?? String(bytes: bytes, encoding: .isoLatin1) ?? "" + } + + fileprivate static func process(_ record: [String]) -> ProcessBuilder? { + guard record.count == 10, let pid = Int32(record[2]), let ppid = Int32(record[3]), + let pgid = Int32(record[4]), let elapsed = Int(record[6]) + else { return nil } + let arguments: [String]? + let commandLine: String + switch record[8] { + case "v": + let vector = record[9].split(separator: "\u{1F}", omittingEmptySubsequences: false).map(String.init) + arguments = vector + commandLine = vector.joined(separator: " ") + case "s": + arguments = nil + commandLine = record[9] + default: + return nil + } + return ProcessBuilder( + pid: pid, parentPID: ppid, processGroupID: pgid, startToken: record[5], elapsedSeconds: elapsed, + name: record[7], arguments: arguments, commandLine: commandLine) + } + + fileprivate struct ProcessBuilder { + let pid: Int32 + let parentPID: Int32 + let processGroupID: Int32 + let startToken: String + let elapsedSeconds: Int + let name: String + let arguments: [String]? + let commandLine: String + var workingDirectory: String? + + func build() -> RemoteProbeProcess { + RemoteProbeProcess( + pid: pid, parentPID: parentPID, processGroupID: processGroupID, startToken: startToken, + elapsedSeconds: elapsedSeconds, name: name, arguments: arguments, commandLine: commandLine, + workingDirectory: workingDirectory) + } + } + + private struct ReportBuilder { + var host: (system: RemoteProbeReport.OperatingSystem, bootID: String)? + var sessions: [SessionBuilder] = [] + var ended: (sessions: Int, processes: Int)? + var malformed = false + + /// Applies one record; false when it is malformed or out of place. + mutating func apply(_ record: [String]) -> Bool { + guard ended == nil else { return false } + switch record.first { + case "H": return applyHost(record) + case "S": return applySession(record) + case "P": + guard !sessions.isEmpty, let process = RemoteProbeParser.process(record) else { return false } + sessions[sessions.count - 1].processes.append(process) + return true + case "D": return applyDirectory(record) + case "END": + guard record.count == 3, let sessionCount = Int(record[1]), let processCount = Int(record[2]) else { + return false + } + ended = (sessionCount, processCount) + return true + default: return false + } + } + + private mutating func applyHost(_ record: [String]) -> Bool { + guard host == nil, record.count == 3, let system = RemoteProbeReport.OperatingSystem(rawValue: record[1]), + record[2] != "-" + else { return false } + host = (system, record[2]) + return true + } + + private mutating func applySession(_ record: [String]) -> Bool { + guard record.count == 5, let anchor = Int32(record[2]), let tpgid = Int32(record[4]) else { return false } + sessions.append( + SessionBuilder( + paneID: UUID(uuidString: record[1]), anchorPID: anchor, tty: record[3] == "-" ? nil : record[3], + foregroundProcessGroupID: tpgid)) + return true + } + + private mutating func applyDirectory(_ record: [String]) -> Bool { + guard record.count == 4, let pid = Int32(record[2]), !sessions.isEmpty, + let index = sessions[sessions.count - 1].processes.lastIndex(where: { $0.pid == pid }) + else { return false } + sessions[sessions.count - 1].processes[index].workingDirectory = record[3] + return true + } + + func result() -> Result { + guard let host else { return .partial(nil) } + let report = RemoteProbeReport( + operatingSystem: host.system, bootID: host.bootID, sessions: sessions.map { $0.build() }) + guard !malformed, let ended, ended.sessions == sessions.count, + ended.processes == sessions.reduce(0, { $0 + $1.processes.count }) + else { return .partial(report) } + return .complete(report) + } + } + + private struct SessionBuilder { + let paneID: UUID? + let anchorPID: Int32 + let tty: String? + let foregroundProcessGroupID: Int32 + var processes: [ProcessBuilder] = [] + + func build() -> RemoteProbeSession { + RemoteProbeSession( + paneID: paneID, anchorPID: anchorPID, tty: tty, foregroundProcessGroupID: foregroundProcessGroupID, + processes: processes.map { $0.build() }) + } + } +} diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift new file mode 100644 index 00000000..714c053c --- /dev/null +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift @@ -0,0 +1,64 @@ +import Foundation + +/// One remote process as a probe snapshot reported it (docs-ai 082, C6). +nonisolated struct RemoteProbeProcess: Equatable, Sendable { + let pid: Int32 + let parentPID: Int32 + let processGroupID: Int32 + /// The host's native start token: Linux start ticks, macOS `lstart`. + let startToken: String + let elapsedSeconds: Int + let name: String + /// The argument vector when the host reports it losslessly (Linux). + let arguments: [String]? + /// The space-joined command line (macOS reports only this form). + let commandLine: String + let workingDirectory: String? +} + +/// An SSH session with a terminal on the host, and its foreground job. +nonisolated struct RemoteProbeSession: Equatable, Sendable { + /// `LC_PROWL_PANE` of the session, when the probe could read it. + let paneID: UUID? + let anchorPID: Int32 + let tty: String? + let foregroundProcessGroupID: Int32 + let processes: [RemoteProbeProcess] +} + +/// The parsed body of one probe run. +nonisolated struct RemoteProbeReport: Equatable, Sendable { + enum OperatingSystem: String, Equatable, Sendable { + case linux = "Linux" + case darwin = "Darwin" + } + + let operatingSystem: OperatingSystem + let bootID: String + let sessions: [RemoteProbeSession] +} + +extension RemoteProbeSession { + /// The session's foreground job as the classifier reads it: remote references that + /// carry the host's boot id and each process's native start token. + nonisolated func foregroundJob(connection: ConnectionKey, bootID: String) -> ForegroundJob? { + let members = processes.filter { $0.processGroupID == foregroundProcessGroupID } + guard !members.isEmpty else { return nil } + return ForegroundJob( + processGroupID: foregroundProcessGroupID, + processes: members.map { process in + let argv0 = process.arguments?.first ?? process.commandLine.split(separator: " ").first.map(String.init) + return ForegroundProcess( + ref: .remote( + RemoteProcessRef( + connection: connection, bootID: bootID, pid: process.pid, startToken: process.startToken)), + parentPID: process.parentPID, + name: process.name, + argv0: argv0.flatMap(ProcessDetection.basename), + cmdline: process.commandLine, + arguments: process.arguments + ) + } + ) + } +} diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift new file mode 100644 index 00000000..113e94c8 --- /dev/null +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift @@ -0,0 +1,168 @@ +import Foundation + +/// The remote process probe (docs-ai 082, C6): a POSIX `sh` script that Prowl pipes to +/// `sh -s` over its side channel. It installs nothing and reads only the process table, +/// so the host needs no Prowl file, configuration, or hook. `RemoteProbeParser` reads +/// its output; the format is described in the script header. +nonisolated enum RemoteProbeScript { + static let text = #""" + # Prowl remote probe (docs-ai 082, C6). POSIX sh, piped to `sh -s`; installs nothing. + # + # Output: one record per line, fields separated by TAB, each field escaped + # (% -> %25, TAB -> %09, LF -> %0A, CR -> %0D). Records: + # PROWL-PROBE + # H + # S an SSH session with a terminal + # P + # a member of its foreground job; + # argv mode v = US-separated vector, + # s = one space-joined string + # D + # END

+ # A snapshot without END is incomplete. + LC_ALL=C + export LC_ALL + uid=$(id -u) + echo "PROWL-PROBE 1" + + if [ -r /proc/self/stat ]; then + boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) + echo "H Linux ${boot:--}" + files= + for d in /proc/[0-9]*; do + [ -O "$d" ] && files="$files $d/stat" + done + hz=$(getconf CLK_TCK 2>/dev/null || echo 100) + up=$(cut -d' ' -f1 /proc/uptime) + # shellcheck disable=SC2086 + table=$(cat $files 2>/dev/null | awk ' + { + line = $0 + open = index(line, "(") + rest = line; sub(/^.*\) /, "", rest) + comm = substr(line, open + 1, length(line) - length(rest) - open - 2) + split(rest, f, " ") + # pid ppid pgrp tpgid starttime comm + printf "%s %s %s %s %s %s\n", substr(line, 1, open - 2), f[2], f[3], f[6], f[20], comm + }') + anchors=$(printf '%s\n' "$table" | awk ' + { pid[$1] = $1; ppid[$1] = $2; comm[$1] = $6; tpgid[$1] = $4 } + END { + for (p in pid) { + c = comm[ppid[p]] + if ((c == "sshd-session" || c == "sshd") && tpgid[p] > 0) print p, tpgid[p] + } + }') + scount=0 + pcount=0 + for a in $(printf '%s\n' "$anchors" | awk 'NF == 2 { print $1 ":" $2 }'); do + apid=${a%%:*} + tpgid=${a#*:} + pane=$(tr '\0' '\n' 2>/dev/null < "/proc/$apid/environ" | sed -n 's/^LC_PROWL_PANE=//p' | head -n 1) + tty=$(readlink "/proc/$apid/fd/0" 2>/dev/null) + printf 'S\t%s\t%s\t%s\t%s\n' "${pane:--}" "$apid" "${tty:--}" "$tpgid" + scount=$((scount + 1)) + for m in $(printf '%s\n' "$table" | awk -v g="$tpgid" '$3 == g { print $1 }'); do + argv=$(tr '\0' '\037' 2>/dev/null < "/proc/$m/cmdline") + cwd=$(readlink "/proc/$m/cwd" 2>/dev/null) + printf '%s\n' "$table" | awk -v m="$m" -v pane="${pane:--}" -v up="$up" -v hz="$hz" \ + -v argv="$argv" -v cwd="$cwd" ' + function esc(s) { + gsub(/%/, "%25", s); gsub(/\t/, "%09", s); gsub(/\n/, "%0A", s); gsub(/\r/, "%0D", s); return s + } + $1 == m { + comm = $0; sub(/^[^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ /, "", comm) + sub(/\037$/, "", argv) + printf "P\t%s\t%s\t%s\t%s\t%s\t%d\t%s\tv\t%s\n", \ + esc(pane), $1, $2, $3, $5, up - $5 / hz, esc(comm), esc(argv) + if (cwd != "") printf "D\t%s\t%s\t%s\n", esc(pane), $1, esc(cwd) + }' + pcount=$((pcount + 1)) + done + done + printf 'END\t%s\t%s\n' "$scount" "$pcount" + exit 0 + fi + + # macOS: the environment of platform binaries (the /bin/zsh login shell) is + # hidden, so the anchor is "parent is sshd-session with a terminal" and the pane + # id comes from the foreground job's own environment. + boot=$(sysctl -n kern.boottime 2>/dev/null | sed -n 's/^{ sec = \([0-9]*\), usec = \([0-9]*\) }.*/\1.\2/p') + echo "H Darwin ${boot:--}" + names=$(ps -A -o pid=,ucomm=) + table=$(ps -A -ww -o pid=,ppid=,pgid=,tpgid=,uid=,tty=,etime=,lstart=,args=) + members=$(printf '%s\n' "$table" | awk -v uid="$uid" ' + { pid = $1; ppid[pid] = $2; tpgid[pid] = $4; owner[pid] = $5; tty[pid] = $6; pgid[pid] = $3 + a = $0; for (i = 1; i <= 12; i++) sub(/^ *[^ ]+/, "", a); sub(/^ +/, "", a); args[pid] = a } + END { + for (p in ppid) { + if (owner[p] != uid || tty[p] == "??") continue + if (args[ppid[p]] !~ /^sshd(-session)?: /) continue + for (m in ppid) if (pgid[m] == tpgid[p] && tty[m] == tty[p]) print p, tpgid[p], tty[p], m + } + }') + pids=$(printf '%s\n' "$members" | awk 'NF { printf "%s%s", sep, $4; sep = "," }') + envs= + cwds= + if [ -n "$pids" ]; then + envs=$(ps -wwE -o pid=,command= -p "$pids" 2>/dev/null | awk ' + { v = "-" + for (i = 2; i <= NF; i++) if ($i ~ /^LC_PROWL_PANE=/) { v = substr($i, 15); break } + print $1, v }') + cwds=$(lsof -a -d cwd -Fpn -p "$pids" 2>/dev/null) + fi + { + printf '%s\n' "$names" | sed 's/^/N /' + printf '%s\n' "$table" | sed 's/^/T /' + printf '%s\n' "$envs" | sed 's/^/V /' + printf '%s\n' "$cwds" | sed 's/^/W /' + printf '%s\n' "$members" | sed 's/^/M /' + } | awk ' + function esc(s) { + gsub(/%/, "%25", s); gsub(/\t/, "%09", s); gsub(/\n/, "%0A", s); gsub(/\r/, "%0D", s); return s + } + function seconds(e, n, a, s, d, x, i) { + d = 0; if (e ~ /-/) { split(e, x, "-"); d = x[1]; e = x[2] } + n = split(e, a, ":"); s = 0 + for (i = 1; i <= n; i++) s = s * 60 + a[i] + return s + d * 86400 + } + $1 == "N" { p = $2; c = $0; sub(/^N +[0-9]+ /, "", c); sub(/ +$/, "", c); name[p] = c; next } + $1 == "T" { + p = $2; ppid[p] = $3; pgid[p] = $4; et[p] = $8 + start[p] = $9 " " $10 " " $11 " " $12 " " $13 + a = $0; for (i = 1; i <= 13; i++) sub(/^ *[^ ]+/, "", a); sub(/^ +/, "", a); args[p] = a + next + } + $1 == "V" { pane[$2] = $3; next } + $1 == "W" { + if ($2 ~ /^p/) wp = substr($2, 2) + else if ($2 ~ /^n/) { c = $0; sub(/^W n/, "", c); cwd[wp] = c } + next + } + $1 == "M" && NF == 5 { + a = $2; m = $5 + if (!(a in seen)) { order[++na] = a; seen[a] = 1; tg[a] = $3; tt[a] = $4 } + mem[a] = mem[a] " " m + if (pane[m] != "" && pane[m] != "-") apane[a] = pane[m] + next + } + END { + pc = 0 + for (i = 1; i <= na; i++) { + a = order[i]; pn = (a in apane) ? apane[a] : "-" + printf "S\t%s\t%s\t/dev/%s\t%s\n", esc(pn), a, tt[a], tg[a] + n = split(mem[a], ms, " ") + for (j = 1; j <= n; j++) { + m = ms[j] + printf "P\t%s\t%s\t%s\t%s\t%s\t%d\t%s\ts\t%s\n", \ + esc(pn), m, ppid[m], pgid[m], esc(start[m]), seconds(et[m]), esc(name[m]), esc(args[m]) + if (m in cwd) printf "D\t%s\t%s\t%s\n", esc(pn), m, esc(cwd[m]) + pc++ + } + } + printf "END\t%d\t%d\n", na, pc + }' + + """# +} diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeSnapshot.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeSnapshot.swift new file mode 100644 index 00000000..3847d9b3 --- /dev/null +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeSnapshot.swift @@ -0,0 +1,102 @@ +import Foundation + +/// Why a probe produced no usable report (docs-ai 082, C3). Each reason maps to a +/// different fix for the user, so the UX can name it. +nonisolated enum RemoteProbeFailure: Equatable, Sendable { + /// The key was refused or no key was available without a prompt (`BatchMode`). + case authentication + /// The host key is unknown or changed. + case hostKey + case unresolvableHost + case unreachable + /// The command could not start (`ssh` missing or not executable). + case launch(String) + /// Any other non-zero exit; the reason is the last line `ssh` printed. + case exit(Int32, String) + /// The output was not the probe's format (for example a shell that prints a banner). + case unrecognizedOutput +} + +/// One probe run as subscribers see it (docs-ai 082, C3). Times are local monotonic +/// seconds (`systemUptime`), never derived from the host's clock. +nonisolated struct RemoteProbeSnapshot: Equatable, Sendable { + enum Outcome: Equatable, Sendable { + /// Every record arrived and the counts matched. + case complete + /// Some records arrived; nothing in it proves absence. + case partial + case failed(RemoteProbeFailure) + case timedOut + } + + let connection: ConnectionKey + /// Bumped when the owner reconnects after a failure; records of different epochs are + /// never compared. + let connectionEpoch: UInt64 + /// Increments once per probe run of one owner. + let sequence: UInt64 + let startedAt: TimeInterval + let finishedAt: TimeInterval + let outcome: Outcome + let report: RemoteProbeReport? + + /// Only a complete snapshot proves that a process is absent. + var isComplete: Bool { outcome == .complete } + + /// A snapshot is fresh for `freshnessWindow` after its run started; an older + /// observation is "status unavailable", never Idle, Done or exit. + static let freshnessWindow: TimeInterval = 10 + + func isFresh(now: TimeInterval) -> Bool { + now - startedAt <= Self.freshnessWindow + } +} + +/// The presence of one remote process across snapshots, from one consumer's view +/// (docs-ai 082, C3). A sequence counts once however often a pane ticks; only complete +/// fresh snapshots count toward absence, and two consecutive ones release the process. +/// Partial, failed, or stale snapshots neither prove absence nor reset the count. +nonisolated struct RemoteProcessPresence: Equatable, Sendable { + enum Verdict: Equatable, Sendable { + case present + /// Missing from complete snapshots, but not yet from enough of them. + case missing + case released + /// No new evidence: a repeated sequence, an older epoch, or an inconclusive run. + case unchanged + } + + static let releaseAfterMissingSnapshots = 2 + + private(set) var connectionEpoch: UInt64? + private(set) var lastSequence: UInt64? + private(set) var consecutiveMissing = 0 + /// When a complete snapshot last confirmed the process. + private(set) var lastSeenAt: TimeInterval? + + init() {} + + mutating func observe(_ snapshot: RemoteProbeSnapshot, contains: Bool, now: TimeInterval) -> Verdict { + if let connectionEpoch, snapshot.connectionEpoch < connectionEpoch { return .unchanged } + if snapshot.connectionEpoch == connectionEpoch, let lastSequence, snapshot.sequence <= lastSequence { + return .unchanged + } + connectionEpoch = snapshot.connectionEpoch + lastSequence = snapshot.sequence + guard snapshot.report != nil, snapshot.isFresh(now: now) else { return .unchanged } + if contains { + consecutiveMissing = 0 + lastSeenAt = snapshot.startedAt + return .present + } + guard snapshot.isComplete else { return .unchanged } + consecutiveMissing += 1 + return consecutiveMissing >= Self.releaseAfterMissingSnapshots ? .released : .missing + } + + /// Whether the last confirmation is too old to describe the process. + func isStale(now: TimeInterval) -> Bool { + guard let lastSeenAt else { return true } + return now - lastSeenAt > RemoteProbeSnapshot.freshnessWindow + } +} diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteShellCommand.swift b/App/Sources/Infrastructure/RemoteHost/RemoteShellCommand.swift new file mode 100644 index 00000000..445a1a72 --- /dev/null +++ b/App/Sources/Infrastructure/RemoteHost/RemoteShellCommand.swift @@ -0,0 +1,70 @@ +import Foundation + +/// Remote work as data (docs-ai 082, C5): an executable, its arguments, a working +/// directory, and environment additions. `script` is the only place it becomes shell text; +/// the text is piped to `sh -s`, so the remote login shell never parses it. +nonisolated struct RemoteShellCommand: Equatable, Sendable { + enum ValidationError: Error, Equatable { + case emptyExecutable + case invalidEnvironmentName(String) + case nulByte + } + + var executable: String + var arguments: [String] + var workingDirectory: String? + /// Exported before `exec`, in order. + var environment: [(name: String, value: String)] + + init( + executable: String, arguments: [String] = [], workingDirectory: String? = nil, + environment: [(name: String, value: String)] = [] + ) { + self.executable = executable + self.arguments = arguments + self.workingDirectory = workingDirectory + self.environment = environment + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.executable == rhs.executable && lhs.arguments == rhs.arguments + && lhs.workingDirectory == rhs.workingDirectory + && lhs.environment.map(\.name) == rhs.environment.map(\.name) + && lhs.environment.map(\.value) == rhs.environment.map(\.value) + } + + /// Exit status of the script when the working directory does not exist. + static let workingDirectoryFailureStatus: Int32 = 97 + + /// POSIX `sh` text that runs the command. A NUL byte cannot be passed through argv or + /// the environment, so it is rejected instead of truncated. + func script() throws(ValidationError) -> String { + guard !executable.isEmpty else { throw .emptyExecutable } + let values = [executable] + arguments + [workingDirectory ?? ""] + environment.flatMap { [$0.name, $0.value] } + guard !values.contains(where: { $0.utf8.contains(0) }) else { throw .nulByte } + var lines: [String] = [] + if let workingDirectory { + lines.append("cd -- \(Self.quote(workingDirectory)) || exit \(Self.workingDirectoryFailureStatus)") + } + for variable in environment { + guard Self.isValidName(variable.name) else { throw .invalidEnvironmentName(variable.name) } + lines.append("\(variable.name)=\(Self.quote(variable.value))") + lines.append("export \(variable.name)") + } + lines.append((["exec", Self.quote(executable)] + arguments.map(Self.quote)).joined(separator: " ")) + return lines.joined(separator: "\n") + "\n" + } + + /// Single-quotes `value` for POSIX `sh`; an embedded quote becomes `'\''`. + static func quote(_ value: String) -> String { + "'" + value.replacing("'", with: #"'\''"#) + "'" + } + + static func isValidName(_ name: String) -> Bool { + guard let first = name.unicodeScalars.first, first == "_" || (first.isASCII && first.properties.isAlphabetic) + else { return false } + return name.unicodeScalars.allSatisfy { + $0 == "_" || ($0.isASCII && ($0.properties.isAlphabetic || ("0"..."9").contains($0))) + } + } +} diff --git a/App/Sources/Infrastructure/RemoteHost/SSHConnectionRecipe.swift b/App/Sources/Infrastructure/RemoteHost/SSHConnectionRecipe.swift new file mode 100644 index 00000000..688d1f23 --- /dev/null +++ b/App/Sources/Infrastructure/RemoteHost/SSHConnectionRecipe.swift @@ -0,0 +1,109 @@ +import CryptoKit +import Foundation + +/// How to reach a host: the destination and the connection-shaping options of an `ssh` +/// command line (docs-ai 082, C1/C4). It carries no forwarding, session, or command option; +/// the side channel adds its own overrides on top. +nonisolated struct SSHConnectionRecipe: Hashable, Sendable, Codable { + let destination: String + /// Options in `ssh` order (`-p`, `2222`, `-o`, `IdentityFile=…`), already filtered to the + /// supported connection-shaping set. + let options: [String] + + init(destination: String, options: [String] = []) { + self.destination = destination + self.options = options + } + + /// The `-F` file the options name, if any; part of the connection key. + var configFile: String? { + guard let index = options.firstIndex(of: "-F"), options.indices.contains(index + 1) else { return nil } + return options[index + 1] + } +} + +/// A persistent host (docs-ai 082, C1). Persisted data refers to `id`; `alias` is only +/// shown; `recipe` still resolves through `~/.ssh/config`, so it is re-resolved with +/// `ssh -G` before use and compared with the key it was bound to. +nonisolated struct HostProfile: Identifiable, Hashable, Sendable, Codable { + let id: UUID + var alias: String + var recipe: SSHConnectionRecipe + /// The effective target the profile was last bound to. + var connectionKey: ConnectionKey + + /// A resolution that reaches another user, hostname, or port (for example an edited + /// `HostName`) is a different authority: operations pause until the user rebinds the + /// profile or keeps the old target. + func authorityChanged(to resolved: ConnectionKey) -> Bool { + resolved.user != connectionKey.user || resolved.hostname != connectionKey.hostname + || resolved.port != connectionKey.port + } +} + +/// The argument vectors Prowl runs for a host. Every side channel invocation overrides the +/// session effects a configuration may add, so a `LocalForward`, `RemoteCommand`, or +/// `LocalCommand` in `~/.ssh/config` never runs for it. `ssh` keeps the first value it +/// obtains for an option and reads the command line before any file, so these come first. +/// There is no `ConnectTimeout`: with `ControlPersist` it holds the first command's output +/// open for about ten seconds (OpenSSH 10.3); the runner's deadline bounds the whole command. +nonisolated enum SSHSideChannel { + static let sessionOverrides: [String] = [ + "-o", "BatchMode=yes", + "-o", "ClearAllForwardings=yes", + "-o", "RemoteCommand=none", + "-o", "RequestTTY=no", + "-o", "PermitLocalCommand=no", + "-o", "ForwardAgent=no", + "-o", "ForwardX11=no", + "-o", "Tunnel=no", + "-o", "SessionType=default", + "-o", "ForkAfterAuthentication=no", + "-o", "StdinNull=no", + "-o", "EscapeChar=none", + "-o", "LogLevel=ERROR", + "-o", "ServerAliveInterval=5", + "-o", "ServerAliveCountMax=2", + ] + + /// `ssh … sh -s`: the script arrives on stdin, so the remote login shell + /// (zsh, bash, fish) never parses Prowl's text. + static func scriptArguments(recipe: SSHConnectionRecipe, controlPath: String, persist: Int) -> [String] { + sessionOverrides + + ["-o", "ControlMaster=auto", "-o", "ControlPath=\(controlPath)", "-o", "ControlPersist=\(persist)", "-T"] + + recipe.options + ["--", recipe.destination, "sh", "-s"] + } + + /// `ssh -G …`: the effective configuration, without connecting. + static func resolveArguments(recipe: SSHConnectionRecipe) -> [String] { + ["-G"] + recipe.options + ["--", recipe.destination] + } + + /// A short private socket path. `sun_path` holds 104 bytes on Darwin, so the key is + /// hashed rather than spelled out. + static func controlPath(for key: ConnectionKey, directory: String) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = (try? encoder.encode(key)) ?? Data() + let digest = SHA256.hash(data: data).prefix(8).map { String(format: "%02x", $0) }.joined() + return (directory as NSString).appendingPathComponent("cm-\(digest)") + } +} + +extension ConnectionKey { + /// The key from `ssh -G` output. Nil when a field the key needs is missing. + nonisolated init?(effectiveConfiguration text: String, configFile: String?) { + var values: [String: String] = [:] + for line in text.split(whereSeparator: \.isNewline) { + let parts = line.split(separator: " ", maxSplits: 1) + guard parts.count == 2 else { continue } + let name = parts[0].lowercased() + if values[name] == nil { values[name] = String(parts[1]) } + } + guard let user = values["user"], let hostname = values["hostname"], + let port = values["port"].flatMap(Int.init) + else { return nil } + let jump = values["proxyjump"].map { $0 == "none" ? [] : $0.split(separator: ",").map(String.init) } ?? [] + self.init(user: user, hostname: hostname, port: port, jumpChain: jump, configFile: configFile) + } +} diff --git a/App/Tests/RemoteCommandRunnerTests.swift b/App/Tests/RemoteCommandRunnerTests.swift new file mode 100644 index 00000000..fef3b519 --- /dev/null +++ b/App/Tests/RemoteCommandRunnerTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing + +@testable import Prowl + +/// The live runner's budgets with local commands in place of `ssh` (docs-ai 082, C4). +nonisolated struct RemoteCommandRunnerTests { + private let runner = RemoteCommandRunner.live + + @Test func stdinReachesTheCommandAndOutputReturns() async { + let outcome = await runner.run(URL(filePath: "/bin/cat"), [], Data("hello\n".utf8), RemoteCommandLimits()) + guard case .exited(let result) = outcome else { + Issue.record("expected exit, got \(outcome)") + return + } + #expect(result.exitCode == 0) + #expect(result.output == Data("hello\n".utf8)) + #expect(!result.outputTruncated) + } + + @Test func outputBeyondTheLimitIsTruncatedAndStopsTheCommand() async { + let limits = RemoteCommandLimits(deadline: 10, outputBytes: 1024) + let outcome = await runner.run(URL(filePath: "/usr/bin/yes"), [], Data(), limits) + guard case .exited(let result) = outcome else { + Issue.record("expected exit, got \(outcome)") + return + } + #expect(result.outputTruncated) + #expect(result.output.count == 1024) + } + + @Test func theDeadlineStopsAHungCommandAndWhatItSpawned() async { + // The background child shares stdout: if the stop missed it, the run would wait for + // its end-of-file for 30 seconds. + let clock = ContinuousClock() + let started = clock.now + let outcome = await runner.run( + URL(filePath: "/bin/sh"), ["-c", "sleep 30 & sleep 30"], Data(), + RemoteCommandLimits(deadline: 0.3, outputBytes: 1024)) + #expect(outcome == .timedOut) + #expect(clock.now - started < .seconds(20)) + } + + @Test func aSignalDeathReportsTheShellStatus() async { + let outcome = await runner.run(URL(filePath: "/bin/sh"), ["-c", "kill -9 $$"], Data(), RemoteCommandLimits()) + guard case .exited(let result) = outcome else { + Issue.record("expected exit, got \(outcome)") + return + } + #expect(result.exitCode == 128 + SIGKILL) + } + + @Test func stderrAndExitStatusAreKept() async { + let outcome = await runner.run( + URL(filePath: "/bin/sh"), ["-c", "echo oops >&2; exit 3"], Data(), RemoteCommandLimits()) + guard case .exited(let result) = outcome else { + Issue.record("expected exit, got \(outcome)") + return + } + #expect(result.exitCode == 3) + #expect(result.errorOutput == "oops\n") + } + + @Test func aMissingExecutableFailsToLaunch() async { + let outcome = await runner.run(URL(filePath: "/nonexistent/ssh"), [], Data(), RemoteCommandLimits()) + guard case .launchFailed = outcome else { + Issue.record("expected launch failure, got \(outcome)") + return + } + } +} diff --git a/App/Tests/RemoteConnectionLiveTests.swift b/App/Tests/RemoteConnectionLiveTests.swift new file mode 100644 index 00000000..7e2d06a8 --- /dev/null +++ b/App/Tests/RemoteConnectionLiveTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing + +@testable import Prowl + +/// The side channel against a real host (docs-ai 082). Disabled unless +/// `PROWL_TEST_SSH_HOST` names a destination that `ssh -o BatchMode=yes` reaches; +/// `PROWL_TEST_SSH_OPTIONS` adds space-separated options (for example `-p 2222`). +/// Run with `TEST_RUNNER_PROWL_TEST_SSH_HOST= xcodebuild test …`. +@Suite(.enabled(if: ProcessInfo.processInfo.environment["PROWL_TEST_SSH_HOST"] != nil)) +struct RemoteConnectionLiveTests { + private var recipe: SSHConnectionRecipe { + let environment = ProcessInfo.processInfo.environment + let options = environment["PROWL_TEST_SSH_OPTIONS"]?.split(separator: " ").map(String.init) ?? [] + return SSHConnectionRecipe(destination: environment["PROWL_TEST_SSH_HOST"] ?? "", options: options) + } + + @Test func probeOverTheSideChannelReturnsACompleteReport() async throws { + let directory = FileManager.default.temporaryDirectory.appending( + path: "prowl-ssh-live", directoryHint: .isDirectory) + let registry = RemoteConnectionRegistry( + configuration: RemoteConnectionOwner.Configuration(controlDirectory: directory.path(percentEncoded: false))) + let key = try #require(await registry.resolve(recipe)) + let owner = await registry.owner(for: key, recipe: recipe) + let (id, _) = await owner.subscribe() + await owner.requestRefresh() + await owner.waitUntilIdle() + let first = try #require(await owner.latest) + #expect(first.outcome == .complete, "\(first.outcome)") + #expect(first.report.map { !$0.bootID.isEmpty } == true) + // The second run reuses the master. + await owner.requestRefresh() + await owner.waitUntilIdle() + let second = try #require(await owner.latest) + #expect(second.outcome == .complete) + #expect(second.sequence == first.sequence + 1) + #expect(second.report?.bootID == first.report?.bootID) + await owner.unsubscribe(id) + await registry.releaseIfUnused(key) + #expect(!FileManager.default.fileExists(atPath: await owner.controlPath)) + } +} diff --git a/App/Tests/RemoteConnectionOwnerTests.swift b/App/Tests/RemoteConnectionOwnerTests.swift new file mode 100644 index 00000000..86ed8a34 --- /dev/null +++ b/App/Tests/RemoteConnectionOwnerTests.swift @@ -0,0 +1,275 @@ +import Foundation +import Testing + +@testable import Prowl + +/// The connection owner's scheduling, budgets, and snapshot contract (docs-ai 082, C3/C4), +/// with a scripted runner in place of `ssh`. +struct RemoteConnectionOwnerTests { + private static let key = ConnectionKey(user: "onevcat", hostname: "mini.local") + private static let recipe = SSHConnectionRecipe(destination: "mini", options: ["-p", "22"]) + + /// Records every call; each probe call can be held open until the test releases it. + private struct FakeCall { + let arguments: [String] + let input: Data + } + + private actor FakeRunner { + var calls: [FakeCall] = [] + var outcomes: [RemoteCommandOutcome] = [] + var holds = false + private var held: [CheckedContinuation] = [] + private var enteredWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + func setOutcomes(_ outcomes: [RemoteCommandOutcome]) { self.outcomes = outcomes } + func setHolds(_ holds: Bool) { self.holds = holds } + + func run(_ arguments: [String], _ input: Data) async -> RemoteCommandOutcome { + calls.append(FakeCall(arguments: arguments, input: input)) + let probes = calls.filter { $0.arguments.last == "-s" }.count + for waiter in enteredWaiters where waiter.count <= probes { waiter.continuation.resume() } + enteredWaiters.removeAll { $0.count <= probes } + if holds, arguments.last == "-s" { + await withCheckedContinuation { held.append($0) } + } + return outcomes.isEmpty ? .exited(Self.success) : outcomes.removeFirst() + } + + func waitForProbes(_ count: Int) async { + guard calls.filter({ $0.arguments.last == "-s" }).count < count else { return } + await withCheckedContinuation { enteredWaiters.append((count, $0)) } + } + + func releaseOne() { + guard !held.isEmpty else { return } + held.removeFirst().resume() + } + + var probeCount: Int { calls.filter { $0.arguments.last == "-s" }.count } + + static let success = RemoteCommandResult( + exitCode: 0, output: Data(RemoteProbeParserTests.linux.utf8), outputTruncated: false, errorOutput: "") + } + + private nonisolated final class Recorder: @unchecked Sendable { + let delays = LockIsolatedArray() + } + + private nonisolated final class LockIsolatedArray: @unchecked Sendable { + private let lock = NSLock() + private var storage: [TimeInterval] = [] + func append(_ value: TimeInterval) { lock.withLock { storage.append(value) } } + var values: [TimeInterval] { lock.withLock { storage } } + } + + private func makeOwner(_ fake: FakeRunner, recorder: Recorder = Recorder()) -> RemoteConnectionOwner { + var configuration = RemoteConnectionOwner.Configuration(controlDirectory: "/tmp/prowl-ssh-tests") + configuration.controlPersistSeconds = 30 + return RemoteConnectionOwner( + connection: Self.key, recipe: Self.recipe, configuration: configuration, + runner: RemoteCommandRunner { _, arguments, input, _ in await fake.run(arguments, input) }, + now: { 100 }, + sleep: { recorder.delays.append($0) }, + random: { 0.5 }) + } + + @Test func probeRunsTheScriptOverTheSideChannelAndPublishesACompleteSnapshot() async throws { + let fake = FakeRunner() + let owner = makeOwner(fake) + let (_, stream) = await owner.subscribe() + await owner.requestRefresh() + var iterator = stream.makeAsyncIterator() + let snapshot = try #require(await iterator.next()) + #expect(snapshot.outcome == .complete) + #expect(snapshot.sequence == 1) + #expect(snapshot.connection == Self.key) + #expect(snapshot.report?.sessions.count == 2) + + let call = try #require(await fake.calls.first) + #expect(call.input == Data(RemoteProbeScript.text.utf8)) + // Overrides come before the user's options, the destination after `--`. + let overrides = call.arguments.prefix(SSHSideChannel.sessionOverrides.count) + #expect(Array(overrides) == SSHSideChannel.sessionOverrides) + #expect(call.arguments.suffix(4) == ["--", "mini", "sh", "-s"]) + #expect(call.arguments.contains("ControlPersist=30")) + #expect(call.arguments.contains("ClearAllForwardings=yes")) + #expect(call.arguments.contains("RemoteCommand=none")) + } + + @Test func requestsDuringAProbeCoalesceIntoOneFollowUpRun() async { + let fake = FakeRunner() + await fake.setHolds(true) + let owner = makeOwner(fake) + await owner.requestRefresh() + await fake.waitForProbes(1) + for _ in 0..<20 { await owner.requestRefresh() } + await fake.releaseOne() + await fake.waitForProbes(2) + // Requests that arrive during the follow-up run coalesce again into exactly one more. + await owner.requestRefresh() + await fake.releaseOne() + await fake.waitForProbes(3) + await fake.releaseOne() + await owner.waitUntilIdle() + #expect(await fake.probeCount == 3) + #expect(await owner.latest?.sequence == 3) + } + + @Test func failuresBackOffWithJitterAndStartANewEpoch() async { + let fake = FakeRunner() + let refused = RemoteCommandResult( + exitCode: 255, output: Data(), outputTruncated: false, + errorOutput: "ssh: connect to host mini port 22: Connection refused\n") + await fake.setOutcomes([.exited(refused), .timedOut, .exited(FakeRunner.success)]) + let recorder = Recorder() + let owner = makeOwner(fake, recorder: recorder) + + await owner.requestRefresh() + await owner.waitUntilIdle() + #expect(await owner.latest?.outcome == .failed(.unreachable)) + #expect(await owner.latest?.connectionEpoch == 1) + await owner.requestRefresh() + await owner.waitUntilIdle() + #expect(await owner.latest?.outcome == .timedOut) + await owner.requestRefresh() + await owner.waitUntilIdle() + #expect(await owner.latest?.outcome == .complete) + // The failure streak kept one epoch; the recovered run reports in it. + #expect(await owner.latest?.connectionEpoch == 1) + // random() == 0.5 keeps the base delay: 0.5 s after one failure, 1 s after two. + #expect(recorder.delays.values == [0.5, 1]) + } + + @Test func shutdownFinishesSubscriptionsAndClosesTheControlConnection() async throws { + let fake = FakeRunner() + let owner = makeOwner(fake) + let (_, stream) = await owner.subscribe() + await owner.shutdown() + var iterator = stream.makeAsyncIterator() + #expect(await iterator.next() == nil) + let exit = try #require(await fake.calls.last) + #expect(exit.arguments.contains("-O")) + #expect(exit.arguments.contains("exit")) + #expect(await owner.subscriberCount == 0) + } + + @Test func truncatedOrForeignOutputIsNeverComplete() { + let truncated = RemoteCommandResult( + exitCode: 0, output: Data(RemoteProbeParserTests.linux.utf8), outputTruncated: true, errorOutput: "") + #expect(RemoteConnectionOwner.interpret(.exited(truncated)).0 == .partial) + let banner = RemoteCommandResult( + exitCode: 0, output: Data("Last login\n".utf8), outputTruncated: false, errorOutput: "") + #expect(RemoteConnectionOwner.interpret(.exited(banner)).0 == .failed(.unrecognizedOutput)) + } + + @Test(arguments: [ + ("Host key verification failed.", RemoteProbeFailure.hostKey), + ("onevcat@mini: Permission denied (publickey).", .authentication), + ("ssh: Could not resolve hostname nowhere: nodename nor servname provided, or not known", .unresolvableHost), + ("ssh: connect to host mini port 22: Operation timed out", .unreachable), + ("Something else", .exit(255, "Something else")), + ]) + func sshErrorsMapToActionableReasons(message: String, expected: RemoteProbeFailure) { + let result = RemoteCommandResult(exitCode: 255, output: Data(), outputTruncated: false, errorOutput: message + "\n") + #expect(RemoteConnectionOwner.failure(result) == expected) + } + + @Test func controlPathStaysWithinTheSocketLimit() { + let path = SSHSideChannel.controlPath(for: Self.key, directory: RemoteConnectionOwner.defaultControlDirectory()) + // `ssh` adds a 17-byte random suffix while it creates the socket; `sun_path` is 104. + #expect(path.utf8.count + 17 < 104) + #expect( + path == SSHSideChannel.controlPath(for: Self.key, directory: RemoteConnectionOwner.defaultControlDirectory())) + let other = ConnectionKey(user: "bob", hostname: "mini.local") + #expect(path != SSHSideChannel.controlPath(for: other, directory: RemoteConnectionOwner.defaultControlDirectory())) + } + + @Test func connectionKeyComesFromTheEffectiveConfiguration() { + let text = """ + user alice + hostname mini.local + port 2022 + proxyjump a@jump1,b@jump2:2200 + sendenv LANG + """ + #expect( + ConnectionKey(effectiveConfiguration: text, configFile: "/tmp/cfg") + == ConnectionKey( + user: "alice", hostname: "mini.local", port: 2022, jumpChain: ["a@jump1", "b@jump2:2200"], + configFile: "/tmp/cfg")) + #expect( + ConnectionKey(effectiveConfiguration: "user a\nhostname h\nport 22\nproxyjump none\n", configFile: nil)?.jumpChain + == []) + #expect(ConnectionKey(effectiveConfiguration: "user a\nport 22\n", configFile: nil) == nil) + } +} + +struct HostProfileTests { + @Test func onlyAnotherUserHostnameOrPortChangesTheAuthority() throws { + let key = ConnectionKey(user: "alice", hostname: "mini.local", port: 22) + var profile = HostProfile( + id: UUID(), alias: "mini", recipe: SSHConnectionRecipe(destination: "mini"), connectionKey: key) + #expect(!profile.authorityChanged(to: key)) + #expect(!profile.authorityChanged(to: ConnectionKey(user: "alice", hostname: "mini.local", jumpChain: ["j"]))) + #expect(profile.authorityChanged(to: ConnectionKey(user: "bob", hostname: "mini.local"))) + #expect(profile.authorityChanged(to: ConnectionKey(user: "alice", hostname: "mini2.local"))) + #expect(profile.authorityChanged(to: ConnectionKey(user: "alice", hostname: "mini.local", port: 2222))) + // Renaming the alias changes nothing a persisted repository refers to. + let id = profile.id + profile.alias = "build box" + let decoded = try JSONDecoder().decode(HostProfile.self, from: JSONEncoder().encode(profile)) + #expect(decoded == profile) + #expect(decoded.id == id) + } +} + +/// The C3 presence rule a pane applies to the owner's snapshots. +struct RemoteProcessPresenceTests { + private func snapshot( + _ sequence: UInt64, epoch: UInt64 = 0, outcome: RemoteProbeSnapshot.Outcome = .complete, at time: TimeInterval = 0 + ) -> RemoteProbeSnapshot { + RemoteProbeSnapshot( + connection: ConnectionKey(user: "u", hostname: "h"), connectionEpoch: epoch, sequence: sequence, + startedAt: time, finishedAt: time, outcome: outcome, + report: outcome == .complete || outcome == .partial + ? RemoteProbeReport(operatingSystem: .linux, bootID: "b", sessions: []) : nil) + } + + @Test func twoConsecutiveCompleteSnapshotsWithoutTheProcessRelease() { + var presence = RemoteProcessPresence() + #expect(presence.observe(snapshot(1), contains: true, now: 0) == .present) + #expect(presence.observe(snapshot(2), contains: false, now: 0) == .missing) + #expect(presence.observe(snapshot(3), contains: false, now: 0) == .released) + } + + @Test func eachSequenceCountsOnceAndInconclusiveRunsNeitherReleaseNorReset() { + var presence = RemoteProcessPresence() + #expect(presence.observe(snapshot(1), contains: true, now: 0) == .present) + #expect(presence.observe(snapshot(2), contains: false, now: 0) == .missing) + // Many pane ticks on one sequence count once. + #expect(presence.observe(snapshot(2), contains: false, now: 0) == .unchanged) + #expect(presence.observe(snapshot(3, outcome: .partial), contains: false, now: 0) == .unchanged) + #expect(presence.observe(snapshot(4, outcome: .failed(.unreachable)), contains: false, now: 0) == .unchanged) + #expect(presence.observe(snapshot(5, outcome: .timedOut), contains: false, now: 0) == .unchanged) + #expect(presence.observe(snapshot(6), contains: false, now: 0) == .released) + } + + @Test func staleSnapshotsAndOlderEpochsProveNothing() { + var presence = RemoteProcessPresence() + #expect(presence.observe(snapshot(1, epoch: 1, at: 0), contains: true, now: 0) == .present) + // Started more than ten seconds ago: no proof of absence. + #expect(presence.observe(snapshot(2, epoch: 1, at: 0), contains: false, now: 11) == .unchanged) + #expect(presence.observe(snapshot(9, epoch: 0, at: 11), contains: false, now: 11) == .unchanged) + #expect(presence.isStale(now: 10.5)) + #expect(!presence.isStale(now: 9)) + // A new epoch restarts the sequence numbering. + #expect(presence.observe(snapshot(1, epoch: 2, at: 12), contains: false, now: 12) == .missing) + } + + @Test func aPartialSnapshotThatShowsTheProcessConfirmsIt() { + var presence = RemoteProcessPresence() + #expect(presence.observe(snapshot(1, outcome: .partial), contains: true, now: 0) == .present) + #expect(presence.lastSeenAt == 0) + } +} diff --git a/App/Tests/RemoteProbeParserTests.swift b/App/Tests/RemoteProbeParserTests.swift new file mode 100644 index 00000000..05ec8c8e --- /dev/null +++ b/App/Tests/RemoteProbeParserTests.swift @@ -0,0 +1,167 @@ +import Foundation +import Testing + +@testable import Prowl + +/// The probe wire format (docs-ai 082, C6) against output recorded on real hosts. +struct RemoteProbeParserTests { + /// Recorded on a macOS 26.5 host (OpenSSH 10.3): Codex and Claude Code started by + /// `ssh -tt host 'cd /tmp && exec "$SHELL" -lc '` with distinct pane ids. + nonisolated static let macOSAgents = + [ + "PROWL-PROBE\t1", + "H\tDarwin\t1783703386.707560", + "S\t22222222-2222-4222-8222-222222222222\t59282\t/dev/ttys000\t59282", + "P\t22222222-2222-4222-8222-222222222222\t59282\t59281\t59282\tSat Oct 17 20:50:38 2026\t20\tcodex\ts\tcodex", + "D\t22222222-2222-4222-8222-222222222222\t59282\t/private/tmp", + "S\t11111111-1111-4111-8111-111111111111\t59286\t/dev/ttys001\t59286", + "P\t11111111-1111-4111-8111-111111111111\t59286\t59284\t59286\tSat Oct 17 20:50:38 2026\t20\t2.1.302\ts\tclaude", + "D\t11111111-1111-4111-8111-111111111111\t59286\t/private/tmp", + "END\t2\t2", + ].joined(separator: "\n") + "\n" + + /// Recorded in a Debian trixie container (OpenSSH 10.0p2). The second session runs + /// `exec -a claude sleep 30` in a directory whose name holds a space, a tab, and `%`. + nonisolated static let linux = + [ + "PROWL-PROBE\t1", + "H\tLinux\td8e2436d-c605-4e3b-92d7-57b6424c1e78", + "S\t44444444-4444-4444-8444-444444444444\t179\t/dev/pts/1\t179", + "P\t44444444-4444-4444-8444-444444444444\t179\t178\t179\t29059914\t3\tsleep\tv\tsleep\u{1F}25", + "D\t44444444-4444-4444-8444-444444444444\t179\t/home/dev", + "S\t33333333-3333-4333-8333-333333333333\t123\t/dev/pts/0\t123", + "P\t33333333-3333-4333-8333-333333333333\t123\t116\t123\t29057954\t22\tsleep\tv\tclaude\u{1F}30", + "D\t33333333-3333-4333-8333-333333333333\t123\t/tmp/odd dir/t%09ab%25x", + "END\t2\t2", + ].joined(separator: "\n") + "\n" + + private static let mini = ConnectionKey(user: "onevcat", hostname: "mini.local") + + private func report(_ text: String) throws -> RemoteProbeReport { + guard case .complete(let report) = RemoteProbeParser.parse(text) else { + Issue.record("expected a complete report") + throw CancellationError() + } + return report + } + + @Test func macOSRecordingIdentifiesBothAgentsWithRemoteReferences() throws { + let report = try report(Self.macOSAgents) + #expect(report.operatingSystem == .darwin) + #expect(report.bootID == "1783703386.707560") + #expect( + report.sessions.map(\.paneID?.uuidString) == [ + "22222222-2222-4222-8222-222222222222", "11111111-1111-4111-8111-111111111111", + ]) + + let codex = try #require(report.sessions[0].foregroundJob(connection: Self.mini, bootID: report.bootID)) + let claude = try #require(report.sessions[1].foregroundJob(connection: Self.mini, bootID: report.bootID)) + let identifiedCodex = try #require(identifyAgentInJob(codex)) + let identifiedClaude = try #require(identifyAgentInJob(claude)) + #expect(identifiedCodex.agent == .codex) + // The native binary's comm is its version; argv identifies it. + #expect(identifiedClaude.agent == .claude) + #expect(identifiedClaude.process.name == "2.1.302") + #expect( + identifiedClaude.process.ref + == .remote( + RemoteProcessRef( + connection: Self.mini, bootID: "1783703386.707560", pid: 59286, startToken: "Sat Oct 17 20:50:38 2026"))) + #expect(identifiedClaude.local == nil) + #expect(report.sessions[1].processes.first?.workingDirectory == "/private/tmp") + // macOS reports a joined command line only. + #expect(report.sessions[1].processes.first?.arguments == nil) + } + + @Test func linuxRecordingKeepsArgumentVectorsAndEscapedFields() throws { + let report = try report(Self.linux) + #expect(report.operatingSystem == .linux) + let session = report.sessions[1] + #expect(session.tty == "/dev/pts/0") + let process = try #require(session.processes.first) + #expect(process.arguments == ["claude", "30"]) + #expect(process.startToken == "29057954") + #expect(process.workingDirectory == "/tmp/odd dir/t\tab%x") + let job = try #require(session.foregroundJob(connection: Self.mini, bootID: report.bootID)) + #expect(identifyAgentInJob(job)?.agent == .claude) + #expect( + identifyAgentInJob(try #require(report.sessions[0].foregroundJob(connection: Self.mini, bootID: "b"))) == nil) + } + + @Test func missingOrMismatchedEndRecordIsPartial() { + let lines = Self.linux.split(separator: "\n").map(String.init) + let truncated = lines.dropLast().joined(separator: "\n") + "\n" + guard case .partial(let partial) = RemoteProbeParser.parse(truncated) else { + Issue.record("expected partial") + return + } + #expect(partial?.sessions.count == 2) + + let mismatched = (lines.dropLast() + ["END\t2\t3"]).joined(separator: "\n") + guard case .partial = RemoteProbeParser.parse(mismatched) else { + Issue.record("expected partial") + return + } + } + + @Test func malformedRecordsMakeTheReportPartial() { + let malformed = [ + "PROWL-PROBE\t1", "H\tLinux\tboot", "S\t-\t10\t/dev/pts/0\t10", + "P\t-\t10\t9\t10\tstart\tnot-a-number\tbash\tv\tbash", "END\t1\t0", + ].joined(separator: "\n") + guard case .partial = RemoteProbeParser.parse(malformed) else { + Issue.record("expected partial") + return + } + let trailing = Self.linux + "S\t-\t1\t-\t1\n" + guard case .partial = RemoteProbeParser.parse(trailing) else { + Issue.record("a record after END is not trusted") + return + } + } + + @Test func aNonUTF8FieldGarblesOnlyItself() throws { + var data = Data( + "PROWL-PROBE\t1\nH\tLinux\tb\nS\t-\t10\t/dev/pts/0\t10\nP\t-\t10\t9\t10\t5\t1\tbash\tv\tbash\u{1F}".utf8) + data.append(contentsOf: [0xFF, 0xFE]) + data.append(Data("\nEND\t1\t1\n".utf8)) + guard case .complete(let report) = RemoteProbeParser.parse(data) else { + Issue.record("expected complete") + return + } + #expect(report.sessions.first?.processes.first?.arguments == ["bash", "\u{FF}\u{FE}"]) + } + + @Test func foreignOutputIsUnrecognized() { + #expect(RemoteProbeParser.parse("") == .unrecognized) + #expect(RemoteProbeParser.parse("Welcome to the host!\nPROWL-PROBE\t1\n") == .unrecognized) + #expect(RemoteProbeParser.parse("PROWL-PROBE\t2\nH\tLinux\tb\nEND\t0\t0\n") == .unrecognized) + } + + @Test func unescapeReversesTheProbeEscapes() { + #expect(RemoteProbeParser.unescape("a%09b%0Ac%0Dd%25e") == "a\tb\nc\rd%e") + // Malformed escapes stay literal. + #expect(RemoteProbeParser.unescape("100%") == "100%") + #expect(RemoteProbeParser.unescape("%zz") == "%zz") + } + + @Test func scriptRunsOnThisMacAndProducesACompleteReport() throws { + let process = Process() + process.executableURL = URL(filePath: "/bin/sh") + process.arguments = ["-s"] + let input = Pipe() + let output = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = FileHandle.nullDevice + try process.run() + try input.fileHandleForWriting.write(contentsOf: Data(RemoteProbeScript.text.utf8)) + try input.fileHandleForWriting.close() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + #expect(process.terminationStatus == 0) + let report = try report(String(bytes: data, encoding: .utf8) ?? "") + #expect(report.operatingSystem == .darwin) + #expect(!report.bootID.isEmpty) + } +} diff --git a/App/Tests/RemoteShellCommandTests.swift b/App/Tests/RemoteShellCommandTests.swift new file mode 100644 index 00000000..d5e01371 --- /dev/null +++ b/App/Tests/RemoteShellCommandTests.swift @@ -0,0 +1,69 @@ +import Foundation +import Testing + +@testable import Prowl + +/// The single quoting boundary for remote work (docs-ai 082, C5). The script runs in a +/// local `/bin/sh -s`, which is what the host's `sh -s` receives. +struct RemoteShellCommandTests { + private func run(_ script: String) throws -> (status: Int32, output: String) { + let process = Process() + process.executableURL = URL(filePath: "/bin/sh") + process.arguments = ["-s"] + let input = Pipe() + let output = Pipe() + process.standardInput = input + process.standardOutput = output + process.standardError = FileHandle.nullDevice + try process.run() + try input.fileHandleForWriting.write(contentsOf: Data(script.utf8)) + try input.fileHandleForWriting.close() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return (process.terminationStatus, String(bytes: data, encoding: .utf8) ?? "") + } + + @Test func argumentsArriveByteForByte() throws { + let hostile = [ + "", "two words", "it's", #"back\slash"#, "$HOME", "$(echo nope)", "`echo nope`", "*", "line\nbreak", + "tab\there", "ünïcødé", "--", "-n", + ] + let command = RemoteShellCommand(executable: "/usr/bin/printf", arguments: ["[%s]\n"] + hostile) + let result = try run(try command.script()) + #expect(result.status == 0) + #expect(result.output == hostile.map { "[\($0)]\n" }.joined()) + } + + @Test func workingDirectoryAndEnvironmentApplyBeforeExec() throws { + let directory = FileManager.default.temporaryDirectory.appending( + path: "prowl remote 'cmd' %x", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let command = RemoteShellCommand( + executable: "/bin/sh", arguments: ["-c", #"printf '%s|%s' "$(pwd -P)" "$PROWL_TEST_VALUE""#], + workingDirectory: directory.path(percentEncoded: false), environment: [("PROWL_TEST_VALUE", "a 'b' $c")]) + let result = try run(try command.script()) + #expect(result.status == 0) + #expect(result.output.hasSuffix("/prowl remote 'cmd' %x|a 'b' $c")) + } + + @Test func missingWorkingDirectoryFailsWithItsOwnStatus() throws { + let command = RemoteShellCommand(executable: "/usr/bin/true", workingDirectory: "/nonexistent/prowl/path") + #expect(try run(try command.script()).status == RemoteShellCommand.workingDirectoryFailureStatus) + } + + @Test func invalidInputIsRejectedInsteadOfQuoted() { + #expect(throws: RemoteShellCommand.ValidationError.emptyExecutable) { + try RemoteShellCommand(executable: "").script() + } + #expect(throws: RemoteShellCommand.ValidationError.invalidEnvironmentName("A-B")) { + try RemoteShellCommand(executable: "env", environment: [("A-B", "x")]).script() + } + #expect(throws: RemoteShellCommand.ValidationError.invalidEnvironmentName("1A")) { + try RemoteShellCommand(executable: "env", environment: [("1A", "x")]).script() + } + #expect(throws: RemoteShellCommand.ValidationError.nulByte) { + try RemoteShellCommand(executable: "echo", arguments: ["a\u{0}b"]).script() + } + } +} diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 2cdcaade..888bffae 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -7,7 +7,8 @@ sections below record what shipped and what differs from the plan. | Date | Change | Ref | | --- | --- | --- | -| 2026-10-17 | S1: typed process references (C2) and the `ConnectionKey` value type; local-only OS entry points; evidence retirement on an origin change. No behaviour change for local panes | S1 PR | +| 2026-10-17 | S1: typed process references (C2) and the `ConnectionKey` value type; local-only OS entry points; evidence retirement on an origin change. No behaviour change for local panes. Review Loop clean after 3 rounds | #924 | +| 2026-10-17 | S2: host identity (C1), connection owner (C4), snapshot contract (C3), structured commands (C5), probe and parser (C6), all unused by the app | S2 PR | ## S1 — Typed process references @@ -48,3 +49,74 @@ sections below record what shipped and what differs from the plan. The change is timestamped when the probe that saw it returns, so reports that landed while that probe ran are left out too (review round 2); the screen decides until the next report. + +## S2 — Remote contracts + +All under `App/Sources/Infrastructure/RemoteHost/`; nothing in the app calls it yet. + +- **C6 probe** (`RemoteProbeScript`). A POSIX `sh` script piped to `sh -s`. Output: one + record per line, TAB-separated, fields escaped (`%25`, `%09`, `%0A`, `%0D`), framed by + `PROWL-PROBE 1` and `END `. + - Records: `H` (OS, boot id), `S` (pane id, anchor, tty, tpgid), `P` (pid, ppid, pgid, + native start token, elapsed, comm, argv), `D` (cwd). + - Linux: owned `/proc/*/stat` files are read in one pass, plus `environ`, `cmdline` and + `cwd` for anchors and foreground members only. The start token is the start tick, + the boot id is `boot_id`, and argv is a US-separated vector. + - macOS: one `ps -A` pass for the table, one for `ucomm`, one `ps -E` and one `lsof` + for the foreground members. The start token is `lstart`, the boot id is + `kern.boottime`, and argv is the joined string. + - Cost: 0.14–0.22 s warm on mini (729 processes) and 0.13 s in the Debian container. +- **Parser** (`RemoteProbeParser`). It works on bytes and decodes field by field: UTF-8, + else Latin-1, so a non-UTF-8 argv garbles only its own field. It returns `complete` only + when the header, host record, records and `END` counts are all consistent; anything + else is `partial` or `unrecognized` (for example a login banner). Fixtures were recorded + on mini (Claude Code 2.1.302 with `comm` = version, Codex 0.163) and in the container + (a cwd with a space, a tab and `%`). +- **C4 owner** (`RemoteConnectionOwner`, `RemoteConnectionRegistry`). + - One actor per `ConnectionKey`, at most one probe in flight. A request while busy + coalesces into one follow-up run. + - After a failure: a jittered backoff (0.5 s doubling to 8 s), and the next run starts + a new connection epoch. + - Each snapshot carries epoch, sequence, start and end (local monotonic), and an outcome + (`complete` / `partial` / `failed(reason)` / `timedOut`). + - Failure reasons for the UX: authentication, host key, unresolvable, unreachable, + launch, exit, unrecognized output. + - `shutdown` sends `-O exit`. + - The side channel is `ssh -o ControlMaster=auto -o ControlPath= + -o ControlPersist=60 -T -- sh -s`. The overrides come + first, because `ssh` keeps the first value it obtains: + - `BatchMode`, `ClearAllForwardings`, `RemoteCommand=none`, `RequestTTY=no`, + `PermitLocalCommand=no` + - no agent, X11 or tunnel forwarding + - `SessionType=default`, no fork, no escape character, `LogLevel=ERROR`, server-alive + checks + - Checked against a configuration with `LocalForward`, `RemoteCommand`, `LocalCommand` + and `SessionType none`: nothing ran and nothing listened. Without the overrides, the + forward and the local command ran. + - **Finding:** `ConnectTimeout` together with `ControlPersist` (OpenSSH 10.3) holds the + first command's output open for about 10 s, so it is not used; the runner's 5 s + deadline bounds the whole command. + - The ControlPath is a SHA-256 prefix of the key, under the user temp directory (78 + bytes, below the 104-byte `sun_path` minus the 17-byte creation suffix). +- **Runner** (`RemoteCommandRunner`). `posix_spawn` in a new process group, so a stop + also reaches a `ProxyCommand`. + - Stdin input, a 256 KiB output cap (truncation stops the command and makes the + snapshot `partial`), a 5 s deadline, and SIGTERM followed by SIGKILL after 1 s. + - The `waitpid`, read and write threads start before the first suspension. With + `Foundation.Process`, or with work started from `async let`, a busy cooperative pool + delayed the exit notification by 40–50 s under the full test suite, and finished + commands were reported as timeouts. +- **C3** (`RemoteProbeSnapshot`, `RemoteProcessPresence`). A snapshot is fresh for 10 s. + Presence counts each sequence once, ignores older epochs and inconclusive or stale runs, + and releases after two consecutive complete fresh snapshots without the process. +- **C5** (`RemoteShellCommand`). Executable, argv, cwd and environment become one POSIX + script for `sh -s`: `cd -- … || exit 97`, then exported variables, then `exec`. It + rejects NUL bytes and invalid variable names. Tested by running the script in a local + `/bin/sh` with hostile argv. +- **C1** (`HostProfile`, `ConnectionKey(effectiveConfiguration:)`). The key comes from + `ssh -G`. `authorityChanged(to:)` flags another user, hostname or port; persistence and + the rebind prompt come with L1. +- **Live check.** `RemoteConnectionLiveTests` runs only with + `TEST_RUNNER_PROWL_TEST_SSH_HOST`. It passed against mini and the Debian container + (`-p 2222`): two complete snapshots on one master, and no socket left after release. + -- 2.54.0 From 1007899b9c80e17d894905166881af66a5ef34b3 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 17 Oct 2026 22:32:58 +0900 Subject: [PATCH 04/51] Harden the remote probe, runner and registry - Pass untrusted argv and cwd to awk through ENVIRON, keep trailing newlines. - Mark the cwd encoding and decode the lsof display form on macOS. - Keep the runner budget until the command's I/O ends; poll-based reads are abandoned after the kill grace. - Lease owners in the registry and let a reacquire wait for the retiring owner's control connection to close. --- .../RemoteHost/RemoteCommandRunner.swift | 68 +++++++++++------- .../RemoteHost/RemoteConnectionRegistry.swift | 39 +++++++++-- .../RemoteHost/RemoteProbeParser.swift | 48 ++++++++++++- .../RemoteHost/RemoteProbeScript.swift | 20 ++++-- App/Tests/RemoteCommandRunnerTests.swift | 18 +++++ App/Tests/RemoteConnectionLiveTests.swift | 4 +- App/Tests/RemoteConnectionOwnerTests.swift | 69 +++++++++++++++++++ App/Tests/RemoteProbeParserTests.swift | 38 ++++++++-- docs-ai/082-ssh-remote-agents/001-action.md | 17 ++++- 9 files changed, 273 insertions(+), 48 deletions(-) diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift b/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift index c8ecb4bb..1f63ab7b 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift @@ -46,8 +46,12 @@ nonisolated struct RemoteCommandRunner: Sendable { private nonisolated final class RemoteProcess: @unchecked Sendable { private let lock = NSLock() private let processGroup: pid_t - private var exited = false + /// Every part (exit status, output, error output) arrived; nothing is stopped after it. + private var done = false private var timedOut = false + /// Set after the kill grace: readers return what they have even if a descendant that + /// left the process group still holds a pipe. + private var abandonReads = false private var status: Int32? private var output: (Data, Bool)? private var errorOutput: Data? @@ -57,30 +61,37 @@ private nonisolated final class RemoteProcess: @unchecked Sendable { self.processGroup = processGroup } - /// SIGTERM to the group, SIGKILL after the grace period if it is still there. + /// SIGTERM to the group, then SIGKILL and abandoned reads after the grace period. It + /// acts until the command's I/O is finished, not only until the leader exits: a + /// descendant that keeps a pipe open is part of the command. func stop() { - guard lock.withLock({ !exited }) else { return } + guard lock.withLock({ !done }) else { return } kill(-processGroup, SIGTERM) DispatchQueue(label: "com.onevcat.prowl.remote-command.kill") .asyncAfter(deadline: .now() + RemoteCommandRunner.killGrace) { [self] in - if lock.withLock({ !exited }) { kill(-processGroup, SIGKILL) } + guard lock.withLock({ !done }) else { return } + kill(-processGroup, SIGKILL) + lock.withLock { abandonReads = true } } } private func expire() { let expired = lock.withLock { - guard !exited else { return false } + guard !done else { return false } timedOut = true return true } if expired { stop() } } + private var readsAbandoned: Bool { lock.withLock { abandonReads } } + /// Each part arrives from its own thread; the last one resumes the caller. private func finish(_ update: (RemoteProcess) -> Void) { let ready: (CheckedContinuation, RemoteCommandOutcome)? = lock.withLock { update(self) guard let status, let output, let errorOutput, let continuation else { return nil } + done = true self.continuation = nil let outcome: RemoteCommandOutcome = timedOut @@ -126,11 +137,11 @@ private nonisolated final class RemoteProcess: @unchecked Sendable { try? handle.close() } Thread.detachNewThread { [self] in - let output = Self.read(pipes.output.fileHandleForReading, limit: limits.outputBytes) { self.stop() } + let output = read(pipes.output.fileHandleForReading, limit: limits.outputBytes) { self.stop() } finish { $0.output = output } } Thread.detachNewThread { [self] in - let (errorOutput, _) = Self.read(pipes.error.fileHandleForReading, limit: limits.errorBytes, onLimit: nil) + let (errorOutput, _) = read(pipes.error.fileHandleForReading, limit: limits.errorBytes, onLimit: nil) finish { $0.errorOutput = errorOutput } } Thread.detachNewThread { [self] in @@ -139,10 +150,7 @@ private nonisolated final class RemoteProcess: @unchecked Sendable { let signal = status & 0x7F // A signal death reports 128 + the signal number, as a shell does. let code = signal == 0 ? (status >> 8) & 0xFF : 128 + signal - finish { - $0.exited = true - $0.status = code - } + finish { $0.status = code } } DispatchQueue(label: "com.onevcat.prowl.remote-command.deadline") .asyncAfter(deadline: .now() + limits.deadline) { [self] in expire() } @@ -189,20 +197,34 @@ private nonisolated final class RemoteProcess: @unchecked Sendable { return .success(pid) } - /// Reads until EOF, keeping at most `limit` bytes. Reaching the limit calls `onLimit` - /// once and keeps draining, so the writer never blocks on a full pipe. - private static func read(_ handle: FileHandle, limit: Int, onLimit: (() -> Void)?) -> (Data, Bool) { + /// Reads until EOF or abandonment, keeping at most `limit` bytes. Reaching the limit + /// calls `onLimit` once and keeps draining, so the writer never blocks on a full pipe. + private func read(_ handle: FileHandle, limit: Int, onLimit: (() -> Void)?) -> (Data, Bool) { + let descriptor = handle.fileDescriptor var data = Data() var truncated = false - while let chunk = try? handle.read(upToCount: 64 * 1024), !chunk.isEmpty { - guard !truncated else { continue } - let room = limit - data.count - if chunk.count > room { - data.append(chunk.prefix(room)) - truncated = true - onLimit?() - } else { - data.append(chunk) + var buffer = [UInt8](repeating: 0, count: 64 * 1024) + var poller = pollfd(fd: descriptor, events: Int16(POLLIN), revents: 0) + reading: while !readsAbandoned { + let ready = poll(&poller, 1, 100) + if ready == 0 || (ready < 0 && errno == EINTR) { continue } + guard ready > 0 else { break } + let count = buffer.withUnsafeMutableBytes { Darwin.read(descriptor, $0.baseAddress, $0.count) } + switch count { + case ..<0 where errno == EINTR || errno == EAGAIN: + continue + case ...0: + break reading + default: + guard !truncated else { continue } + let room = limit - data.count + if count > room { + data.append(contentsOf: buffer[..] = [:] init( configuration: RemoteConnectionOwner.Configuration = RemoteConnectionOwner.Configuration(), @@ -27,18 +33,37 @@ actor RemoteConnectionRegistry { effectiveConfiguration: String(bytes: result.output, encoding: .utf8) ?? "", configFile: recipe.configFile) } - /// The owner for `key`; the first recipe that reached the key is the one it connects with. - func owner(for key: ConnectionKey, recipe: SSHConnectionRecipe) -> RemoteConnectionOwner { + /// Leases the owner for `key`; the first recipe that reached the key is the one it + /// connects with. Every acquire needs one `release`. + func acquire(_ key: ConnectionKey, recipe: SSHConnectionRecipe) async -> RemoteConnectionOwner { + while let retirement = retirements[key] { + await retirement.value + // A finished task returns without suspending, so whoever sees it end removes it. + if retirements[key] == retirement { retirements.removeValue(forKey: key) } + } + leases[key, default: 0] += 1 if let owner = owners[key] { return owner } let owner = RemoteConnectionOwner(connection: key, recipe: recipe, configuration: configuration, runner: runner) owners[key] = owner return owner } - /// Shuts the owner down once nothing subscribes to it any more. - func releaseIfUnused(_ key: ConnectionKey) async { - guard let owner = owners[key], await owner.subscriberCount == 0 else { return } - owners.removeValue(forKey: key) - await owner.shutdown() + /// Ends one lease. The last one shuts the owner down and returns after it closed. + func release(_ key: ConnectionKey) async { + guard let count = leases[key] else { return } + guard count == 1 else { + leases[key] = count - 1 + return + } + leases.removeValue(forKey: key) + guard let owner = owners.removeValue(forKey: key) else { return } + let retirement = Task { await owner.shutdown() } + retirements[key] = retirement + await retirement.value + if retirements[key] == retirement { retirements.removeValue(forKey: key) } + } + + func leaseCount(_ key: ConnectionKey) -> Int { + leases[key] ?? 0 } } diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift index 238920d6..ef81823c 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift @@ -58,6 +58,44 @@ nonisolated enum RemoteProbeParser { decode(unescape(Data(field.utf8))) } + /// Reverses the display form `lsof` gives a name under `LC_ALL=C`: `\\`, `\t`, `\n`, `\r`, + /// `\b`, `\f`, `\xNN`, and `^X` for other control characters. A literal `^` before one + /// of `@A-Z[\]^_` cannot be told apart from a control character and decodes as one. + static func lsofName(_ text: String) -> String { + var bytes = Data() + var iterator = Array(text.utf8)[...] + let controls: [UInt8: UInt8] = [ + UInt8(ascii: "t"): 0x09, UInt8(ascii: "n"): 0x0A, UInt8(ascii: "r"): 0x0D, UInt8(ascii: "b"): 0x08, + UInt8(ascii: "f"): 0x0C, UInt8(ascii: "\\"): UInt8(ascii: "\\"), + ] + while let byte = iterator.popFirst() { + switch byte { + case UInt8(ascii: "\\"): + if let next = iterator.first, let control = controls[next] { + bytes.append(control) + iterator = iterator.dropFirst() + } else if iterator.first == UInt8(ascii: "x"), iterator.count >= 3, + let hex = String(bytes: iterator.dropFirst().prefix(2), encoding: .ascii), let value = UInt8(hex, radix: 16) + { + bytes.append(value) + iterator = iterator.dropFirst(3) + } else { + bytes.append(byte) + } + case UInt8(ascii: "^"): + if let next = iterator.first, (0x40...0x5F).contains(next) { + bytes.append(next - 0x40) + iterator = iterator.dropFirst() + } else { + bytes.append(byte) + } + default: + bytes.append(byte) + } + } + return decode(bytes) + } + /// UTF-8 when valid; otherwise Latin-1, which maps every byte, so the record survives. private static func decode(_ bytes: Data) -> String { String(bytes: bytes, encoding: .utf8) ?? String(bytes: bytes, encoding: .isoLatin1) ?? "" @@ -149,10 +187,16 @@ nonisolated enum RemoteProbeParser { } private mutating func applyDirectory(_ record: [String]) -> Bool { - guard record.count == 4, let pid = Int32(record[2]), !sessions.isEmpty, + guard record.count == 5, let pid = Int32(record[2]), !sessions.isEmpty, let index = sessions[sessions.count - 1].processes.lastIndex(where: { $0.pid == pid }) else { return false } - sessions[sessions.count - 1].processes[index].workingDirectory = record[3] + let path: String + switch record[3] { + case "r": path = record[4] + case "l": path = RemoteProbeParser.lsofName(record[4]) + default: return false + } + sessions[sessions.count - 1].processes[index].workingDirectory = path return true } diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift index 113e94c8..aa82c393 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift @@ -17,7 +17,8 @@ nonisolated enum RemoteProbeScript { # a member of its foreground job; # argv mode v = US-separated vector, # s = one space-joined string - # D + # D r = raw bytes, l = lsof's escaped + # display form (macOS) # END

# A snapshot without END is incomplete. LC_ALL=C @@ -63,19 +64,24 @@ nonisolated enum RemoteProbeScript { printf 'S\t%s\t%s\t%s\t%s\n' "${pane:--}" "$apid" "${tty:--}" "$tpgid" scount=$((scount + 1)) for m in $(printf '%s\n' "$table" | awk -v g="$tpgid" '$3 == g { print $1 }'); do - argv=$(tr '\0' '\037' 2>/dev/null < "/proc/$m/cmdline") - cwd=$(readlink "/proc/$m/cwd" 2>/dev/null) - printf '%s\n' "$table" | awk -v m="$m" -v pane="${pane:--}" -v up="$up" -v hz="$hz" \ - -v argv="$argv" -v cwd="$cwd" ' + # The trailing x keeps trailing newlines that command substitution would strip. + argv=$(tr '\0' '\037' 2>/dev/null < "/proc/$m/cmdline"; printf x) + argv=${argv%x} + cwd=$(readlink "/proc/$m/cwd" 2>/dev/null && printf x) + [ -n "$cwd" ] && cwd=${cwd%?x} + # Untrusted strings reach awk through ENVIRON: `-v` would decode backslash escapes. + printf '%s\n' "$table" | PROWL_PANE="${pane:--}" PROWL_ARGV="$argv" PROWL_CWD="$cwd" \ + awk -v m="$m" -v up="$up" -v hz="$hz" ' function esc(s) { gsub(/%/, "%25", s); gsub(/\t/, "%09", s); gsub(/\n/, "%0A", s); gsub(/\r/, "%0D", s); return s } $1 == m { + pane = ENVIRON["PROWL_PANE"]; argv = ENVIRON["PROWL_ARGV"]; cwd = ENVIRON["PROWL_CWD"] comm = $0; sub(/^[^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ /, "", comm) sub(/\037$/, "", argv) printf "P\t%s\t%s\t%s\t%s\t%s\t%d\t%s\tv\t%s\n", \ esc(pane), $1, $2, $3, $5, up - $5 / hz, esc(comm), esc(argv) - if (cwd != "") printf "D\t%s\t%s\t%s\n", esc(pane), $1, esc(cwd) + if (cwd != "") printf "D\t%s\t%s\tr\t%s\n", esc(pane), $1, esc(cwd) }' pcount=$((pcount + 1)) done @@ -157,7 +163,7 @@ nonisolated enum RemoteProbeScript { m = ms[j] printf "P\t%s\t%s\t%s\t%s\t%s\t%d\t%s\ts\t%s\n", \ esc(pn), m, ppid[m], pgid[m], esc(start[m]), seconds(et[m]), esc(name[m]), esc(args[m]) - if (m in cwd) printf "D\t%s\t%s\t%s\n", esc(pn), m, esc(cwd[m]) + if (m in cwd) printf "D\t%s\t%s\tl\t%s\n", esc(pn), m, esc(cwd[m]) pc++ } } diff --git a/App/Tests/RemoteCommandRunnerTests.swift b/App/Tests/RemoteCommandRunnerTests.swift index fef3b519..4b7a86c9 100644 --- a/App/Tests/RemoteCommandRunnerTests.swift +++ b/App/Tests/RemoteCommandRunnerTests.swift @@ -41,6 +41,24 @@ nonisolated struct RemoteCommandRunnerTests { #expect(clock.now - started < .seconds(20)) } + @Test(arguments: [ + // The leader exits at once; a child it left behind keeps stdout open. + "sleep 30 & exit 0", + // The descendant ignores SIGTERM; SIGKILL after the grace period ends it. + "(trap '' TERM; sleep 30) & wait", + // The descendant leaves the process group; the reads are abandoned after the grace. + "/usr/bin/perl -e 'setpgrp(0, 0); sleep 8' & wait", + ]) + func theBudgetCoversDescendantsThatKeepThePipesOpen(script: String) async { + let clock = ContinuousClock() + let started = clock.now + let outcome = await runner.run( + URL(filePath: "/bin/sh"), ["-c", script], Data(), RemoteCommandLimits(deadline: 0.3, outputBytes: 1024)) + #expect(outcome == .timedOut) + // Deadline plus kill grace plus one poll interval, with room for a loaded machine. + #expect(clock.now - started < .seconds(6)) + } + @Test func aSignalDeathReportsTheShellStatus() async { let outcome = await runner.run(URL(filePath: "/bin/sh"), ["-c", "kill -9 $$"], Data(), RemoteCommandLimits()) guard case .exited(let result) = outcome else { diff --git a/App/Tests/RemoteConnectionLiveTests.swift b/App/Tests/RemoteConnectionLiveTests.swift index 7e2d06a8..81e71886 100644 --- a/App/Tests/RemoteConnectionLiveTests.swift +++ b/App/Tests/RemoteConnectionLiveTests.swift @@ -21,7 +21,7 @@ struct RemoteConnectionLiveTests { let registry = RemoteConnectionRegistry( configuration: RemoteConnectionOwner.Configuration(controlDirectory: directory.path(percentEncoded: false))) let key = try #require(await registry.resolve(recipe)) - let owner = await registry.owner(for: key, recipe: recipe) + let owner = await registry.acquire(key, recipe: recipe) let (id, _) = await owner.subscribe() await owner.requestRefresh() await owner.waitUntilIdle() @@ -36,7 +36,7 @@ struct RemoteConnectionLiveTests { #expect(second.sequence == first.sequence + 1) #expect(second.report?.bootID == first.report?.bootID) await owner.unsubscribe(id) - await registry.releaseIfUnused(key) + await registry.release(key) #expect(!FileManager.default.fileExists(atPath: await owner.controlPath)) } } diff --git a/App/Tests/RemoteConnectionOwnerTests.swift b/App/Tests/RemoteConnectionOwnerTests.swift index 86ed8a34..45d06d80 100644 --- a/App/Tests/RemoteConnectionOwnerTests.swift +++ b/App/Tests/RemoteConnectionOwnerTests.swift @@ -22,11 +22,40 @@ struct RemoteConnectionOwnerTests { private var held: [CheckedContinuation] = [] private var enteredWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + var holdsExit = false + private var heldExit: CheckedContinuation? + private var exitWaiter: CheckedContinuation? + var events: [String] = [] + func setOutcomes(_ outcomes: [RemoteCommandOutcome]) { self.outcomes = outcomes } func setHolds(_ holds: Bool) { self.holds = holds } + func setHoldsExit(_ holds: Bool) { holdsExit = holds } + + func waitForExitCall() async { + guard heldExit == nil else { return } + await withCheckedContinuation { exitWaiter = $0 } + } + + func releaseExit() { + heldExit?.resume() + heldExit = nil + } func run(_ arguments: [String], _ input: Data) async -> RemoteCommandOutcome { calls.append(FakeCall(arguments: arguments, input: input)) + if arguments.contains("-O") { + events.append("exit started") + if holdsExit { + await withCheckedContinuation { continuation in + heldExit = continuation + exitWaiter?.resume() + exitWaiter = nil + } + } + events.append("exit finished") + return .exited(Self.success) + } + if arguments.last == "-s" { events.append("probe") } let probes = calls.filter { $0.arguments.last == "-s" }.count for waiter in enteredWaiters where waiter.count <= probes { waiter.continuation.resume() } enteredWaiters.removeAll { $0.count <= probes } @@ -154,6 +183,46 @@ struct RemoteConnectionOwnerTests { #expect(await owner.subscriberCount == 0) } + @Test func aReacquiredKeyWaitsUntilTheRetiringOwnerClosed() async { + let fake = FakeRunner() + await fake.setHoldsExit(true) + let registry = RemoteConnectionRegistry( + configuration: RemoteConnectionOwner.Configuration(controlDirectory: "/tmp/prowl-ssh-tests"), + runner: RemoteCommandRunner { _, arguments, input, _ in await fake.run(arguments, input) }) + let first = await registry.acquire(Self.key, recipe: Self.recipe) + #expect(await registry.leaseCount(Self.key) == 1) + let retiring = Task { await registry.release(Self.key) } + await fake.waitForExitCall() + let reacquired = Task { () -> RemoteConnectionOwner in + let owner = await registry.acquire(Self.key, recipe: Self.recipe) + await owner.requestRefresh() + await owner.waitUntilIdle() + return owner + } + await fake.releaseExit() + await retiring.value + let second = await reacquired.value + #expect(first !== second) + #expect(await fake.events == ["exit started", "exit finished", "probe"]) + await fake.setHoldsExit(false) + await registry.release(Self.key) + } + + @Test func onlyTheLastLeaseRetiresTheOwner() async { + let fake = FakeRunner() + let registry = RemoteConnectionRegistry( + configuration: RemoteConnectionOwner.Configuration(controlDirectory: "/tmp/prowl-ssh-tests"), + runner: RemoteCommandRunner { _, arguments, input, _ in await fake.run(arguments, input) }) + let first = await registry.acquire(Self.key, recipe: Self.recipe) + let second = await registry.acquire(Self.key, recipe: Self.recipe) + #expect(first === second) + await registry.release(Self.key) + #expect(await fake.events.isEmpty) + await registry.release(Self.key) + #expect(await fake.events == ["exit started", "exit finished"]) + #expect(await registry.leaseCount(Self.key) == 0) + } + @Test func truncatedOrForeignOutputIsNeverComplete() { let truncated = RemoteCommandResult( exitCode: 0, output: Data(RemoteProbeParserTests.linux.utf8), outputTruncated: true, errorOutput: "") diff --git a/App/Tests/RemoteProbeParserTests.swift b/App/Tests/RemoteProbeParserTests.swift index 05ec8c8e..232e5bba 100644 --- a/App/Tests/RemoteProbeParserTests.swift +++ b/App/Tests/RemoteProbeParserTests.swift @@ -13,10 +13,10 @@ struct RemoteProbeParserTests { "H\tDarwin\t1783703386.707560", "S\t22222222-2222-4222-8222-222222222222\t59282\t/dev/ttys000\t59282", "P\t22222222-2222-4222-8222-222222222222\t59282\t59281\t59282\tSat Oct 17 20:50:38 2026\t20\tcodex\ts\tcodex", - "D\t22222222-2222-4222-8222-222222222222\t59282\t/private/tmp", + "D\t22222222-2222-4222-8222-222222222222\t59282\tl\t/private/tmp", "S\t11111111-1111-4111-8111-111111111111\t59286\t/dev/ttys001\t59286", "P\t11111111-1111-4111-8111-111111111111\t59286\t59284\t59286\tSat Oct 17 20:50:38 2026\t20\t2.1.302\ts\tclaude", - "D\t11111111-1111-4111-8111-111111111111\t59286\t/private/tmp", + "D\t11111111-1111-4111-8111-111111111111\t59286\tl\t/private/tmp", "END\t2\t2", ].joined(separator: "\n") + "\n" @@ -28,10 +28,10 @@ struct RemoteProbeParserTests { "H\tLinux\td8e2436d-c605-4e3b-92d7-57b6424c1e78", "S\t44444444-4444-4444-8444-444444444444\t179\t/dev/pts/1\t179", "P\t44444444-4444-4444-8444-444444444444\t179\t178\t179\t29059914\t3\tsleep\tv\tsleep\u{1F}25", - "D\t44444444-4444-4444-8444-444444444444\t179\t/home/dev", + "D\t44444444-4444-4444-8444-444444444444\t179\tr\t/home/dev", "S\t33333333-3333-4333-8333-333333333333\t123\t/dev/pts/0\t123", "P\t33333333-3333-4333-8333-333333333333\t123\t116\t123\t29057954\t22\tsleep\tv\tclaude\u{1F}30", - "D\t33333333-3333-4333-8333-333333333333\t123\t/tmp/odd dir/t%09ab%25x", + "D\t33333333-3333-4333-8333-333333333333\t123\tr\t/tmp/odd dir/t%09ab%25x", "END\t2\t2", ].joined(separator: "\n") + "\n" @@ -132,6 +132,36 @@ struct RemoteProbeParserTests { #expect(report.sessions.first?.processes.first?.arguments == ["bash", "\u{FF}\u{FE}"]) } + @Test func lsofNamesDecodeToTheRealPath() { + // `lsof -Fn` under LC_ALL=C on macOS 26.5 for `/tmp/ab\c^d%e 中文`, `nlx`, + // and `x<0x01>yzwvuq`. + #expect( + RemoteProbeParser.lsofName(#"/tmp/a\tb\\c^d%e \xe4\xb8\xad\xe6\x96\x87"#) == "/tmp/a\tb\\c^d%e 中文") + #expect(RemoteProbeParser.lsofName(#"/tmp/nl\nx"#) == "/tmp/nl\nx") + #expect( + RemoteProbeParser.lsofName(#"/tmp/x^Ay\rz^[w\x7fv\bu\fq"#) == "/tmp/x\u{1}y\rz\u{1B}w\u{7F}v\u{8}u\u{C}q") + // Recorded over ssh for a shell in `/tmp/中文 \xy`. + #expect( + RemoteProbeParser.lsofName(#"/private/tmp/\xe4\xb8\xad\xe6\x96\x87 \\x\ty"#) == "/private/tmp/中文 \\x\ty") + // An unknown escape stays literal. + #expect(RemoteProbeParser.lsofName(#"a\qb"#) == #"a\qb"#) + } + + @Test func directoryEncodingIsChecked() { + let base = ["PROWL-PROBE\t1", "H\tDarwin\tb", "S\t-\t10\t/dev/ttys000\t10", "P\t-\t10\t9\t10\ts\t1\tzsh\ts\tzsh"] + let lsof = (base + ["D\t-\t10\tl\t" + #"/Users/me/\xe4\xb8\xad"#, "END\t1\t1"]).joined(separator: "\n") + guard case .complete(let report) = RemoteProbeParser.parse(lsof) else { + Issue.record("expected complete") + return + } + #expect(report.sessions.first?.processes.first?.workingDirectory == "/Users/me/中") + let unknown = (base + ["D\t-\t10\tq\t/x", "END\t1\t1"]).joined(separator: "\n") + guard case .partial = RemoteProbeParser.parse(unknown) else { + Issue.record("expected partial") + return + } + } + @Test func foreignOutputIsUnrecognized() { #expect(RemoteProbeParser.parse("") == .unrecognized) #expect(RemoteProbeParser.parse("Welcome to the host!\nPROWL-PROBE\t1\n") == .unrecognized) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 888bffae..e1af2147 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -61,10 +61,15 @@ All under `App/Sources/Infrastructure/RemoteHost/`; nothing in the app calls it native start token, elapsed, comm, argv), `D` (cwd). - Linux: owned `/proc/*/stat` files are read in one pass, plus `environ`, `cmdline` and `cwd` for anchors and foreground members only. The start token is the start tick, - the boot id is `boot_id`, and argv is a US-separated vector. + the boot id is `boot_id`, and argv is a US-separated vector. Untrusted strings + reach awk through `ENVIRON`, because `-v` decodes backslash escapes; a trailing + newline survives command substitution (review round 1). - macOS: one `ps -A` pass for the table, one for `ucomm`, one `ps -E` and one `lsof` for the foreground members. The start token is `lstart`, the boot id is - `kern.boottime`, and argv is the joined string. + `kern.boottime`, and argv is the joined string. `lsof` prints names in an escaped + display form (`\\`, `\t`, `\n`, `\xNN`, `^X`); the `D` record marks it (`l`, Linux `r`) + and the parser decodes it, so a cwd with Chinese characters, a backslash or a tab + round-trips (review round 1). - Cost: 0.14–0.22 s warm on mini (729 processes) and 0.13 s in the Debian container. - **Parser** (`RemoteProbeParser`). It works on bytes and decodes field by field: UTF-8, else Latin-1, so a non-UTF-8 argv garbles only its own field. It returns `complete` only @@ -82,6 +87,9 @@ All under `App/Sources/Infrastructure/RemoteHost/`; nothing in the app calls it - Failure reasons for the UX: authentication, host key, unresolvable, unreachable, launch, exit, unrecognized output. - `shutdown` sends `-O exit`. + - The registry leases owners. The last release retires one, and an acquire for the + same key waits until the old owner's `-O exit` finished, so two lifecycles never share + a control socket (review round 1). - The side channel is `ssh -o ControlMaster=auto -o ControlPath= -o ControlPersist=60 -T -- sh -s`. The overrides come first, because `ssh` keeps the first value it obtains: @@ -99,7 +107,10 @@ All under `App/Sources/Infrastructure/RemoteHost/`; nothing in the app calls it - The ControlPath is a SHA-256 prefix of the key, under the user temp directory (78 bytes, below the 104-byte `sun_path` minus the 17-byte creation suffix). - **Runner** (`RemoteCommandRunner`). `posix_spawn` in a new process group, so a stop - also reaches a `ProxyCommand`. + also reaches a `ProxyCommand`. The budget lasts until the command's I/O is finished, not + only until the leader exits: a descendant that keeps a pipe open is stopped with the + group, and reads are abandoned after the kill grace when a descendant left the group + (review round 1). - Stdin input, a 256 KiB output cap (truncation stops the command and makes the snapshot `partial`), a 5 s deadline, and SIGTERM followed by SIGKILL after 1 s. - The `waitpid`, read and write threads start before the first suspension. With -- 2.54.0 From 8b7ba6f3f6f1b142a022e9ab9fc23a6d3a71ef36 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 17 Oct 2026 23:17:46 +0900 Subject: [PATCH 05/51] Check runner budgets by process state, not elapsed time A loaded test host delayed the awaiting test task by tens of seconds, so elapsed-time assertions failed although the runner stopped the group on time. Assert that the descendants are gone (or still alive for the one that left the group) instead. --- App/Tests/RemoteCommandRunnerTests.swift | 57 +++++++++++++++--------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/App/Tests/RemoteCommandRunnerTests.swift b/App/Tests/RemoteCommandRunnerTests.swift index 4b7a86c9..8d940d3a 100644 --- a/App/Tests/RemoteCommandRunnerTests.swift +++ b/App/Tests/RemoteCommandRunnerTests.swift @@ -29,34 +29,47 @@ nonisolated struct RemoteCommandRunnerTests { #expect(result.output.count == 1024) } - @Test func theDeadlineStopsAHungCommandAndWhatItSpawned() async { - // The background child shares stdout: if the stop missed it, the run would wait for - // its end-of-file for 30 seconds. - let clock = ContinuousClock() - let started = clock.now + /// Runs `script` with `$PIDFILE` naming a file for the descendant's pid, and returns it. + private func runRecordingDescendant(_ script: String) async throws -> (RemoteCommandOutcome, pid_t) { + let file = FileManager.default.temporaryDirectory.appending(path: "prowl-runner-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: file) } let outcome = await runner.run( - URL(filePath: "/bin/sh"), ["-c", "sleep 30 & sleep 30"], Data(), + URL(filePath: "/bin/sh"), ["-c", "PIDFILE='\(file.path(percentEncoded: false))'; \(script)"], Data(), RemoteCommandLimits(deadline: 0.3, outputBytes: 1024)) + let text = try String(contentsOf: file, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + return (outcome, try #require(pid_t(text))) + } + + /// Gone, or a zombie waiting for launchd: either way it no longer holds the pipes. + private func isDead(_ pid: pid_t) -> Bool { + guard kill(pid, 0) == 0 else { return true } + return ProcessDetection.processBSDInfo(pid: pid).map { $0.pbi_status == SZOMB } ?? true + } + + // Each descendant shares stdout. The checks are on processes, not on elapsed time: a + // loaded test host can delay this task's resumption by tens of seconds. + + @Test func theDeadlineStopsTheGroupWhenTheLeaderExitsEarly() async throws { + let (outcome, child) = try await runRecordingDescendant("sleep 30 & echo $! > \"$PIDFILE\"; exit 0") #expect(outcome == .timedOut) - #expect(clock.now - started < .seconds(20)) + #expect(isDead(child)) } - @Test(arguments: [ - // The leader exits at once; a child it left behind keeps stdout open. - "sleep 30 & exit 0", - // The descendant ignores SIGTERM; SIGKILL after the grace period ends it. - "(trap '' TERM; sleep 30) & wait", - // The descendant leaves the process group; the reads are abandoned after the grace. - "/usr/bin/perl -e 'setpgrp(0, 0); sleep 8' & wait", - ]) - func theBudgetCoversDescendantsThatKeepThePipesOpen(script: String) async { - let clock = ContinuousClock() - let started = clock.now - let outcome = await runner.run( - URL(filePath: "/bin/sh"), ["-c", script], Data(), RemoteCommandLimits(deadline: 0.3, outputBytes: 1024)) + @Test func theKillGraceEndsADescendantThatIgnoresSIGTERM() async throws { + let (outcome, child) = try await runRecordingDescendant( + "(trap '' TERM; exec sleep 30) & echo $! > \"$PIDFILE\"; wait") + #expect(outcome == .timedOut) + #expect(isDead(child)) + } + + @Test func readsAreAbandonedWhenADescendantLeftTheGroup() async throws { + // The descendant outlives the run; returning while it still holds the pipes proves + // the reads were abandoned instead of waiting for its end of file. + let (outcome, child) = try await runRecordingDescendant( + "/usr/bin/perl -e 'setpgrp(0, 0); sleep 120' & echo $! > \"$PIDFILE\"; wait") + defer { kill(child, SIGKILL) } #expect(outcome == .timedOut) - // Deadline plus kill grace plus one poll interval, with room for a loaded machine. - #expect(clock.now - started < .seconds(6)) + #expect(!isDead(child)) } @Test func aSignalDeathReportsTheShellStatus() async { -- 2.54.0 From 4a499c6145636bd622ad6f0f15b3d4f570af9b1b Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 17 Oct 2026 23:23:21 +0900 Subject: [PATCH 06/51] Detect agents in manual SSH sessions behind a flag Panes export LC_PROWL_PANE. A pane whose foreground is a local ssh with a supported command line leases the host's connection owner, correlates the session that carries its pane id, and binds the remote agent of that session's foreground job. The agent is held across ticks and released by two complete snapshots without it, or at once when the ssh exits. Remote OSC 7501 records count from the ssh start, or from the confirmation of a successor. Enabled by PROWL_REMOTE_AGENT_DETECTION=1 (docs-ai 082 S3a). --- .../AgentDetectionCoordinator.swift | 20 +- .../BusinessLogic/RemotePaneTracker.swift | 232 ++++++++++++ ...WorktreeTerminalState+AgentDetection.swift | 91 ++++- .../Models/WorktreeTerminalState.swift | 7 + .../Ghostty/GhosttySurfaceView.swift | 4 + .../RemoteHost/RemoteAgentDetection.swift | 13 + .../RemoteHost/SSHInvocation.swift | 109 ++++++ App/Tests/RemoteAgentDetectionTests.swift | 342 ++++++++++++++++++ App/Tests/SSHInvocationTests.swift | 51 +++ docs-ai/082-ssh-remote-agents/001-action.md | 47 ++- docs/components/cli.md | 3 + 11 files changed, 904 insertions(+), 15 deletions(-) create mode 100644 App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift create mode 100644 App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift create mode 100644 App/Sources/Infrastructure/RemoteHost/SSHInvocation.swift create mode 100644 App/Tests/RemoteAgentDetectionTests.swift create mode 100644 App/Tests/SSHInvocationTests.swift diff --git a/App/Sources/Features/Terminal/BusinessLogic/AgentDetectionCoordinator.swift b/App/Sources/Features/Terminal/BusinessLogic/AgentDetectionCoordinator.swift index 8512b95a..0d17f2c6 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/AgentDetectionCoordinator.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/AgentDetectionCoordinator.swift @@ -211,12 +211,22 @@ final class AgentDetectionCoordinator { /// that arrived before it belong to the departed origin. var programStatusOriginFence: Date? - /// The arrival-time fence for the bound generation: its start, or a later origin change. - /// A remote generation has no local start time; until the remote binding supplies its - /// fence, no record counts for it. + /// The fence of a bound remote agent (docs-ai 082, L0 item 5), from the pane's tracker: + /// the `ssh` start for the first agent of a connection, the confirming snapshot for a + /// successor. + var remoteProgramStatusFence: Date? + + /// The arrival-time fence for the bound generation: its start or a later origin change + /// for a local one, the tracker's fence for a remote one. Without a fence no record counts. private var programStatusNotBefore: Date? { - guard let startedAt = process?.localGeneration?.startedAt else { return nil } - return programStatusOriginFence.map { max($0, startedAt) } ?? startedAt + switch process { + case .local(let generation): + return programStatusOriginFence.map { max($0, generation.startedAt) } ?? generation.startedAt + case .remote: + return remoteProgramStatusFence + case nil: + return nil + } } private var isVerified: Bool { diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift new file mode 100644 index 00000000..1765ea44 --- /dev/null +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -0,0 +1,232 @@ +import Foundation + +/// A pane's subscription to the host its foreground `ssh` reaches (docs-ai 082, L0). +nonisolated struct RemoteConnectionLease: Sendable { + let connection: ConnectionKey + let snapshots: AsyncStream + let refresh: @Sendable () async -> Void + let close: @Sendable () async -> Void +} + +/// Opens leases; the live source resolves the recipe with `ssh -G` and leases the host's +/// connection owner from the shared registry. +nonisolated struct RemoteSnapshotSource: Sendable { + var connect: @Sendable (SSHConnectionRecipe) async -> RemoteConnectionLease? + + static let live = RemoteSnapshotSource { recipe in + let registry = RemoteConnectionRegistry.shared + guard let key = await registry.resolve(recipe) else { return nil } + let owner = await registry.acquire(key, recipe: recipe) + let (id, snapshots) = await owner.subscribe() + return RemoteConnectionLease( + connection: key, + snapshots: snapshots, + refresh: { await owner.requestRefresh() }, + close: { + await owner.unsubscribe(id) + await registry.release(key) + }) + } +} + +/// How a pane relates to the remote host's sessions (docs-ai 082, L0 item 1). Prowl never +/// picks an arbitrary session: anything but `correlated` leaves the pane without a probed +/// remote agent. +nonisolated enum RemotePaneCorrelation: Equatable, Sendable { + /// The connection is being resolved or the first snapshot has not arrived. + case pending + /// Exactly one session on the host carries this pane's id. + case correlated + /// More than one session carries this pane's id (for example two `ssh` jobs). + case ambiguous + /// A complete snapshot has no session with this pane's id (`AcceptEnv` or `SendEnv` + /// missing, `-F` without `SendEnv`, or a shell whose environment the host hides). + case unmapped + /// No usable snapshot: the connection failed, timed out, or was refused. + case probeUnavailable(RemoteProbeFailure?) + /// The local `ssh` command line is outside the supported grammar. + case unsupported(String) +} + +/// Tracks one pane's foreground `ssh` and the remote agent it shows (docs-ai 082, L0). +/// +/// It belongs to one local `ssh` generation; a new `ssh` gets a new tracker, and the old +/// one's late snapshots are dropped with it. The pane's detection loop asks it for an +/// observation on every tick; snapshots arrive from the host's connection owner and wake +/// the loop. +@MainActor +final class RemotePaneTracker { + struct Observation: Equatable { + var correlation: RemotePaneCorrelation + var connection: ConnectionKey? + /// The bound remote agent: identified from a snapshot and held until the snapshot rule + /// releases it, so every tick sees the same generation. + var identified: IdentifiedAgentProcess? + /// What the latest new snapshot says about the bound remote agent; nil when no agent + /// is bound or no new snapshot arrived. + var verdict: RemoteProcessPresence.Verdict? + /// The arrival-time fence for OSC records of the bound remote agent (L0 item 5). + var programStatusFence: Date? + } + + var boundAgent: IdentifiedAgentProcess? { bound } + + /// Probe cadence while a remote agent is bound or the pane settles after a wake. + static let activeCadence: TimeInterval = 1 + /// Probe cadence while the pane shows a remote shell. + static let idleCadence: TimeInterval = 3 + + let surfaceID: UUID + /// The local `ssh` process this tracker belongs to. + let ssh: LocalProcessGeneration + let invocation: SSHInvocation + private let source: RemoteSnapshotSource + private let onSnapshot: @MainActor () -> Void + private let uptime: () -> TimeInterval + + private var lease: RemoteConnectionLease? + private var connectTask: Task? + private var connectFailed = false + private var retired = false + private var lastRequestAt: TimeInterval? + private(set) var latest: RemoteProbeSnapshot? + private var consumedSequence: (epoch: UInt64, sequence: UInt64)? + + private var bound: IdentifiedAgentProcess? + private var presence = RemoteProcessPresence() + private var boundFence: Date? + /// Whether any agent was bound since this `ssh` started; the first agent's reports + /// count from the `ssh` start, a successor's only after the snapshot that confirmed it. + private var hadAgent = false + + init( + surfaceID: UUID, + ssh: LocalProcessGeneration, + invocation: SSHInvocation, + source: RemoteSnapshotSource, + uptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, + onSnapshot: @escaping @MainActor () -> Void + ) { + self.surfaceID = surfaceID + self.ssh = ssh + self.invocation = invocation + self.source = source + self.uptime = uptime + self.onSnapshot = onSnapshot + } + + /// Opens the connection lease and consumes snapshots until the tracker retires. + func start() { + guard connectTask == nil, case .supported(let recipe) = invocation else { return } + let source = source + connectTask = Task { [weak self] in + guard let lease = await source.connect(recipe) else { + self?.connectFailed = true + self?.onSnapshot() + return + } + guard let self, !self.retired else { + await lease.close() + return + } + self.lease = lease + self.lastRequestAt = self.uptime() + await lease.refresh() + for await snapshot in lease.snapshots { + guard !self.retired, !Task.isCancelled else { break } + self.latest = snapshot + self.onSnapshot() + } + } + } + + /// Ends the subscription; snapshots that arrive later are dropped. + func retire() { + guard !retired else { return } + retired = true + connectTask?.cancel() + if let lease { + Task { await lease.close() } + } + } + + /// Asks for a snapshot on the next tick, whatever the cadence (a key press or a report). + func requestSoon() { + lastRequestAt = nil + } + + /// One tick's view. Requests a refresh when the cadence is due; reads only the latest + /// snapshot, so pane ticks never wait for the host. + func observe(active: Bool, now: Date) -> Observation { + requestRefreshIfDue(active: active) + var verdict: RemoteProcessPresence.Verdict? + if let latest, let report = latest.report, + consumedSequence.map({ $0 != (latest.connectionEpoch, latest.sequence) }) ?? true + { + consumedSequence = (latest.connectionEpoch, latest.sequence) + verdict = consume(latest, report: report, now: now) + } + return Observation( + correlation: correlation, connection: lease?.connection, identified: bound, verdict: verdict, + programStatusFence: boundFence) + } + + /// Applies a new snapshot to the binding and returns its verdict for the bound agent. + private func consume(_ snapshot: RemoteProbeSnapshot, report: RemoteProbeReport, now: Date) + -> RemoteProcessPresence.Verdict? + { + let job = correlatedJob(report: report) + if let identified = job.flatMap(identifyAgentInJob), identified.process.ref != bound?.process.ref { + bound = identified + presence = RemoteProcessPresence() + boundFence = hadAgent ? now : ssh.startedAt + hadAgent = true + } + guard let current = bound else { return nil } + let contains = job?.processes.contains { $0.ref == current.process.ref } ?? false + let verdict = presence.observe(snapshot, contains: contains, now: uptime()) + if verdict == .released { + bound = nil + presence = RemoteProcessPresence() + } + return verdict + } + + var correlation: RemotePaneCorrelation { + if case .unsupported(let reason) = invocation { return .unsupported(reason) } + if connectFailed { return .probeUnavailable(nil) } + guard let latest else { return .pending } + guard let report = latest.report else { + if case .failed(let failure) = latest.outcome { return .probeUnavailable(failure) } + return .probeUnavailable(nil) + } + switch sessions(in: report).count { + case 0: return latest.isComplete ? .unmapped : .pending + case 1: return .correlated + default: return .ambiguous + } + } + + private func sessions(in report: RemoteProbeReport) -> [RemoteProbeSession] { + report.sessions.filter { $0.paneID == surfaceID } + } + + private func correlatedJob(report: RemoteProbeReport) -> ForegroundJob? { + let matching = sessions(in: report) + guard matching.count == 1, let session = matching.first, let connection = lease?.connection else { return nil } + return session.foregroundJob(connection: connection, bootID: report.bootID) + } + + /// The cadence counts from the host's latest snapshot, which every pane on the host + /// shares, and from this pane's last request: panes that tick out of phase do not double + /// the probe rate. + private func requestRefreshIfDue(active: Bool) { + guard let lease, !retired else { return } + let now = uptime() + let cadence = active ? Self.activeCadence : Self.idleCadence + if let lastRequestAt, now - lastRequestAt < cadence { return } + if lastRequestAt != nil, let latest, now - latest.startedAt < cadence { return } + lastRequestAt = now + Task { await lease.refresh() } + } +} diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 39cbfe38..3c1037ee 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -21,6 +21,7 @@ extension WorktreeTerminalState { } agentDetectionFullTickRequests.insert(view.id) agentDetectionSettleUntilBySurface[view.id] = now.addingTimeInterval(AgentDetectionSchedule.settleWindow) + remotePaneTrackers[view.id]?.requestSoon() startAgentDetectionTaskIfNeeded(for: view, tabId: tabId) interruptAgentDetectionSleep(forSurfaceID: view.id) } @@ -121,18 +122,13 @@ extension WorktreeTerminalState { return false } - let identified = job.flatMap { identifyAgentInJob($0) } - let probedAgent = identified?.agent + let localIdentified = job.flatMap { identifyAgentInJob($0) } + let remote = observeRemoteAgent(job: job, localIdentified: localIdentified, surfaceID: surfaceID, now: now) + let identified = localIdentified ?? remote?.identified // The origin change is confirmed when the probe returns, not when the poll began: // a report that landed while the probe ran still belongs to the departed origin. let originFence = applyAgentOrigin(identified, surfaceID: surfaceID, now: max(now, Date())) - - var presence = agentDetectionPresenceBySurface[surfaceID] ?? AgentDetectionPresence() - let agent = presence.update(detectedAgent: probedAgent, releaseOnMiss: finished != nil) - agentDetectionPresenceBySurface[surfaceID] = presence - if let finished { - applyCommandFinished(finished, identified: identified, surfaceID: surfaceID) - } + let agent = updateAgentPresence(identified: identified, remote: remote, finished: finished, surfaceID: surfaceID) logUnmappedProgramStatusAppIfNeeded(surfaceID: surfaceID, agent: agent) let probe = AgentDetectionDiagnostic.Probe( @@ -152,6 +148,7 @@ extension WorktreeTerminalState { let coordinator = agentDetectionCoordinators[surfaceID] ?? makeAgentDetectionCoordinator(surfaceID: surfaceID) agentDetectionCoordinators[surfaceID] = coordinator coordinator.programStatusOriginFence = originFence + coordinator.remoteProgramStatusFence = remote?.programStatusFence let process = Self.processGeneration(identified) // The tick is delegated only while the schedule says so (a key press puts one full // tick back on the active schedule and keeps it there for the settle window) and the @@ -278,6 +275,79 @@ extension WorktreeTerminalState { launchProfilesBySurface.removeValue(forKey: surfaceID) } + /// The pane's presence after one probe. A remote agent is identified on every tick while + /// its tracker holds it; the tracker releases it by the snapshot rule (docs-ai 082, C3) + /// instead of the local miss count, and a gone `ssh` releases it at once. A finished + /// command (OSC 133 D) on a remote pane asks the host for a snapshot right away. + private func updateAgentPresence( + identified: IdentifiedAgentProcess?, remote: RemotePaneTracker.Observation?, + finished: PendingCommandFinished?, surfaceID: UUID + ) -> DetectedAgent? { + var presence = agentDetectionPresenceBySurface[surfaceID] ?? AgentDetectionPresence() + defer { agentDetectionPresenceBySurface[surfaceID] = presence } + if remote != nil, finished != nil { remotePaneTrackers[surfaceID]?.requestSoon() } + let previousIsRemote = surfaceAgentStates[surfaceID]?.agentProcess.map { $0.origin != .local } ?? false + let releasesAtOnce = identified == nil && previousIsRemote + let agent = presence.update(detectedAgent: identified?.agent, releaseOnMiss: finished != nil || releasesAtOnce) + if let finished { applyCommandFinished(finished, identified: identified, surfaceID: surfaceID) } + return agent + } + + /// The remote side of one probe (docs-ai 082, L0): when no local agent is in the + /// foreground but an `ssh` is, the pane's tracker for that `ssh` generation reports what + /// the host shows. Anything else retires the tracker. + private func observeRemoteAgent( + job: ForegroundJob?, localIdentified: IdentifiedAgentProcess?, surfaceID: UUID, now: Date + ) -> RemotePaneTracker.Observation? { + guard remoteAgentDetectionEnabled, localIdentified == nil, let ssh = job.flatMap(Self.foregroundSSH) else { + retireRemotePaneTracker(surfaceID: surfaceID) + return nil + } + let tracker: RemotePaneTracker + if let existing = remotePaneTrackers[surfaceID], existing.ssh == ssh.generation { + tracker = existing + } else { + retireRemotePaneTracker(surfaceID: surfaceID) + tracker = RemotePaneTracker( + surfaceID: surfaceID, ssh: ssh.generation, invocation: SSHInvocation.parse(ssh.arguments), + source: remoteSnapshotSourceForTesting ?? .live + ) { [weak self] in + self?.wakeAgentDetectionForRemoteSnapshot(surfaceID: surfaceID) + } + remotePaneTrackers[surfaceID] = tracker + tracker.start() + } + let active = + surfaceAgentStates[surfaceID]?.detectedAgent != nil || isAgentDetectionSettling(surfaceID: surfaceID, now: now) + return tracker.observe(active: active, now: now) + } + + /// A local foreground `ssh` and its arguments (without `argv[0]`), with the generation + /// that its tracker belongs to. + nonisolated static func foregroundSSH( + _ job: ForegroundJob + ) -> (generation: LocalProcessGeneration, arguments: [String])? { + for process in job.processes where process.argv0 == "ssh" || process.name == "ssh" { + guard let pid = process.ref.localPID, let arguments = process.arguments, + let startedAt = ProcessDetection.processStartDate(pid: pid) + else { continue } + return (LocalProcessGeneration(pid: pid, startedAt: startedAt), Array(arguments.dropFirst())) + } + return nil + } + + func retireRemotePaneTracker(surfaceID: UUID) { + remotePaneTrackers.removeValue(forKey: surfaceID)?.retire() + } + + /// A snapshot arrived: run the next tick now, without the settle window a key press + /// opens, so a steady probe cadence does not keep the pane on the active schedule. + private func wakeAgentDetectionForRemoteSnapshot(surfaceID: UUID) { + guard let view = surfaces[surfaceID], let tabId = tabId(containing: surfaceID) else { return } + startAgentDetectionTaskIfNeeded(for: view, tabId: tabId) + interruptAgentDetectionSleep(forSurfaceID: surfaceID) + } + /// Origin bookkeeping for one probe: retires launch context a remote launch cannot /// wear and returns the surface's OSC origin fence. private func applyAgentOrigin(_ identified: IdentifiedAgentProcess?, surfaceID: UUID, now: Date) -> Date? { @@ -602,6 +672,7 @@ extension WorktreeTerminalState { func cleanupAgentDetectionState(forSurfaceId surfaceId: UUID) { agentDetectionLifecycleBySurface[surfaceId, default: 0] &+= 1 + retireRemotePaneTracker(surfaceID: surfaceId) agentDetectionCoordinators.removeValue(forKey: surfaceId)?.invalidate() agentDetectionTasks[surfaceId]?.cancel() agentDetectionTasks.removeValue(forKey: surfaceId) @@ -626,6 +697,8 @@ extension WorktreeTerminalState { func cleanupAllAgentDetectionState() { for coordinator in agentDetectionCoordinators.values { coordinator.invalidate() } + for tracker in remotePaneTrackers.values { tracker.retire() } + remotePaneTrackers.removeAll() agentDetectionCoordinators.removeAll() for task in agentDetectionTasks.values { task.cancel() diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift index fa41f74e..f02405b6 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift @@ -243,6 +243,13 @@ final class WorktreeTerminalState { @ObservationIgnored var pendingCommandFinishedBySurface: [UUID: PendingCommandFinished] = [:] /// Unmapped `app` values already reported once per surface. @ObservationIgnored var programStatusUnmappedAppsLoggedBySurface: [UUID: Set] = [:] + /// One tracker per pane whose foreground is an `ssh` (docs-ai 082, L0). + @ObservationIgnored var remotePaneTrackers: [UUID: RemotePaneTracker] = [:] + @ObservationIgnored var remoteSnapshotSourceForTesting: RemoteSnapshotSource? + @ObservationIgnored var remoteAgentDetectionEnabledForTesting: Bool? + var remoteAgentDetectionEnabled: Bool { + remoteAgentDetectionEnabledForTesting ?? RemoteAgentDetection.isEnabled + } /// Test seams: the process probe, the OSC support table, and the diagnostics sink. @ObservationIgnored var agentProcessProbeForTesting: AgentProcessProbeOverride? @ObservationIgnored var programStatusSupportForTesting: ((DetectedAgent) -> ProgramStatusSupport)? diff --git a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView.swift b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView.swift index af1e6106..10c99cad 100644 --- a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView.swift +++ b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView.swift @@ -119,6 +119,7 @@ final class GhosttySurfaceView: NSView, Identifiable { /// without guessing from focus. Convenience identity only: the value is /// inherited and forgeable, so trusted attribution stays on caller-PID resolution. static let paneIdentityEnvironmentKey = "PROWL_PANE_ID" + static let remotePaneIdentityEnvironmentKey = "LC_PROWL_PANE" let runtime: GhosttyRuntime let id: UUID @@ -323,6 +324,9 @@ final class GhosttySurfaceView: NSView, Identifiable { } var launchEnvironment = environment launchEnvironment[Self.paneIdentityEnvironmentKey] = id.uuidString + // The pane id under a name that `ssh` sends by default (`SendEnv LC_*`), so a remote + // session can be matched to its pane (docs-ai 082). + launchEnvironment[Self.remotePaneIdentityEnvironmentKey] = id.uuidString // Codex's shared daemon hides which pane drives a thread; this log restores it (docs-ai 073). launchEnvironment.merge(CodexTUISessionLog.environment(for: id)) { explicit, _ in explicit } self.launchEnvironment = launchEnvironment diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift b/App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift new file mode 100644 index 00000000..996ca439 --- /dev/null +++ b/App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift @@ -0,0 +1,13 @@ +import Foundation + +/// The switch for SSH-transparent detection (docs-ai 082, L0). Off until the L0 UX ships; +/// `PROWL_REMOTE_AGENT_DETECTION=1` or the `remoteAgentDetectionEnabled` default turns it +/// on for dogfooding. +nonisolated enum RemoteAgentDetection { + static let defaultsKey = "remoteAgentDetectionEnabled" + static let environmentKey = "PROWL_REMOTE_AGENT_DETECTION" + + static var isEnabled: Bool { + ProcessInfo.processInfo.environment[environmentKey] == "1" || UserDefaults.standard.bool(forKey: defaultsKey) + } +} diff --git a/App/Sources/Infrastructure/RemoteHost/SSHInvocation.swift b/App/Sources/Infrastructure/RemoteHost/SSHInvocation.swift new file mode 100644 index 00000000..3a6e5e0d --- /dev/null +++ b/App/Sources/Infrastructure/RemoteHost/SSHInvocation.swift @@ -0,0 +1,109 @@ +import Foundation + +/// The local `ssh` command line of a pane, read with an explicit grammar (docs-ai 082, +/// L0 item 2). Connection-shaping options become the side channel's recipe; forwarding, +/// session, and command options are dropped, because the side channel never replays them; +/// anything outside the grammar makes the pane `unsupported` instead of guessed. +nonisolated enum SSHInvocation: Equatable, Sendable { + case supported(SSHConnectionRecipe) + case unsupported(String) + + /// Flags that take no value and shape the connection. + private static let replayedFlags: Set = ["4", "6", "C", "k", "q"] + /// Options with a value that shape the connection. + private static let replayedValues: Set = ["b", "B", "c", "F", "i", "I", "J", "l", "m", "p", "P"] + /// Flags without a value that only affect the interactive session. + private static let droppedFlags: Set = ["A", "a", "g", "K", "M", "n", "t", "T", "v", "x", "X", "y", "Y"] + /// Options with a value that only affect the interactive session. + private static let droppedValues: Set = ["D", "e", "E", "L", "R", "S", "w"] + + /// `-o` keys (lowercased) the side channel replays. + static let replayedConfigKeys: Set = [ + "addressfamily", "bindaddress", "bindinterface", "canonicaldomains", "canonicalizefallbacklocal", + "canonicalizehostname", "canonicalizemaxdots", "canonicalizepermittedcnames", "casignaturealgorithms", + "certificatefile", "checkhostip", "ciphers", "compression", "connectionattempts", "fingerprinthash", + "globalknownhostsfile", "gssapiauthentication", "gssapidelegatecredentials", "hashknownhosts", + "hostbasedacceptedalgorithms", "hostbasedauthentication", "hostkeyalgorithms", "hostkeyalias", "hostname", + "identitiesonly", "identityagent", "identityfile", "ipqos", "kbdinteractiveauthentication", + "kbdinteractivedevices", "kexalgorithms", "knownhostscommand", "macs", "nohostauthenticationforlocalhost", + "numberofpasswordprompts", "passwordauthentication", "pkcs11provider", "port", "preferredauthentications", + "proxycommand", "proxyjump", "proxyusefdpass", "pubkeyacceptedalgorithms", "pubkeyauthentication", + "rekeylimit", "requiredrsasize", "revokedhostkeys", "securitykeyprovider", "serveralivecountmax", + "serveraliveinterval", "streamlocalbindmask", "stricthostkeychecking", "tag", "tcpkeepalive", + "updatehostkeys", "user", "userknownhostsfile", "verifyhostkeydns", "visualhostkey", "xauthlocation", + "usekeychain", "addkeystoagent", + ] + /// `-o` keys (lowercased) that only shape the interactive session; dropped. + static let droppedConfigKeys: Set = [ + "clearallforwardings", "controlmaster", "controlpath", "controlpersist", "dynamicforward", + "enableescapecommandline", + "escapechar", "exitonforwardfailure", "forkafterauthentication", "forwardagent", "forwardx11", + "forwardx11timeout", "forwardx11trusted", "gatewayports", "localcommand", "localforward", "logverbose", + "obscurekeystroketiming", "permitlocalcommand", "permitremoteopen", "remotecommand", "remoteforward", + "requesttty", "sessiontype", "stdinnull", "streamlocalbindunlink", "syslogfacility", "tunnel", "tunneldevice", + "batchmode", "loglevel", "sendenv", "setenv", + // With ControlPersist it holds the first command's output open; the runner bounds it. + "connecttimeout", + ] + + /// Parses `arguments` (argv without `ssh` itself). + static func parse(_ arguments: [String]) -> SSHInvocation { + var options: [String] = [] + var index = arguments.startIndex + while index < arguments.endIndex { + let argument = arguments[index] + if argument == "--" { + index += 1 + break + } + guard argument.hasPrefix("-"), argument.count > 1 else { break } + var letters = argument.dropFirst()[...] + while let letter = letters.popFirst() { + if replayedFlags.contains(letter) { + options.append("-\(letter)") + continue + } + if droppedFlags.contains(letter) { continue } + guard replayedValues.contains(letter) || droppedValues.contains(letter) || letter == "o" else { + return .unsupported("ssh option -\(letter)") + } + // The value is the rest of this argument, or the next one. + let value: String + if !letters.isEmpty { + value = String(letters) + letters = "" + } else { + index += 1 + guard index < arguments.endIndex else { return .unsupported("ssh option -\(letter) without a value") } + value = arguments[index] + } + if letter == "o" { + switch configOption(value) { + case .replayed: options += ["-o", value] + case .dropped: break + case .unsupported(let key): return .unsupported("ssh option -o \(key)") + } + } else if replayedValues.contains(letter) { + options += ["-\(letter)", value] + } + } + index += 1 + } + guard index < arguments.endIndex, !arguments[index].isEmpty else { return .unsupported("no destination") } + return .supported(SSHConnectionRecipe(destination: arguments[index], options: options)) + } + + private enum ConfigOption: Equatable { + case replayed + case dropped + case unsupported(String) + } + + /// `Key=value`, `Key value`, or `Key = value`, as `ssh -o` accepts them. + private static func configOption(_ text: String) -> ConfigOption { + let key = text.prefix { !$0.isWhitespace && $0 != "=" }.lowercased() + if replayedConfigKeys.contains(key) { return .replayed } + if droppedConfigKeys.contains(key) { return .dropped } + return .unsupported(key.isEmpty ? text : key) + } +} diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift new file mode 100644 index 00000000..0160bf02 --- /dev/null +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -0,0 +1,342 @@ +import Foundation +import GhosttyKit +import Testing + +@testable import Prowl + +/// SSH-transparent detection in the terminal owner (docs-ai 082, S3a): a pane whose +/// foreground is a local `ssh` reports the remote agent the host's snapshots show. The local +/// probe and the snapshot source are fakes; the `ssh` member is this test process, so its +/// generation has a real start time. +@MainActor +@Suite(.serialized) +struct RemoteAgentDetectionTests { + private struct Fixture { + let state: WorktreeTerminalState + let tabID: TerminalTabID + let surface: GhosttySurfaceView + let source: FakeRemoteSource + var surfaceID: UUID { surface.id } + } + + private final class Probe { + var job: ForegroundJob? + } + + nonisolated final class FakeRemoteSource: @unchecked Sendable { + private let lock = NSLock() + private var continuation: AsyncStream.Continuation? + private var storedRecipes: [SSHConnectionRecipe] = [] + private var storedRefreshes = 0 + private var storedCloses = 0 + let connection = ConnectionKey(user: "onevcat", hostname: "mini.local") + + var recipes: [SSHConnectionRecipe] { lock.withLock { storedRecipes } } + var refreshes: Int { lock.withLock { storedRefreshes } } + var closes: Int { lock.withLock { storedCloses } } + + var source: RemoteSnapshotSource { + RemoteSnapshotSource { [self] recipe in connect(recipe) } + } + + private func connect(_ recipe: SSHConnectionRecipe) -> RemoteConnectionLease { + let (stream, continuation) = AsyncStream.makeStream() + lock.withLock { + self.continuation = continuation + storedRecipes.append(recipe) + } + return RemoteConnectionLease( + connection: connection, snapshots: stream, + refresh: { [self] in lock.withLock { storedRefreshes += 1 } }, + close: { [self] in lock.withLock { storedCloses += 1 } }) + } + + func push(_ snapshot: RemoteProbeSnapshot) { + _ = lock.withLock { continuation }?.yield(snapshot) + } + } + + private func makeFixture(probe: Probe, enabled: Bool = true) -> Fixture { + let state = WorktreeTerminalState( + runtime: GhosttyRuntime(), + worktree: Worktree( + id: "/tmp/repo/worktree", name: "worktree", detail: "", + workingDirectory: URL(fileURLWithPath: "/tmp/repo/worktree"), + repositoryRootURL: URL(fileURLWithPath: "/tmp/repo")), + skipsSurfaceCreationForTesting: true) + let source = FakeRemoteSource() + state.remoteAgentDetectionEnabledForTesting = enabled + state.remoteSnapshotSourceForTesting = source.source + state.programStatusSupportForTesting = { _ in .verified } + state.agentProcessProbeForTesting = { _, _, _ in probe.job } + state.lastWindowIsKey = false + state.lastWindowIsVisible = false + let tabID = state.tabManager.createTab(title: "worktree 1", icon: "terminal") + let surface = GhosttySurfaceView( + runtime: state.runtime, workingDirectory: URL(fileURLWithPath: "/tmp/repo/worktree", isDirectory: true), + fontSize: nil, context: GHOSTTY_SURFACE_CONTEXT_TAB, skipsSurfaceCreationForTesting: true) + state.configureBridgeCallbacks(for: surface, tabId: tabID) + state.configureSurfaceCallbacks(for: surface, tabId: tabID) + state.surfaces[surface.id] = surface + state.trees[tabID] = SplitTree(view: surface) + state.focusedSurfaceIdByTab[tabID] = surface.id + state.surfaceAgentStates[surface.id] = PaneAgentState(lastChangedAt: Date(timeIntervalSince1970: 1_000)) + state.agentDetectionTasks[surface.id] = Task {} + return Fixture(state: state, tabID: tabID, surface: surface, source: source) + } + + private var sshJob: ForegroundJob { + ForegroundJob( + processGroupID: getpid(), + processes: [ + ForegroundProcess( + pid: getpid(), parentProcessID: 1, name: "ssh", argv0: "ssh", cmdline: "ssh -p 2222 mini", + arguments: ["ssh", "-p", "2222", "mini"]) + ]) + } + + private func remoteProcess(_ pid: Int32, name: String, start: String = "t1") -> RemoteProbeProcess { + RemoteProbeProcess( + pid: pid, parentPID: 100, processGroupID: pid, startToken: start, elapsedSeconds: 1, name: name, + arguments: [name], commandLine: name, workingDirectory: "/home/dev") + } + + private func snapshot( + _ sequence: UInt64, pane: UUID?, foreground: RemoteProbeProcess?, extraPane: UUID? = nil, + outcome: RemoteProbeSnapshot.Outcome = .complete + ) -> RemoteProbeSnapshot { + func session(_ id: UUID?) -> RemoteProbeSession { + let process = foreground ?? remoteProcess(101, name: "zsh") + return RemoteProbeSession( + paneID: id, anchorPID: 100, tty: "/dev/pts/0", foregroundProcessGroupID: process.processGroupID, + processes: [process]) + } + var sessions = [session(pane)] + if let extraPane { sessions.append(session(extraPane)) } + let now = ProcessInfo.processInfo.systemUptime + return RemoteProbeSnapshot( + connection: ConnectionKey(user: "onevcat", hostname: "mini.local"), connectionEpoch: 0, sequence: sequence, + startedAt: now, finishedAt: now, outcome: outcome, + report: RemoteProbeReport(operatingSystem: .linux, bootID: "boot", sessions: sessions)) + } + + private func remoteRef(_ pid: Int32, start: String = "t1") -> ProcessRef { + .remote( + RemoteProcessRef( + connection: ConnectionKey(user: "onevcat", hostname: "mini.local"), bootID: "boot", pid: pid, + startToken: start)) + } + + private func noSession( + _: IdentifiedAgentProcess?, _: PaneAgentState, _: URL?, _: String, _: (surfaceID: UUID, configRoot: URL?) + ) -> (session: AgentSession?, missStreak: Int) { + (nil, 0) + } + + @discardableResult + private func detect(_ fixture: Fixture) async -> Bool { + fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID] = WorktreeTerminalState.AgentScreenScan( + agent: .claude, text: "", detection: AgentScreenDetection(state: .idle, reason: .legacyDetector)) + return await fixture.state.detectAgentState( + for: fixture.surface, tabId: fixture.tabID, resolveSession: noSession, now: Date()) + } + + /// Waits for the tracker's consumer task, which runs on the main actor between test steps. + private func settle(_ condition: () -> Bool) async { + for _ in 0..<1_000 where !condition() { + await Task.yield() + } + #expect(condition()) + } + + private func deliver(_ fixture: Fixture, _ snapshot: RemoteProbeSnapshot) async { + fixture.source.push(snapshot) + await settle { fixture.state.remotePaneTrackers[fixture.surfaceID]?.latest?.sequence == snapshot.sequence } + } + + /// Binds Claude through a first snapshot; returns after the entry is published. + private func bindClaude(_ fixture: Fixture, probe: Probe) async { + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture) + } + + @Test func aCorrelatedRemoteAgentAppearsWithItsRemoteReference() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var emitted: [ActiveAgentEntry] = [] + fixture.state.onAgentEntryChanged = { emitted.append($0) } + await bindClaude(fixture, probe: probe) + + #expect(fixture.source.recipes == [SSHConnectionRecipe(destination: "mini", options: ["-p", "2222"])]) + let pane = fixture.state.surfaceAgentStates[fixture.surfaceID] + #expect(pane?.detectedAgent == .claude) + #expect(pane?.agentProcess == remoteRef(200)) + #expect(emitted.last?.agent == .claude) + // No local file evidence for a remote process. + #expect(pane?.session == nil) + #expect(fixture.state.agentDetectionCoordinators[fixture.surfaceID]?.boundProcess?.localGeneration == nil) + } + + @Test func twoCompleteSnapshotsWithoutTheAgentReleaseIt() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var removed: [UUID] = [] + fixture.state.onAgentEntryRemoved = { removed.append($0) } + await bindClaude(fixture, probe: probe) + + // A tick with no new snapshot changes nothing. + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) + // An inconclusive run neither proves absence nor resets the count. + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: nil, outcome: .partial)) + await detect(fixture) + await deliver(fixture, snapshot(3, pane: fixture.surfaceID, foreground: nil)) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) + #expect(removed.isEmpty) + await deliver(fixture, snapshot(4, pane: fixture.surfaceID, foreground: nil)) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + #expect(removed == [fixture.surfaceID]) + } + + @Test func theLocalSSHExitingReleasesAtOnceAndClosesTheLease() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var removed: [UUID] = [] + fixture.state.onAgentEntryRemoved = { removed.append($0) } + await bindClaude(fixture, probe: probe) + + probe.job = ForegroundJob( + processGroupID: getpid(), + processes: [ForegroundProcess(pid: getpid(), parentProcessID: 1, name: "zsh", argv0: "zsh", cmdline: "zsh")]) + await detect(fixture) + #expect(removed == [fixture.surfaceID]) + #expect(fixture.state.remotePaneTrackers[fixture.surfaceID] == nil) + await settle { fixture.source.closes == 1 } + } + + @Test func aLateSnapshotAfterTheSSHExitedResurrectsNothing() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindClaude(fixture, probe: probe) + probe.job = nil + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + fixture.source.push(snapshot(9, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + for _ in 0..<100 { await Task.yield() } + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + } + + @Test func otherPanesAndAmbiguousSessionsNeverBind() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + let claude = remoteProcess(200, name: "claude") + + await deliver(fixture, snapshot(1, pane: UUID(), foreground: claude)) + await detect(fixture) + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + #expect(tracker.correlation == .unmapped) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: claude, extraPane: fixture.surfaceID)) + await detect(fixture) + #expect(tracker.correlation == .ambiguous) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + } + + @Test func unsupportedArgvNeverConnects() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + probe.job = ForegroundJob( + processGroupID: getpid(), + processes: [ + ForegroundProcess( + pid: getpid(), parentProcessID: 1, name: "ssh", argv0: "ssh", cmdline: "ssh -N -L 1:a:1 box", + arguments: ["ssh", "-N", "-L", "1:a:1", "box"]) + ]) + await detect(fixture) + for _ in 0..<50 { await Task.yield() } + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + guard case .unsupported = tracker.correlation else { + Issue.record("expected unsupported, got \(tracker.correlation)") + return + } + #expect(fixture.source.recipes.isEmpty) + } + + @Test func remoteReportsCountFromTheSSHStartAndASuccessorStartsANewFence() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + // Claude reported before the first snapshot confirmed it; the report is after the + // `ssh` started, so it counts once the agent binds. + fixture.state.handleProgramStatus( + GhosttyProgramStatusReport(state: .working, kind: nil, id: "", app: "claude-code"), surfaceID: fixture.surfaceID) + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.decision?.reason.identifier == "osc.working") + let sshStart = try #require(ProcessDetection.processStartDate(pid: getpid())) + #expect(fixture.state.agentDetectionCoordinators[fixture.surfaceID]?.remoteProgramStatusFence == sshStart) + // Ticks without a new snapshot still see the bound generation, so the verified + // producer's root delegates the screen read. + await detect(fixture) + #expect(fixture.state.agentDetectionCoordinators[fixture.surfaceID]?.isDelegated == true) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + + // A successor replaced Claude between snapshots: the predecessor's report no longer + // counts; the screen decides until the successor reports. + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(300, name: "claude"))) + await detect(fixture) + let pane = fixture.state.surfaceAgentStates[fixture.surfaceID] + #expect(pane?.agentProcess == remoteRef(300)) + #expect(pane?.decision?.reason.identifier != "osc.working") + fixture.state.handleProgramStatus( + GhosttyProgramStatusReport(state: .idle, kind: nil, id: "", app: "claude-code"), surfaceID: fixture.surfaceID) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.decision?.reason.identifier == "osc.idle") + } + + @Test func theFlagOffNeverStartsATracker() async { + let probe = Probe() + let fixture = makeFixture(probe: probe, enabled: false) + probe.job = sshJob + await detect(fixture) + #expect(fixture.state.remotePaneTrackers.isEmpty) + #expect(fixture.source.recipes.isEmpty) + } + + @Test func ticksRequestSnapshotsOnTheirCadenceAndWakesAtOnce() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.refreshes == 1 } + // Ticks inside the cadence do not ask again. + await detect(fixture) + await detect(fixture) + for _ in 0..<50 { await Task.yield() } + #expect(fixture.source.refreshes == 1) + // A wake asks on the next tick. + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + tracker.requestSoon() + await detect(fixture) + await settle { fixture.source.refreshes == 2 } + } + + @Test func paneShellsExportThePaneIdUnderAnSSHForwardedName() { + let surface = GhosttySurfaceView( + runtime: GhosttyRuntime(), workingDirectory: nil, fontSize: nil, context: GHOSTTY_SURFACE_CONTEXT_TAB, + skipsSurfaceCreationForTesting: true) + #expect(surface.launchEnvironment["LC_PROWL_PANE"] == surface.id.uuidString) + #expect(surface.launchEnvironment["PROWL_PANE_ID"] == surface.id.uuidString) + } +} diff --git a/App/Tests/SSHInvocationTests.swift b/App/Tests/SSHInvocationTests.swift new file mode 100644 index 00000000..822f6cc8 --- /dev/null +++ b/App/Tests/SSHInvocationTests.swift @@ -0,0 +1,51 @@ +import Foundation +import Testing + +@testable import Prowl + +/// The supported grammar of a pane's local `ssh` command line (docs-ai 082, L0 item 2). +struct SSHInvocationTests { + private func recipe(_ arguments: [String]) -> SSHConnectionRecipe? { + guard case .supported(let recipe) = SSHInvocation.parse(arguments) else { return nil } + return recipe + } + + @Test func connectionOptionsAreReplayedAndTheRemoteCommandIsNot() { + #expect(recipe(["mini"]) == SSHConnectionRecipe(destination: "mini")) + #expect( + recipe(["-p", "2222", "-l", "dev", "-i", "~/.ssh/id", "-J", "jump", "box", "claude", "--resume"]) + == SSHConnectionRecipe( + destination: "box", options: ["-p", "2222", "-l", "dev", "-i", "~/.ssh/id", "-J", "jump"])) + // Joined values and grouped flags. + #expect( + recipe(["-p2222", "-4C", "dev@box"]) + == SSHConnectionRecipe(destination: "dev@box", options: ["-p", "2222", "-4", "-C"])) + #expect(recipe(["-F", "/tmp/cfg", "--", "box"])?.configFile == "/tmp/cfg") + } + + @Test func forwardingSessionAndControlOptionsAreDropped() { + let parsed = recipe([ + "-t", "-A", "-X", "-v", "-L", "8080:localhost:80", "-R", "9000:localhost:9000", "-D", "1080", "-S", "/tmp/ctl", + "-o", "LocalForward=1 localhost:1", "-o", "RemoteCommand=tmux attach", "-o", "RequestTTY yes", "-o", + "ControlMaster=auto", "-o", "ConnectTimeout=3", "-o", "SendEnv=FOO", "box", + ]) + #expect(parsed == SSHConnectionRecipe(destination: "box")) + } + + @Test func connectionShapingConfigOptionsAreReplayedInAnySpelling() { + #expect( + recipe(["-o", "Port=2222", "-o", "IdentityFile ~/.ssh/k", "-oUser=dev", "-o", "ProxyJump = j", "box"])?.options + == ["-o", "Port=2222", "-o", "IdentityFile ~/.ssh/k", "-o", "User=dev", "-o", "ProxyJump = j"]) + } + + @Test(arguments: [ + ["-N", "box"], ["-f", "box"], ["-W", "host:22", "jump"], ["-G", "box"], ["-O", "check", "box"], + ["-s", "box", "sftp"], ["-Z", "box"], ["-o", "FancyNewOption=yes", "box"], ["-p"], [], ["-v"], + ]) + func anythingOutsideTheGrammarIsUnsupported(arguments: [String]) { + guard case .unsupported = SSHInvocation.parse(arguments) else { + Issue.record("expected unsupported for \(arguments)") + return + } + } +} diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index e1af2147..279ac6b7 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -8,7 +8,8 @@ sections below record what shipped and what differs from the plan. | Date | Change | Ref | | --- | --- | --- | | 2026-10-17 | S1: typed process references (C2) and the `ConnectionKey` value type; local-only OS entry points; evidence retirement on an origin change. No behaviour change for local panes. Review Loop clean after 3 rounds | #924 | -| 2026-10-17 | S2: host identity (C1), connection owner (C4), snapshot contract (C3), structured commands (C5), probe and parser (C6), all unused by the app | S2 PR | +| 2026-10-17 | S2: host identity (C1), connection owner (C4), snapshot contract (C3), structured commands (C5), probe and parser (C6), all unused by the app. Review Loop clean after 2 rounds | #925 | +| 2026-10-17 | S3a: manual-session correlation behind `PROWL_REMOTE_AGENT_DETECTION` — `LC_PROWL_PANE`, argv grammar, per-pane tracker, identity, snapshot release, remote OSC fence | S3a PR | ## S1 — Typed process references @@ -131,3 +132,47 @@ All under `App/Sources/Infrastructure/RemoteHost/`; nothing in the app calls it `TEST_RUNNER_PROWL_TEST_SSH_HOST`. It passed against mini and the Debian container (`-p 2222`): two complete snapshots on one master, and no socket left after release. +## S3a — Manual-session correlation (behind a flag) + +The plan's S3 is split: S3a binds and releases remote agents; S3b adds the stale projection, +the degraded OSC-only binding and admission refusal. Both stay behind +`PROWL_REMOTE_AGENT_DETECTION=1` (or the `remoteAgentDetectionEnabled` default) until S4. + +- **Pane id.** Every pane exports `LC_PROWL_PANE` next to `PROWL_PANE_ID`, whatever the + flag says, so panes opened before the flag is turned on can be correlated. +- **Argv grammar** (`SSHInvocation`). Connection-shaping options are replayed. These are + dropped: forwarding, session and control options, including `ConnectTimeout`, + `SendEnv`, `SetEnv` and `LogLevel`. These are `unsupported`: `-N`, `-f`, `-W`, `-s`, + `-G`, `-O`, `-Q`, `-V`, unknown flags, unknown `-o` keys, and a missing destination. + The remote command is never replayed. +- **Trigger.** A foreground `ssh` with no local agent. The tracker belongs to that `ssh` + generation (pid and start time); a new `ssh`, a local agent, the flag off or a closed + pane retire it and close the lease, and snapshots that arrive later are dropped. +- **Tracker** (`RemotePaneTracker`). It leases the host's owner (`ssh -G`, then the + registry) and consumes snapshots on the main actor. + - Correlation: `pending`, `correlated`, `ambiguous`, `unmapped`, `probeUnavailable`, + `unsupported`. Only `correlated` binds. + - The bound agent is held across ticks, so the coordinator can delegate. It is + released by the C3 rule (two complete fresh snapshots without it) or at once when the + local `ssh` is gone. An OSC 133 D from the remote shell asks for a snapshot right away. + - Cadence: 1 s while an agent is bound or the pane settles after a wake, 3 s otherwise. + It counts from the host's shared latest snapshot, so panes on one host do not double + the probe rate. Wakes (input, reports) request at once; a snapshot interrupts the + loop's sleep without opening a settle window. +- **Remote OSC fence.** The first agent since the `ssh` started counts reports from the + `ssh` start; a successor counts only reports after the snapshot that confirmed it. The S1 + origin fence applies to local generations only. +- **Measured** in a Debug instance with the flag on: + - mini (macOS 26.5), Claude Code 2.1.302 started by hand: appeared 0.62 s after the + command; trust prompt Blocked; `osc.idle` → `osc.working` → `osc.done` through ssh; + released 2.69 s after Ctrl+C. + - mini, Codex 0.162.1 (no OSC): appeared 1.13 s after the command; screen rules. Killing + the local `ssh` released it in 0.44 s. + - Debian container, Claude Code 2.1.302 over `-p 2222 -o UserKnownHostsFile=…`: + appeared 2.39 s after the command (node start and a new master); released 2.35 s + after Ctrl+C. + - Two panes on one host: separate attribution, one control socket. + - Claude inside remote tmux: no entry (S5). + - Debug build CPU: a delegated remote Claude costs 0.1–0.8 % of a core. A screen-rule + agent costs as much as it does locally. Before the bound agent was held across ticks, + a remote OSC producer could not delegate and read the screen every 300 ms. diff --git a/docs/components/cli.md b/docs/components/cli.md index 16272e32..ad92b9f4 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -86,6 +86,9 @@ process launched inside it (agents, their tools, scripts): `create pane`, and as the guard that keeps automation from acting on itself. - `PROWL_WORKTREE_PATH`, `PROWL_ROOT_PATH` — the worktree directory and repository root (see [custom-actions](custom-actions.md)). +- `LC_PROWL_PANE` — the same UUID as `PROWL_PANE_ID`, under a name that `ssh` forwards by + default (`SendEnv LC_*` on the client, `AcceptEnv LC_*` on most servers). Prowl uses it + to match a remote session to its pane; do not use it as a selector. Resolve your own pane, tab, and worktree. Inside Codex (`CODEX_THREAD_ID` is set), take the pane from `.data.caller` instead of the variable (see below): -- 2.54.0 From 10bde198cc02a626fadd509de01c97fbd26b03f8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sat, 17 Oct 2026 23:48:38 +0900 Subject: [PATCH 07/51] Keep remote evidence mapped, parse trailing ssh options, keep probing - Recognize the pane's remote session by its anchor once its id was read; unmapped or ambiguous scans neither release nor confirm the bound agent. - Read ssh options after the destination, as OpenSSH does. - Resolve and probe in the local ssh's working directory. - Keep ssh panes ticking at the remote cadence; a bound agent at least 1 s. - Confirm a successor after the tick's local probe returns. --- .../BusinessLogic/RemotePaneTracker.swift | 14 ++- ...WorktreeTerminalState+AgentDetection.swift | 24 ++++- .../AgentDetection/ProcessDetection.swift | 16 ++++ .../RemoteHost/RemoteCommandRunner.swift | 42 +++++++-- .../RemoteHost/RemoteConnectionOwner.swift | 6 +- .../RemoteHost/RemoteConnectionRegistry.swift | 3 +- .../RemoteHost/SSHConnectionRecipe.swift | 14 ++- .../RemoteHost/SSHInvocation.swift | 94 ++++++++++++------- App/Tests/RemoteAgentDetectionTests.swift | 94 +++++++++++++++++-- App/Tests/RemoteCommandRunnerTests.swift | 23 +++++ App/Tests/RemoteConnectionOwnerTests.swift | 6 +- App/Tests/SSHInvocationTests.swift | 26 +++++ docs-ai/082-ssh-remote-agents/001-action.md | 18 +++- 13 files changed, 315 insertions(+), 65 deletions(-) diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index 1765ea44..ae892049 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -92,6 +92,11 @@ final class RemotePaneTracker { private(set) var latest: RemoteProbeSnapshot? private var consumedSequence: (epoch: UInt64, sequence: UInt64)? + /// The remote session's anchor, learned from the snapshot that first carried this + /// pane's id. On macOS the id is readable only from non-platform foreground processes, so + /// the session that showed it once is recognized by its anchor when the system shell is + /// back in front. + private var anchor: Int32? private var bound: IdentifiedAgentProcess? private var presence = RemoteProcessPresence() private var boundFence: Date? @@ -175,6 +180,11 @@ final class RemotePaneTracker { private func consume(_ snapshot: RemoteProbeSnapshot, report: RemoteProbeReport, now: Date) -> RemoteProcessPresence.Verdict? { + let mapped = sessions(in: report) + if anchor == nil, mapped.count == 1, mapped[0].paneID == surfaceID { anchor = mapped[0].anchorPID } + // Only this pane's own session is evidence about its agent: an unmapped or + // ambiguous scan proves nothing (C3). + guard mapped.count == 1 else { return bound == nil ? nil : .unchanged } let job = correlatedJob(report: report) if let identified = job.flatMap(identifyAgentInJob), identified.process.ref != bound?.process.ref { bound = identified @@ -208,7 +218,9 @@ final class RemotePaneTracker { } private func sessions(in report: RemoteProbeReport) -> [RemoteProbeSession] { - report.sessions.filter { $0.paneID == surfaceID } + report.sessions.filter { session in + session.paneID == surfaceID || (session.paneID == nil && anchor != nil && session.anchorPID == anchor) + } } private func correlatedJob(report: RemoteProbeReport) -> ForegroundJob? { diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 3c1037ee..d5019956 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -51,7 +51,10 @@ extension WorktreeTerminalState { self.agentDetectionSchedules[view.id] = self.nextScheduleAfterTick( surfaceID: view.id, hasAgent: hasAgent, now: now) - guard let interval = self.agentDetectionSchedules[view.id]?.nextInterval(now: now) else { + guard + let interval = self.remoteAdjustedInterval( + surfaceID: view.id, self.agentDetectionSchedules[view.id]?.nextInterval(now: now)) + else { self.finishColdAgentDetection(forSurfaceID: view.id) return } @@ -74,6 +77,17 @@ extension WorktreeTerminalState { agentDetectionSchedules[surfaceID] ?? .cold, hasAgent: hasAgent, delegated: delegated, now: now) } + /// A pane with a foreground `ssh` keeps ticking whatever the local schedule says: the + /// host is probed only from ticks, and a remote agent can start without a key press here. + /// A bound remote agent is ticked at least every second so two absent snapshots release it + /// within the L0 budget. + func remoteAdjustedInterval(surfaceID: UUID, _ interval: Duration?) -> Duration? { + guard let tracker = remotePaneTrackers[surfaceID] else { return interval } + let cap: Duration = .seconds( + tracker.boundAgent != nil ? RemotePaneTracker.activeCadence : RemotePaneTracker.idleCadence) + return min(interval ?? cap, cap) + } + /// Whether a wake still holds the surface on the active cadence; an expired window is /// dropped on the way out. func isAgentDetectionSettling(surfaceID: UUID, now: Date) -> Bool { @@ -123,7 +137,9 @@ extension WorktreeTerminalState { } let localIdentified = job.flatMap { identifyAgentInJob($0) } - let remote = observeRemoteAgent(job: job, localIdentified: localIdentified, surfaceID: surfaceID, now: now) + // A successor is confirmed when the probe returns, not when the poll began (as below). + let remote = observeRemoteAgent( + job: job, localIdentified: localIdentified, surfaceID: surfaceID, now: max(now, Date())) let identified = localIdentified ?? remote?.identified // The origin change is confirmed when the probe returns, not when the poll began: // a report that landed while the probe ran still belongs to the departed origin. @@ -309,7 +325,9 @@ extension WorktreeTerminalState { } else { retireRemotePaneTracker(surfaceID: surfaceID) tracker = RemotePaneTracker( - surfaceID: surfaceID, ssh: ssh.generation, invocation: SSHInvocation.parse(ssh.arguments), + surfaceID: surfaceID, ssh: ssh.generation, + invocation: SSHInvocation.parse( + ssh.arguments, workingDirectory: ProcessDetection.currentDirectory(pid: ssh.generation.pid)), source: remoteSnapshotSourceForTesting ?? .live ) { [weak self] in self?.wakeAgentDetectionForRemoteSnapshot(surfaceID: surfaceID) diff --git a/App/Sources/Infrastructure/AgentDetection/ProcessDetection.swift b/App/Sources/Infrastructure/AgentDetection/ProcessDetection.swift index 4a7cb94c..ed160c1c 100644 --- a/App/Sources/Infrastructure/AgentDetection/ProcessDetection.swift +++ b/App/Sources/Infrastructure/AgentDetection/ProcessDetection.swift @@ -230,6 +230,22 @@ nonisolated enum ProcessDetection { return result == Int32(size) ? info : nil } + /// The current working directory of a same-user process. + static func currentDirectory(pid: pid_t) -> String? { + var info = proc_vnodepathinfo() + let size = MemoryLayout.size + let result = withUnsafeMutablePointer(to: &info) { pointer in + proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, pointer, Int32(size)) + } + guard result == Int32(size) else { return nil } + return withUnsafeBytes(of: info.pvi_cdir.vip_path) { rawBuffer -> String? in + let bytes = rawBuffer.bindMemory(to: UInt8.self) + let end = bytes.firstIndex(of: 0) ?? bytes.endIndex + guard end > bytes.startIndex else { return nil } + return String(bytes: bytes[.. Date? { guard let info = processBSDInfo(pid: pid), info.pbi_start_tvsec > 0 else { return nil } return Date( diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift b/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift index 1f63ab7b..f67bda9e 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift @@ -31,12 +31,33 @@ nonisolated enum RemoteCommandOutcome: Equatable, Sendable { /// suspension and never wait for a shared pool: a busy app must not turn a finished probe /// into a timeout. nonisolated struct RemoteCommandRunner: Sendable { - var run: - @Sendable (_ executable: URL, _ arguments: [String], _ input: Data, _ limits: RemoteCommandLimits) async -> - RemoteCommandOutcome + var execute: + @Sendable ( + _ executable: URL, _ arguments: [String], _ input: Data, _ limits: RemoteCommandLimits, + _ workingDirectory: String? + ) async -> RemoteCommandOutcome + + init( + execute: @escaping @Sendable (URL, [String], Data, RemoteCommandLimits, String?) async -> RemoteCommandOutcome + ) { + self.execute = execute + } + + /// Runs in `workingDirectory` when it names an existing directory, else in the app's. + func run( + _ executable: URL, _ arguments: [String], _ input: Data, _ limits: RemoteCommandLimits, + workingDirectory: String? = nil + ) async -> RemoteCommandOutcome { + await execute(executable, arguments, input, limits, workingDirectory) + } - static let live = RemoteCommandRunner { executable, arguments, input, limits in - await RemoteProcess.run(executable: executable, arguments: arguments, input: input, limits: limits) + static let live = RemoteCommandRunner { executable, arguments, input, limits, workingDirectory in + var isDirectory: ObjCBool = false + let directory = workingDirectory.flatMap { + FileManager.default.fileExists(atPath: $0, isDirectory: &isDirectory) && isDirectory.boolValue ? $0 : nil + } + return await RemoteProcess.run( + executable: executable, arguments: arguments, input: input, limits: limits, workingDirectory: directory) } /// Grace period between SIGTERM and SIGKILL. @@ -105,12 +126,12 @@ private nonisolated final class RemoteProcess: @unchecked Sendable { if let (continuation, outcome) = ready { continuation.resume(returning: outcome) } } - static func run(executable: URL, arguments: [String], input: Data, limits: RemoteCommandLimits) async - -> RemoteCommandOutcome - { + static func run( + executable: URL, arguments: [String], input: Data, limits: RemoteCommandLimits, workingDirectory: String? + ) async -> RemoteCommandOutcome { let pipes = Pipes() let pid: pid_t - switch spawn(executable: executable, arguments: arguments, pipes: pipes) { + switch spawn(executable: executable, arguments: arguments, pipes: pipes, workingDirectory: workingDirectory) { case .success(let spawned): pid = spawned case .failure(let message): return .launchFailed(message) } @@ -167,10 +188,11 @@ private nonisolated final class RemoteProcess: @unchecked Sendable { case failure(String) } - private static func spawn(executable: URL, arguments: [String], pipes: Pipes) -> Spawned { + private static func spawn(executable: URL, arguments: [String], pipes: Pipes, workingDirectory: String?) -> Spawned { var actions: posix_spawn_file_actions_t? posix_spawn_file_actions_init(&actions) defer { posix_spawn_file_actions_destroy(&actions) } + if let workingDirectory { posix_spawn_file_actions_addchdir_np(&actions, workingDirectory) } posix_spawn_file_actions_adddup2(&actions, pipes.input.fileHandleForReading.fileDescriptor, 0) posix_spawn_file_actions_adddup2(&actions, pipes.output.fileHandleForWriting.fileDescriptor, 1) posix_spawn_file_actions_adddup2(&actions, pipes.error.fileHandleForWriting.fileDescriptor, 2) diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift b/App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift index f44354e7..7d5f70c4 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift @@ -114,7 +114,8 @@ actor RemoteConnectionOwner { configuration.sshExecutable, ["-o", "ControlPath=\(controlPath)", "-O", "exit", "--", recipe.destination], Data(), - RemoteCommandLimits(deadline: 2, outputBytes: 1024, errorBytes: 1024)) + RemoteCommandLimits(deadline: 2, outputBytes: 1024, errorBytes: 1024), + workingDirectory: recipe.workingDirectory) } nonisolated static func defaultControlDirectory() -> String { @@ -149,7 +150,8 @@ actor RemoteConnectionOwner { let arguments = SSHSideChannel.scriptArguments( recipe: recipe, controlPath: controlPath, persist: configuration.controlPersistSeconds) let outcome = await runner.run( - configuration.sshExecutable, arguments, Data(RemoteProbeScript.text.utf8), configuration.limits) + configuration.sshExecutable, arguments, Data(RemoteProbeScript.text.utf8), configuration.limits, + workingDirectory: recipe.workingDirectory) let (result, report) = Self.interpret(outcome) switch result { case .complete, .partial: diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteConnectionRegistry.swift b/App/Sources/Infrastructure/RemoteHost/RemoteConnectionRegistry.swift index 946d43a6..2028bba2 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteConnectionRegistry.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteConnectionRegistry.swift @@ -27,7 +27,8 @@ actor RemoteConnectionRegistry { func resolve(_ recipe: SSHConnectionRecipe) async -> ConnectionKey? { let outcome = await runner.run( configuration.sshExecutable, SSHSideChannel.resolveArguments(recipe: recipe), Data(), - RemoteCommandLimits(deadline: 3, outputBytes: 64 * 1024, errorBytes: 4 * 1024)) + RemoteCommandLimits(deadline: 3, outputBytes: 64 * 1024, errorBytes: 4 * 1024), + workingDirectory: recipe.workingDirectory) guard case .exited(let result) = outcome, result.exitCode == 0 else { return nil } return ConnectionKey( effectiveConfiguration: String(bytes: result.output, encoding: .utf8) ?? "", configFile: recipe.configFile) diff --git a/App/Sources/Infrastructure/RemoteHost/SSHConnectionRecipe.swift b/App/Sources/Infrastructure/RemoteHost/SSHConnectionRecipe.swift index 688d1f23..4d71d1f2 100644 --- a/App/Sources/Infrastructure/RemoteHost/SSHConnectionRecipe.swift +++ b/App/Sources/Infrastructure/RemoteHost/SSHConnectionRecipe.swift @@ -10,15 +10,23 @@ nonisolated struct SSHConnectionRecipe: Hashable, Sendable, Codable { /// supported connection-shaping set. let options: [String] - init(destination: String, options: [String] = []) { + /// The directory relative operands resolve against: the user's `ssh` ran there, so + /// `-F ./config` or `-i keys/id` must mean the same files for the side channel. + let workingDirectory: String? + + init(destination: String, options: [String] = [], workingDirectory: String? = nil) { self.destination = destination self.options = options + self.workingDirectory = workingDirectory } - /// The `-F` file the options name, if any; part of the connection key. + /// The `-F` file the options name, absolute, if any; part of the connection key. var configFile: String? { guard let index = options.firstIndex(of: "-F"), options.indices.contains(index + 1) else { return nil } - return options[index + 1] + let path = options[index + 1] + guard !path.hasPrefix("/"), !path.hasPrefix("~"), let workingDirectory else { return path } + return URL(filePath: workingDirectory, directoryHint: .isDirectory).appending(path: path).standardized + .path(percentEncoded: false) } } diff --git a/App/Sources/Infrastructure/RemoteHost/SSHInvocation.swift b/App/Sources/Infrastructure/RemoteHost/SSHInvocation.swift index 3a6e5e0d..fef1c35e 100644 --- a/App/Sources/Infrastructure/RemoteHost/SSHInvocation.swift +++ b/App/Sources/Infrastructure/RemoteHost/SSHInvocation.swift @@ -46,51 +46,79 @@ nonisolated enum SSHInvocation: Equatable, Sendable { "connecttimeout", ] - /// Parses `arguments` (argv without `ssh` itself). - static func parse(_ arguments: [String]) -> SSHInvocation { + /// Parses `arguments` (argv without `ssh` itself). Like OpenSSH, options are read + /// before and after the destination; the first other argument starts the remote command. + /// `workingDirectory` is the `ssh` process's directory: relative file operands (`-F`, + /// `-i`, `IdentityFile`, …) resolve against it, as they do for the user's `ssh`. + static func parse(_ arguments: [String], workingDirectory: String? = nil) -> SSHInvocation { var options: [String] = [] + var destination: String? var index = arguments.startIndex while index < arguments.endIndex { let argument = arguments[index] if argument == "--" { - index += 1 + // Ends the options; a destination may still follow. + if destination == nil, arguments.indices.contains(index + 1) { destination = arguments[index + 1] } break } - guard argument.hasPrefix("-"), argument.count > 1 else { break } - var letters = argument.dropFirst()[...] - while let letter = letters.popFirst() { - if replayedFlags.contains(letter) { - options.append("-\(letter)") - continue - } - if droppedFlags.contains(letter) { continue } - guard replayedValues.contains(letter) || droppedValues.contains(letter) || letter == "o" else { - return .unsupported("ssh option -\(letter)") + if argument.hasPrefix("-"), argument.count > 1 { + switch parseOptionGroup(arguments, at: &index) { + case .success(let parsed): options += parsed + case .failure(let reason): return .unsupported(reason.message) } - // The value is the rest of this argument, or the next one. - let value: String - if !letters.isEmpty { - value = String(letters) - letters = "" - } else { - index += 1 - guard index < arguments.endIndex else { return .unsupported("ssh option -\(letter) without a value") } - value = arguments[index] + index += 1 + continue + } + guard destination == nil else { break } + destination = argument + index += 1 + } + guard let destination, !destination.isEmpty else { return .unsupported("no destination") } + return .supported( + SSHConnectionRecipe( + destination: destination, options: options, workingDirectory: workingDirectory)) + } + + private struct ParseFailure: Error { + let message: String + } + + /// One `-xyz` argument (and the value it consumes); `index` moves to the last argument read. + private static func parseOptionGroup(_ arguments: [String], at index: inout Int) -> Result<[String], ParseFailure> { + var options: [String] = [] + var letters = arguments[index].dropFirst()[...] + while let letter = letters.popFirst() { + if replayedFlags.contains(letter) { + options.append("-\(letter)") + continue + } + if droppedFlags.contains(letter) { continue } + guard replayedValues.contains(letter) || droppedValues.contains(letter) || letter == "o" else { + return .failure(ParseFailure(message: "ssh option -\(letter)")) + } + // The value is the rest of this argument, or the next one. + let value: String + if !letters.isEmpty { + value = String(letters) + letters = "" + } else { + index += 1 + guard index < arguments.endIndex else { + return .failure(ParseFailure(message: "ssh option -\(letter) without a value")) } - if letter == "o" { - switch configOption(value) { - case .replayed: options += ["-o", value] - case .dropped: break - case .unsupported(let key): return .unsupported("ssh option -o \(key)") - } - } else if replayedValues.contains(letter) { - options += ["-\(letter)", value] + value = arguments[index] + } + if letter == "o" { + switch configOption(value) { + case .replayed: options += ["-o", value] + case .dropped: break + case .unsupported(let key): return .failure(ParseFailure(message: "ssh option -o \(key)")) } + } else if replayedValues.contains(letter) { + options += ["-\(letter)", value] } - index += 1 } - guard index < arguments.endIndex, !arguments[index].isEmpty else { return .unsupported("no destination") } - return .supported(SSHConnectionRecipe(destination: arguments[index], options: options)) + return .success(options) } private enum ConfigOption: Equatable { diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 0160bf02..d4de770d 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -21,6 +21,9 @@ struct RemoteAgentDetectionTests { private final class Probe { var job: ForegroundJob? + /// When set, the next probe call suspends here until resumed. + var gate: CheckedContinuation? + var entered: AsyncStream.Continuation? } nonisolated final class FakeRemoteSource: @unchecked Sendable { @@ -68,7 +71,16 @@ struct RemoteAgentDetectionTests { state.remoteAgentDetectionEnabledForTesting = enabled state.remoteSnapshotSourceForTesting = source.source state.programStatusSupportForTesting = { _ in .verified } - state.agentProcessProbeForTesting = { _, _, _ in probe.job } + state.agentProcessProbeForTesting = { _, _, _ in + if let entered = probe.entered { + probe.entered = nil + await withCheckedContinuation { continuation in + probe.gate = continuation + entered.yield(()) + } + } + return probe.job + } state.lastWindowIsKey = false state.lastWindowIsVisible = false let tabID = state.tabManager.createTab(title: "worktree 1", icon: "terminal") @@ -103,12 +115,12 @@ struct RemoteAgentDetectionTests { private func snapshot( _ sequence: UInt64, pane: UUID?, foreground: RemoteProbeProcess?, extraPane: UUID? = nil, - outcome: RemoteProbeSnapshot.Outcome = .complete + outcome: RemoteProbeSnapshot.Outcome = .complete, anchor: Int32 = 100 ) -> RemoteProbeSnapshot { func session(_ id: UUID?) -> RemoteProbeSession { let process = foreground ?? remoteProcess(101, name: "zsh") return RemoteProbeSession( - paneID: id, anchorPID: 100, tty: "/dev/pts/0", foregroundProcessGroupID: process.processGroupID, + paneID: id, anchorPID: anchor, tty: "/dev/pts/0", foregroundProcessGroupID: process.processGroupID, processes: [process]) } var sessions = [session(pane)] @@ -170,7 +182,10 @@ struct RemoteAgentDetectionTests { fixture.state.onAgentEntryChanged = { emitted.append($0) } await bindClaude(fixture, probe: probe) - #expect(fixture.source.recipes == [SSHConnectionRecipe(destination: "mini", options: ["-p", "2222"])]) + #expect(fixture.source.recipes.map(\.destination) == ["mini"]) + #expect(fixture.source.recipes.first?.options == ["-p", "2222"]) + // Relative operands resolve where the user's `ssh` runs. + #expect(fixture.source.recipes.first?.workingDirectory == ProcessDetection.currentDirectory(pid: getpid())) let pane = fixture.state.surfaceAgentStates[fixture.surfaceID] #expect(pane?.detectedAgent == .claude) #expect(pane?.agentProcess == remoteRef(200)) @@ -193,16 +208,83 @@ struct RemoteAgentDetectionTests { // An inconclusive run neither proves absence nor resets the count. await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: nil, outcome: .partial)) await detect(fixture) - await deliver(fixture, snapshot(3, pane: fixture.surfaceID, foreground: nil)) + // Back at a system shell, macOS hides the pane id; the session is known by its anchor. + await deliver(fixture, snapshot(3, pane: nil, foreground: nil)) await detect(fixture) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) #expect(removed.isEmpty) - await deliver(fixture, snapshot(4, pane: fixture.surfaceID, foreground: nil)) + await deliver(fixture, snapshot(4, pane: nil, foreground: nil)) await detect(fixture) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) #expect(removed == [fixture.surfaceID]) } + @Test func unmappedOrAmbiguousScansNeverReleaseABoundAgent() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindClaude(fixture, probe: probe) + let claude = remoteProcess(200, name: "claude") + // Another anchor without a pane id, then this pane's id twice: neither is evidence. + for sequence in UInt64(2)...4 { + await deliver(fixture, snapshot(sequence, pane: nil, foreground: nil, anchor: 900)) + await detect(fixture) + } + await deliver(fixture, snapshot(5, pane: fixture.surfaceID, foreground: nil, extraPane: fixture.surfaceID)) + await detect(fixture) + await deliver(fixture, snapshot(6, pane: fixture.surfaceID, foreground: nil, extraPane: fixture.surfaceID)) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + // Mapped evidence counts again as soon as it returns. + await deliver(fixture, snapshot(7, pane: fixture.surfaceID, foreground: claude)) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) + } + + @Test func sshPanesKeepTickingOnTheRemoteCadence() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + #expect(fixture.state.remoteAdjustedInterval(surfaceID: fixture.surfaceID, nil) == nil) + #expect(fixture.state.remoteAdjustedInterval(surfaceID: fixture.surfaceID, .seconds(2)) == .seconds(2)) + probe.job = sshJob + await detect(fixture) + // No agent: never cold, at most every three seconds. + #expect(fixture.state.remoteAdjustedInterval(surfaceID: fixture.surfaceID, nil) == .seconds(3)) + #expect( + fixture.state.remoteAdjustedInterval(surfaceID: fixture.surfaceID, .milliseconds(300)) == .milliseconds(300)) + await settle { fixture.source.recipes.count == 1 } + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture) + // A bound agent (even a delegated one on the two-second schedule) ticks every second. + #expect(fixture.state.remoteAdjustedInterval(surfaceID: fixture.surfaceID, .seconds(2)) == .seconds(1)) + } + + @Test func predecessorReportsDuringTheConfirmingProbeNeverDecideForTheSuccessor() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindClaude(fixture, probe: probe) + fixture.state.handleProgramStatus( + GhosttyProgramStatusReport(state: .working, kind: nil, id: "", app: "claude-code"), surfaceID: fixture.surfaceID) + // The tick that will see the successor starts; while its local probe runs, the + // successor's snapshot arrives and the predecessor's last reports land. + let entered = AsyncStream.makeStream() + probe.entered = entered.continuation + let tick = Task { await self.detect(fixture) } + var iterator = entered.stream.makeAsyncIterator() + _ = await iterator.next() + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(300, name: "claude"))) + fixture.state.handleProgramStatus( + GhosttyProgramStatusReport(state: .done, kind: nil, id: "", app: "claude-code"), surfaceID: fixture.surfaceID) + fixture.state.handleProgramStatus( + GhosttyProgramStatusReport(state: .blocked, kind: nil, id: "child", app: "claude-code"), + surfaceID: fixture.surfaceID) + probe.gate?.resume() + _ = await tick.value + let pane = fixture.state.surfaceAgentStates[fixture.surfaceID] + #expect(pane?.agentProcess == remoteRef(300)) + #expect(pane?.decision?.reason.identifier != "osc.done") + #expect(pane?.state != .blocked) + } + @Test func theLocalSSHExitingReleasesAtOnceAndClosesTheLease() async { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/App/Tests/RemoteCommandRunnerTests.swift b/App/Tests/RemoteCommandRunnerTests.swift index 8d940d3a..40720fa7 100644 --- a/App/Tests/RemoteCommandRunnerTests.swift +++ b/App/Tests/RemoteCommandRunnerTests.swift @@ -92,6 +92,29 @@ nonisolated struct RemoteCommandRunnerTests { #expect(result.errorOutput == "oops\n") } + @Test func theCommandRunsInTheGivenDirectory() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: "prowl-runner-cwd-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let outcome = await runner.run( + URL(filePath: "/bin/pwd"), ["-P"], Data(), RemoteCommandLimits(), + workingDirectory: directory.path(percentEncoded: false)) + guard case .exited(let result) = outcome else { + Issue.record("expected exit, got \(outcome)") + return + } + let printed = String(bytes: result.output, encoding: .utf8)?.trimmingCharacters(in: .newlines) ?? "" + #expect(printed.hasSuffix(directory.lastPathComponent)) + // A directory that no longer exists falls back to the app's own. + let missing = await runner.run( + URL(filePath: "/bin/pwd"), [], Data(), RemoteCommandLimits(), workingDirectory: "/nonexistent/prowl") + guard case .exited(let fallback) = missing else { + Issue.record("expected exit, got \(missing)") + return + } + #expect(fallback.exitCode == 0) + } + @Test func aMissingExecutableFailsToLaunch() async { let outcome = await runner.run(URL(filePath: "/nonexistent/ssh"), [], Data(), RemoteCommandLimits()) guard case .launchFailed = outcome else { diff --git a/App/Tests/RemoteConnectionOwnerTests.swift b/App/Tests/RemoteConnectionOwnerTests.swift index 45d06d80..4e9f1000 100644 --- a/App/Tests/RemoteConnectionOwnerTests.swift +++ b/App/Tests/RemoteConnectionOwnerTests.swift @@ -97,7 +97,7 @@ struct RemoteConnectionOwnerTests { configuration.controlPersistSeconds = 30 return RemoteConnectionOwner( connection: Self.key, recipe: Self.recipe, configuration: configuration, - runner: RemoteCommandRunner { _, arguments, input, _ in await fake.run(arguments, input) }, + runner: RemoteCommandRunner { _, arguments, input, _, _ in await fake.run(arguments, input) }, now: { 100 }, sleep: { recorder.delays.append($0) }, random: { 0.5 }) @@ -188,7 +188,7 @@ struct RemoteConnectionOwnerTests { await fake.setHoldsExit(true) let registry = RemoteConnectionRegistry( configuration: RemoteConnectionOwner.Configuration(controlDirectory: "/tmp/prowl-ssh-tests"), - runner: RemoteCommandRunner { _, arguments, input, _ in await fake.run(arguments, input) }) + runner: RemoteCommandRunner { _, arguments, input, _, _ in await fake.run(arguments, input) }) let first = await registry.acquire(Self.key, recipe: Self.recipe) #expect(await registry.leaseCount(Self.key) == 1) let retiring = Task { await registry.release(Self.key) } @@ -212,7 +212,7 @@ struct RemoteConnectionOwnerTests { let fake = FakeRunner() let registry = RemoteConnectionRegistry( configuration: RemoteConnectionOwner.Configuration(controlDirectory: "/tmp/prowl-ssh-tests"), - runner: RemoteCommandRunner { _, arguments, input, _ in await fake.run(arguments, input) }) + runner: RemoteCommandRunner { _, arguments, input, _, _ in await fake.run(arguments, input) }) let first = await registry.acquire(Self.key, recipe: Self.recipe) let second = await registry.acquire(Self.key, recipe: Self.recipe) #expect(first === second) diff --git a/App/Tests/SSHInvocationTests.swift b/App/Tests/SSHInvocationTests.swift index 822f6cc8..f5389a54 100644 --- a/App/Tests/SSHInvocationTests.swift +++ b/App/Tests/SSHInvocationTests.swift @@ -23,6 +23,32 @@ struct SSHInvocationTests { #expect(recipe(["-F", "/tmp/cfg", "--", "box"])?.configFile == "/tmp/cfg") } + @Test func optionsAfterTheDestinationShapeTheConnectionUntilTheCommand() { + // OpenSSH keeps reading options after the destination. + #expect( + recipe(["host", "-p", "2222", "-l", "reviewer"]) + == SSHConnectionRecipe(destination: "host", options: ["-p", "2222", "-l", "reviewer"])) + #expect(recipe(["host", "-o", "HostName=10.0.0.2"])?.options == ["-o", "HostName=10.0.0.2"]) + // The first other argument starts the command; what follows is the command's. + #expect( + recipe(["-p", "22", "host", "claude", "-p", "prompt"]) + == SSHConnectionRecipe(destination: "host", options: ["-p", "22"])) + #expect(recipe(["host", "--", "-p", "2222"])?.options == []) + } + + @Test func relativeOperandsKeepTheSSHWorkingDirectory() { + guard + case .supported(let parsed) = SSHInvocation.parse(["-F", "./ssh_config", "dev"], workingDirectory: "/work/proj") + else { + Issue.record("expected supported") + return + } + #expect(parsed.workingDirectory == "/work/proj") + #expect(parsed.configFile == "/work/proj/ssh_config") + #expect( + SSHConnectionRecipe(destination: "d", options: ["-F", "~/cfg"], workingDirectory: "/w").configFile == "~/cfg") + } + @Test func forwardingSessionAndControlOptionsAreDropped() { let parsed = recipe([ "-t", "-A", "-X", "-v", "-L", "8080:localhost:80", "-R", "9000:localhost:9000", "-D", "1080", "-S", "/tmp/ctl", diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 279ac6b7..78f153c2 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -144,23 +144,32 @@ the degraded OSC-only binding and admission refusal. Both stay behind dropped: forwarding, session and control options, including `ConnectTimeout`, `SendEnv`, `SetEnv` and `LogLevel`. These are `unsupported`: `-N`, `-f`, `-W`, `-s`, `-G`, `-O`, `-Q`, `-V`, unknown flags, unknown `-o` keys, and a missing destination. - The remote command is never replayed. + The remote command is never replayed. As in OpenSSH, options are read before and after + the destination, up to the first other argument. The tracker runs `ssh -G` and the side + channel in the local `ssh`'s working directory, so relative `-F`/`-i` operands mean the + same files (review round 1). - **Trigger.** A foreground `ssh` with no local agent. The tracker belongs to that `ssh` generation (pid and start time); a new `ssh`, a local agent, the flag off or a closed pane retire it and close the lease, and snapshots that arrive later are dropped. - **Tracker** (`RemotePaneTracker`). It leases the host's owner (`ssh -G`, then the registry) and consumes snapshots on the main actor. - Correlation: `pending`, `correlated`, `ambiguous`, `unmapped`, `probeUnavailable`, - `unsupported`. Only `correlated` binds. + `unsupported`. Only `correlated` binds. The session that first carried the pane id is + also recognized by its anchor pid: back at the macOS system shell the id is hidden again. + Only that single mapped session is evidence; an unmapped or ambiguous scan neither + releases nor confirms the bound agent (review round 1). - The bound agent is held across ticks, so the coordinator can delegate. It is released by the C3 rule (two complete fresh snapshots without it) or at once when the local `ssh` is gone. An OSC 133 D from the remote shell asks for a snapshot right away. - Cadence: 1 s while an agent is bound or the pane settles after a wake, 3 s otherwise. + A pane with a foreground `ssh` never goes cold, and a bound agent is ticked at least + every second, even on the two-second delegated schedule (review round 1). It counts from the host's shared latest snapshot, so panes on one host do not double the probe rate. Wakes (input, reports) request at once; a snapshot interrupts the loop's sleep without opening a settle window. - **Remote OSC fence.** The first agent since the `ssh` started counts reports from the - `ssh` start; a successor counts only reports after the snapshot that confirmed it. The S1 + `ssh` start; a successor counts only reports after the snapshot that confirmed it, + timestamped after the tick's local probe returns (review round 1). The S1 origin fence applies to local generations only. - **Measured** in a Debug instance with the flag on: - mini (macOS 26.5), Claude Code 2.1.302 started by hand: appeared 0.62 s after the @@ -173,6 +182,9 @@ the degraded OSC-only binding and admission refusal. Both stay behind after Ctrl+C. - Two panes on one host: separate attribution, one control socket. - Claude inside remote tmux: no entry (S5). + - After review round 1: `ssh host -l user` bound Claude in 0.36 s; release after Ctrl+C + through the anchor-pinned session took 2.87 s; after 40 s idle, `sleep 5; codex` was + found 2.6 s after Codex started. - Debug build CPU: a delegated remote Claude costs 0.1–0.8 % of a core. A screen-rule agent costs as much as it does locally. Before the bound agent was held across ticks, a remote OSC producer could not delegate and read the screen every 300 ms. -- 2.54.0 From 1ffbe83fd47f3be08bcd173be61341763e357cb5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 00:07:34 +0900 Subject: [PATCH 08/51] Scope the remote anchor and keep the fence across Undo - The remembered session anchor is valid only within the connection epoch and boot it was seen in. - The remote OSC fence belongs to the ssh generation and lives with the surface's record store, so a tracker recreated after an undone close keeps it. --- .../BusinessLogic/RemotePaneTracker.swift | 72 +++++++++++++------ ...WorktreeTerminalState+AgentDetection.swift | 6 +- .../WorktreeTerminalState+ProgramStatus.swift | 1 + .../Models/WorktreeTerminalState.swift | 3 + App/Tests/RemoteAgentDetectionTests.swift | 52 +++++++++++++- docs-ai/082-ssh-remote-agents/001-action.md | 7 +- 6 files changed, 114 insertions(+), 27 deletions(-) diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index ae892049..e16099e3 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -1,5 +1,26 @@ import Foundation +/// The OSC fence of one local `ssh` generation (docs-ai 082, L0 item 5): the first agent +/// since the `ssh` started counts reports from the `ssh` start, a successor only those after +/// the snapshot that confirmed it. The terminal keeps it with the surface's record store, so +/// a tracker recreated for the same `ssh` (an undone close) never moves the fence back. +nonisolated struct RemoteFenceHistory: Equatable, Sendable { + let ssh: LocalProcessGeneration + /// The agent the fence belongs to. + private(set) var process: ProcessRef? + private(set) var fence: Date? + + init(ssh: LocalProcessGeneration) { + self.ssh = ssh + } + + mutating func bind(_ process: ProcessRef, confirmedAt now: Date) { + guard process != self.process else { return } + fence = self.process == nil && fence == nil ? ssh.startedAt : now + self.process = process + } +} + /// A pane's subscription to the host its foreground `ssh` reaches (docs-ai 082, L0). nonisolated struct RemoteConnectionLease: Sendable { let connection: ConnectionKey @@ -92,22 +113,28 @@ final class RemotePaneTracker { private(set) var latest: RemoteProbeSnapshot? private var consumedSequence: (epoch: UInt64, sequence: UInt64)? - /// The remote session's anchor, learned from the snapshot that first carried this - /// pane's id. On macOS the id is readable only from non-platform foreground processes, so - /// the session that showed it once is recognized by its anchor when the system shell is - /// back in front. - private var anchor: Int32? + /// The remote session's anchor, learned from a snapshot that carried this pane's id. On + /// macOS the id is readable only from non-platform foreground processes, so the session + /// that showed it is recognized by its anchor when the system shell is back in front. The + /// anchor is valid only within the connection epoch and boot it was seen in: a pid in a + /// new scope can be another session. + private var anchor: Anchor? + + private struct Anchor { + let pid: Int32 + let epoch: UInt64 + let bootID: String + } private var bound: IdentifiedAgentProcess? private var presence = RemoteProcessPresence() - private var boundFence: Date? - /// Whether any agent was bound since this `ssh` started; the first agent's reports - /// count from the `ssh` start, a successor's only after the snapshot that confirmed it. - private var hadAgent = false + /// The OSC fence of this `ssh` generation; it outlives the tracker (close and Undo). + private(set) var fenceHistory: RemoteFenceHistory init( surfaceID: UUID, ssh: LocalProcessGeneration, invocation: SSHInvocation, + fenceHistory: RemoteFenceHistory? = nil, source: RemoteSnapshotSource, uptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, onSnapshot: @escaping @MainActor () -> Void @@ -115,6 +142,7 @@ final class RemotePaneTracker { self.surfaceID = surfaceID self.ssh = ssh self.invocation = invocation + self.fenceHistory = fenceHistory.flatMap { $0.ssh == ssh ? $0 : nil } ?? RemoteFenceHistory(ssh: ssh) self.source = source self.uptime = uptime self.onSnapshot = onSnapshot @@ -173,24 +201,25 @@ final class RemotePaneTracker { } return Observation( correlation: correlation, connection: lease?.connection, identified: bound, verdict: verdict, - programStatusFence: boundFence) + programStatusFence: fenceHistory.fence) } /// Applies a new snapshot to the binding and returns its verdict for the bound agent. private func consume(_ snapshot: RemoteProbeSnapshot, report: RemoteProbeReport, now: Date) -> RemoteProcessPresence.Verdict? { - let mapped = sessions(in: report) - if anchor == nil, mapped.count == 1, mapped[0].paneID == surfaceID { anchor = mapped[0].anchorPID } + let mapped = sessions(in: report, epoch: snapshot.connectionEpoch) + if mapped.count == 1, mapped[0].paneID == surfaceID { + anchor = Anchor(pid: mapped[0].anchorPID, epoch: snapshot.connectionEpoch, bootID: report.bootID) + } // Only this pane's own session is evidence about its agent: an unmapped or // ambiguous scan proves nothing (C3). guard mapped.count == 1 else { return bound == nil ? nil : .unchanged } - let job = correlatedJob(report: report) + let job = correlatedJob(report: report, epoch: snapshot.connectionEpoch) if let identified = job.flatMap(identifyAgentInJob), identified.process.ref != bound?.process.ref { bound = identified presence = RemoteProcessPresence() - boundFence = hadAgent ? now : ssh.startedAt - hadAgent = true + fenceHistory.bind(identified.process.ref, confirmedAt: now) } guard let current = bound else { return nil } let contains = job?.processes.contains { $0.ref == current.process.ref } ?? false @@ -210,21 +239,22 @@ final class RemotePaneTracker { if case .failed(let failure) = latest.outcome { return .probeUnavailable(failure) } return .probeUnavailable(nil) } - switch sessions(in: report).count { + switch sessions(in: report, epoch: latest.connectionEpoch).count { case 0: return latest.isComplete ? .unmapped : .pending case 1: return .correlated default: return .ambiguous } } - private func sessions(in report: RemoteProbeReport) -> [RemoteProbeSession] { - report.sessions.filter { session in - session.paneID == surfaceID || (session.paneID == nil && anchor != nil && session.anchorPID == anchor) + private func sessions(in report: RemoteProbeReport, epoch: UInt64) -> [RemoteProbeSession] { + let anchorPID = anchor.flatMap { $0.epoch == epoch && $0.bootID == report.bootID ? $0.pid : nil } + return report.sessions.filter { session in + session.paneID == surfaceID || (session.paneID == nil && anchorPID != nil && session.anchorPID == anchorPID) } } - private func correlatedJob(report: RemoteProbeReport) -> ForegroundJob? { - let matching = sessions(in: report) + private func correlatedJob(report: RemoteProbeReport, epoch: UInt64) -> ForegroundJob? { + let matching = sessions(in: report, epoch: epoch) guard matching.count == 1, let session = matching.first, let connection = lease?.connection else { return nil } return session.foregroundJob(connection: connection, bootID: report.bootID) } diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index d5019956..b385307a 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -328,6 +328,7 @@ extension WorktreeTerminalState { surfaceID: surfaceID, ssh: ssh.generation, invocation: SSHInvocation.parse( ssh.arguments, workingDirectory: ProcessDetection.currentDirectory(pid: ssh.generation.pid)), + fenceHistory: remoteFenceHistoryBySurface[surfaceID], source: remoteSnapshotSourceForTesting ?? .live ) { [weak self] in self?.wakeAgentDetectionForRemoteSnapshot(surfaceID: surfaceID) @@ -337,7 +338,9 @@ extension WorktreeTerminalState { } let active = surfaceAgentStates[surfaceID]?.detectedAgent != nil || isAgentDetectionSettling(surfaceID: surfaceID, now: now) - return tracker.observe(active: active, now: now) + let observation = tracker.observe(active: active, now: now) + remoteFenceHistoryBySurface[surfaceID] = tracker.fenceHistory + return observation } /// A local foreground `ssh` and its arguments (without `argv[0]`), with the generation @@ -735,6 +738,7 @@ extension WorktreeTerminalState { programStatusStoresBySurface.removeAll() programStatusUnmappedAppsLoggedBySurface.removeAll() programStatusOriginBySurface.removeAll() + remoteFenceHistoryBySurface.removeAll() agentDetectionSchedules.removeAll() surfaceAgentStates.removeAll() agentDetectionPresenceBySurface.removeAll() diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift index 38b9e090..d9474bba 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift @@ -114,6 +114,7 @@ extension WorktreeTerminalState { programStatusStoresBySurface.removeValue(forKey: surfaceID) programStatusUnmappedAppsLoggedBySurface.removeValue(forKey: surfaceID) programStatusOriginBySurface.removeValue(forKey: surfaceID) + remoteFenceHistoryBySurface.removeValue(forKey: surfaceID) } } diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift index f02405b6..86a3ef24 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift @@ -245,6 +245,9 @@ final class WorktreeTerminalState { @ObservationIgnored var programStatusUnmappedAppsLoggedBySurface: [UUID: Set] = [:] /// One tracker per pane whose foreground is an `ssh` (docs-ai 082, L0). @ObservationIgnored var remotePaneTrackers: [UUID: RemotePaneTracker] = [:] + /// The remote OSC fence per surface, kept as long as the surface's record store so an + /// undone close restores it with the records it fences. + @ObservationIgnored var remoteFenceHistoryBySurface: [UUID: RemoteFenceHistory] = [:] @ObservationIgnored var remoteSnapshotSourceForTesting: RemoteSnapshotSource? @ObservationIgnored var remoteAgentDetectionEnabledForTesting: Bool? var remoteAgentDetectionEnabled: Bool { diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index d4de770d..8c9f240e 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -115,7 +115,8 @@ struct RemoteAgentDetectionTests { private func snapshot( _ sequence: UInt64, pane: UUID?, foreground: RemoteProbeProcess?, extraPane: UUID? = nil, - outcome: RemoteProbeSnapshot.Outcome = .complete, anchor: Int32 = 100 + outcome: RemoteProbeSnapshot.Outcome = .complete, anchor: Int32 = 100, epoch: UInt64 = 0, + bootID: String = "boot" ) -> RemoteProbeSnapshot { func session(_ id: UUID?) -> RemoteProbeSession { let process = foreground ?? remoteProcess(101, name: "zsh") @@ -127,9 +128,9 @@ struct RemoteAgentDetectionTests { if let extraPane { sessions.append(session(extraPane)) } let now = ProcessInfo.processInfo.systemUptime return RemoteProbeSnapshot( - connection: ConnectionKey(user: "onevcat", hostname: "mini.local"), connectionEpoch: 0, sequence: sequence, + connection: ConnectionKey(user: "onevcat", hostname: "mini.local"), connectionEpoch: epoch, sequence: sequence, startedAt: now, finishedAt: now, outcome: outcome, - report: RemoteProbeReport(operatingSystem: .linux, bootID: "boot", sessions: sessions)) + report: RemoteProbeReport(operatingSystem: .linux, bootID: bootID, sessions: sessions)) } private func remoteRef(_ pid: Int32, start: String = "t1") -> ProcessRef { @@ -240,6 +241,51 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) } + @Test func anAnchorFromAnotherEpochOrBootIdentifiesNothing() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindClaude(fixture, probe: probe) + // The host rebooted while the connection was down: a pane-less session reuses the + // anchor pid and runs another agent. Neither releases nor replaces the binding. + await deliver( + fixture, + snapshot(2, pane: nil, foreground: remoteProcess(999, name: "claude"), epoch: 1, bootID: "boot-2")) + await detect(fixture) + await deliver(fixture, snapshot(3, pane: nil, foreground: nil, epoch: 1, bootID: "boot-2")) + await detect(fixture) + await deliver(fixture, snapshot(4, pane: nil, foreground: nil, epoch: 1, bootID: "boot-2")) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + // A new epoch on the same boot is a new scope too. + await deliver(fixture, snapshot(5, pane: nil, foreground: remoteProcess(998, name: "claude"), epoch: 2)) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + } + + @Test func aRecreatedTrackerKeepsTheSuccessorFenceOfItsSSH() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindClaude(fixture, probe: probe) + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(300, name: "claude"))) + await detect(fixture) + let fence = try #require(fixture.state.agentDetectionCoordinators[fixture.surfaceID]?.remoteProgramStatusFence) + let sshStart = try #require(ProcessDetection.processStartDate(pid: getpid())) + #expect(fence > sshStart) + + // A close retained for undo retires the tracker and keeps the record store; the + // restored pane gets a new tracker for the same `ssh`. + fixture.state.retireRemotePaneTracker(surfaceID: fixture.surfaceID) + await detect(fixture) + await settle { fixture.source.recipes.count == 2 } + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(300, name: "claude"))) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(300)) + #expect(fixture.state.agentDetectionCoordinators[fixture.surfaceID]?.remoteProgramStatusFence == fence) + // Dropping the store for good drops the fence with it. + fixture.state.dropProgramStatusStores(forSurfaceIDs: [fixture.surfaceID]) + #expect(fixture.state.remoteFenceHistoryBySurface[fixture.surfaceID] == nil) + } + @Test func sshPanesKeepTickingOnTheRemoteCadence() async { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 78f153c2..c99a66b2 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -157,7 +157,8 @@ the degraded OSC-only binding and admission refusal. Both stay behind `unsupported`. Only `correlated` binds. The session that first carried the pane id is also recognized by its anchor pid: back at the macOS system shell the id is hidden again. Only that single mapped session is evidence; an unmapped or ambiguous scan neither - releases nor confirms the bound agent (review round 1). + releases nor confirms the bound agent (review round 1). The anchor is valid only in the + connection epoch and boot it was seen in (review round 2). - The bound agent is held across ticks, so the coordinator can delegate. It is released by the C3 rule (two complete fresh snapshots without it) or at once when the local `ssh` is gone. An OSC 133 D from the remote shell asks for a snapshot right away. @@ -169,7 +170,9 @@ the degraded OSC-only binding and admission refusal. Both stay behind loop's sleep without opening a settle window. - **Remote OSC fence.** The first agent since the `ssh` started counts reports from the `ssh` start; a successor counts only reports after the snapshot that confirmed it, - timestamped after the tick's local probe returns (review round 1). The S1 + timestamped after the tick's local probe returns (review round 1). The fence belongs to the + `ssh` generation and lives with the surface's record store (`remoteFenceHistoryBySurface`), + so a tracker recreated by an undone close never moves it back (review round 2). The S1 origin fence applies to local generations only. - **Measured** in a Debug instance with the flag on: - mini (macOS 26.5), Claude Code 2.1.302 started by hand: appeared 0.62 s after the -- 2.54.0 From 5e8e6c7ab63938dc6b00c7aa9420408b8a93fa3a Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 00:22:08 +0900 Subject: [PATCH 09/51] Track the terminal's own ssh, not its ProxyJump transport ssh -J starts an ssh -W helper in the same job. Pick only an ssh with no ssh ancestor in the job. --- ...WorktreeTerminalState+AgentDetection.swift | 19 +++++++++++--- App/Tests/RemoteAgentDetectionTests.swift | 26 +++++++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 6 ++++- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index b385307a..80a6290a 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -343,12 +343,25 @@ extension WorktreeTerminalState { return observation } - /// A local foreground `ssh` and its arguments (without `argv[0]`), with the generation - /// that its tracker belongs to. + /// The terminal's own foreground `ssh` and its arguments (without `argv[0]`), with the + /// generation that its tracker belongs to. An `ssh` that another `ssh` of the job started + /// (the `-W` transport of `-J`, or one under a `ProxyCommand` shell) is never it. nonisolated static func foregroundSSH( _ job: ForegroundJob ) -> (generation: LocalProcessGeneration, arguments: [String])? { - for process in job.processes where process.argv0 == "ssh" || process.name == "ssh" { + func isSSH(_ process: ForegroundProcess) -> Bool { process.argv0 == "ssh" || process.name == "ssh" } + func hasSSHAncestor(_ process: ForegroundProcess) -> Bool { + var parent = process.parentPID + for _ in job.processes.indices { + guard let pid = parent, let ancestor = job.processes.first(where: { $0.ref.namespacePID == pid }) else { + return false + } + if isSSH(ancestor) { return true } + parent = ancestor.parentPID + } + return false + } + for process in job.processes where isSSH(process) && !hasSSHAncestor(process) { guard let pid = process.ref.localPID, let arguments = process.arguments, let startedAt = ProcessDetection.processStartDate(pid: pid) else { continue } diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 8c9f240e..c834b2a8 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -380,6 +380,32 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) } + @Test func aProxyJumpHelperNeverStandsInForTheTerminalsSSH() async throws { + // `ssh -J jump target` runs `ssh -W … jump` as its child in the same job; the helper is + // listed first. Its own process is real so its generation is readable. + let helper = Process() + helper.executableURL = URL(fileURLWithPath: "/bin/sleep") + helper.arguments = ["30"] + try helper.run() + defer { helper.terminate() } + let probe = Probe() + let fixture = makeFixture(probe: probe) + probe.job = ForegroundJob( + processGroupID: getpid(), + processes: [ + ForegroundProcess( + pid: helper.processIdentifier, parentProcessID: getpid(), name: "ssh", argv0: "ssh", + cmdline: "ssh -W [127.0.0.1]:22 jump", arguments: ["/usr/bin/ssh", "-W", "[127.0.0.1]:22", "jump"]), + ForegroundProcess( + pid: getpid(), parentProcessID: 1, name: "ssh", argv0: "ssh", cmdline: "ssh -J jump dev@target", + arguments: ["ssh", "-J", "jump", "dev@target"]), + ]) + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + #expect(fixture.source.recipes.first?.destination == "dev@target") + #expect(fixture.source.recipes.first?.options == ["-J", "jump"]) + } + @Test func unsupportedArgvNeverConnects() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index c99a66b2..6e09b33d 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -148,7 +148,9 @@ the degraded OSC-only binding and admission refusal. Both stay behind the destination, up to the first other argument. The tracker runs `ssh -G` and the side channel in the local `ssh`'s working directory, so relative `-F`/`-i` operands mean the same files (review round 1). -- **Trigger.** A foreground `ssh` with no local agent. The tracker belongs to that `ssh` +- **Trigger.** A foreground `ssh` with no local agent. It must be the terminal's own `ssh`: + one that another `ssh` of the job started (the `-W` transport of `-J`, or one under a + `ProxyCommand` shell) is skipped (review round 3). The tracker belongs to that `ssh` generation (pid and start time); a new `ssh`, a local agent, the flag off or a closed pane retire it and close the lease, and snapshots that arrive later are dropped. - **Tracker** (`RemotePaneTracker`). It leases the host's owner (`ssh -G`, then the @@ -188,6 +190,8 @@ the degraded OSC-only binding and admission refusal. Both stay behind - After review round 1: `ssh host -l user` bound Claude in 0.36 s; release after Ctrl+C through the anchor-pinned session took 2.87 s; after 40 s idle, `sleep 5; codex` was found 2.6 s after Codex started. + - After review round 3: `ssh -J mini onevcat@127.0.0.1` (ProxyJump to mini's loopback): + Claude bound in 0.37 s, released in 2.91 s. - Debug build CPU: a delegated remote Claude costs 0.1–0.8 % of a core. A screen-rule agent costs as much as it does locally. Before the bound agent was held across ticks, a remote OSC producer could not delegate and read the screen every 300 ms. -- 2.54.0 From 5c57920110a5dcfbe5e1a18b9b6fbd0f82e49a10 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 00:34:43 +0900 Subject: [PATCH 10/51] Fence a resumed remote agent from a local agent run in between The surface origin record keeps when each origin was last seen. When a pane returns to a remote origin and the origin it left was seen after the ssh started, the remote fence rises to the switch. --- ...WorktreeTerminalState+AgentDetection.swift | 2 +- .../WorktreeTerminalState+ProgramStatus.swift | 33 +++++++++++++++---- App/Tests/RemoteAgentDetectionTests.swift | 30 +++++++++++++++-- docs-ai/082-ssh-remote-agents/001-action.md | 6 +++- 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 80a6290a..f539262f 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -164,7 +164,7 @@ extension WorktreeTerminalState { let coordinator = agentDetectionCoordinators[surfaceID] ?? makeAgentDetectionCoordinator(surfaceID: surfaceID) agentDetectionCoordinators[surfaceID] = coordinator coordinator.programStatusOriginFence = originFence - coordinator.remoteProgramStatusFence = remote?.programStatusFence + coordinator.remoteProgramStatusFence = remoteProgramStatusFence(remote?.programStatusFence, surfaceID: surfaceID) let process = Self.processGeneration(identified) // The tick is delegated only while the schedule says so (a key press puts one full // tick back on the active schedule and keeps it there for the settle window) and the diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift index d9474bba..b306c80a 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift @@ -13,6 +13,10 @@ struct ProgramStatusOrigin: Equatable { let origin: ProcessOrigin /// When this origin replaced another one; nil for the surface's first origin. let since: Date? + /// The last probe that saw an agent of this origin. + var lastSeenAt: Date? + /// When the origin this one replaced was last seen. + var departedLastSeenAt: Date? } extension WorktreeTerminalState { @@ -124,17 +128,34 @@ extension WorktreeTerminalState { /// the change returned, so reports that arrived before that confirmation (also those /// that landed while the probe ran) are left out; the screen decides until the next report. func noteAgentOrigin(_ identified: IdentifiedAgentProcess?, surfaceID: UUID, now: Date) -> Date? { - let previous = programStatusOriginBySurface[surfaceID] - guard let origin = identified?.process.ref.origin else { return previous?.since } - guard let previous else { - programStatusOriginBySurface[surfaceID] = ProgramStatusOrigin(origin: origin, since: nil) + guard var previous = programStatusOriginBySurface[surfaceID] else { + guard let origin = identified?.process.ref.origin else { return nil } + programStatusOriginBySurface[surfaceID] = ProgramStatusOrigin(origin: origin, since: nil, lastSeenAt: now) return nil } - guard previous.origin != origin else { return previous.since } - programStatusOriginBySurface[surfaceID] = ProgramStatusOrigin(origin: origin, since: now) + guard let origin = identified?.process.ref.origin else { return previous.since } + guard previous.origin != origin else { + previous.lastSeenAt = now + programStatusOriginBySurface[surfaceID] = previous + return previous.since + } + programStatusOriginBySurface[surfaceID] = ProgramStatusOrigin( + origin: origin, since: now, lastSeenAt: now, departedLastSeenAt: previous.lastSeenAt) return now } + /// The fence for a bound remote agent: the tracker's, raised to the moment the pane + /// switched to this remote origin when the origin it left was seen after the `ssh` + /// started (a suspended `ssh` with a local agent run in between). An agent that ran before + /// the `ssh` leaves the first remote agent's early reports alone. + func remoteProgramStatusFence(_ fence: Date?, surfaceID: UUID) -> Date? { + guard let fence, let origin = programStatusOriginBySurface[surfaceID], origin.origin != .local, + let since = origin.since, let departed = origin.departedLastSeenAt, + let sshStart = remotePaneTrackers[surfaceID]?.ssh.startedAt, departed > sshStart + else { return fence } + return max(fence, since) + } + /// New producers show up in dogfooding logs: a root record whose `app` is not the /// bound agent's is reported once per surface and app. A known producer's record /// left on a pane with no agent (its process exited, or the probe has not bound it diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index c834b2a8..e7939725 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -147,9 +147,9 @@ struct RemoteAgentDetectionTests { } @discardableResult - private func detect(_ fixture: Fixture) async -> Bool { + private func detect(_ fixture: Fixture, screen: AgentRawState = .idle) async -> Bool { fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID] = WorktreeTerminalState.AgentScreenScan( - agent: .claude, text: "", detection: AgentScreenDetection(state: .idle, reason: .legacyDetector)) + agent: .claude, text: "", detection: AgentScreenDetection(state: screen, reason: .legacyDetector)) return await fixture.state.detectAgentState( for: fixture.surface, tabId: fixture.tabID, resolveSession: noSession, now: Date()) } @@ -286,6 +286,32 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.remoteFenceHistoryBySurface[fixture.surfaceID] == nil) } + @Test func aLocalAgentBetweenSuspendAndResumeNeverDecidesForTheReturningRemoteAgent() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindClaude(fixture, probe: probe) + // Ctrl-Z on the `ssh`, a local Claude runs in the pane and ends with a `done` root. + probe.job = ForegroundJob( + processGroupID: getpid(), + processes: [ + ForegroundProcess(pid: getpid(), parentProcessID: 1, name: "claude", argv0: "claude", cmdline: "claude") + ]) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .local(getpid())) + fixture.state.handleProgramStatus( + GhosttyProgramStatusReport(state: .done, kind: nil, id: "", app: "claude-code"), surfaceID: fixture.surfaceID) + // `fg`: the same remote Claude is still at its permission prompt and sends nothing. + probe.job = sshJob + await detect(fixture, screen: .blocked) + await settle { fixture.source.recipes.count == 2 } + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture, screen: .blocked) + let pane = fixture.state.surfaceAgentStates[fixture.surfaceID] + #expect(pane?.agentProcess == remoteRef(200)) + #expect(pane?.decision?.reason.identifier != "osc.done") + #expect(pane?.state == .blocked) + } + @Test func sshPanesKeepTickingOnTheRemoteCadence() async { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 6e09b33d..92ac4154 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -174,7 +174,11 @@ the degraded OSC-only binding and admission refusal. Both stay behind `ssh` start; a successor counts only reports after the snapshot that confirmed it, timestamped after the tick's local probe returns (review round 1). The fence belongs to the `ssh` generation and lives with the surface's record store (`remoteFenceHistoryBySurface`), - so a tracker recreated by an undone close never moves it back (review round 2). The S1 + so a tracker recreated by an undone close never moves it back (review round 2). The surface + origin record also keeps when each origin was last seen. When the pane returns to a remote + origin and the origin it left was seen after the `ssh` started, the remote fence rises to + the switch, so a local agent run while the `ssh` was suspended cannot decide for the + resumed remote one (review round 4). The S1 origin fence applies to local generations only. - **Measured** in a Debug instance with the flag on: - mini (macOS 26.5), Claude Code 2.1.302 started by hand: appeared 0.62 s after the -- 2.54.0 From 54cb81fdb378060f0e0b65efd92fbd80e5a5c0f8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 00:48:52 +0900 Subject: [PATCH 11/51] Retry a failed remote connection with backoff A failed ssh -G resolution no longer disables the pane's tracker for its ssh generation: it retries after 1 s doubling to 30 s, and at once on the next wake. --- .../BusinessLogic/RemotePaneTracker.swift | 29 ++++++-- App/Tests/RemoteAgentDetectionTests.swift | 68 ++++++++++++++++++- docs-ai/082-ssh-remote-agents/001-action.md | 3 +- 3 files changed, 93 insertions(+), 7 deletions(-) diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index e16099e3..24e3c5cc 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -108,6 +108,9 @@ final class RemotePaneTracker { private var lease: RemoteConnectionLease? private var connectTask: Task? private var connectFailed = false + private var connectFailures = 0 + /// When a failed connection may be tried again (bounded exponential backoff). + private var nextConnectAt: TimeInterval? private var retired = false private var lastRequestAt: TimeInterval? private(set) var latest: RemoteProbeSnapshot? @@ -148,14 +151,25 @@ final class RemotePaneTracker { self.onSnapshot = onSnapshot } - /// Opens the connection lease and consumes snapshots until the tracker retires. + /// Longest wait between connection attempts after failures. + static let maximumConnectBackoff: TimeInterval = 30 + + /// Opens the connection lease and consumes snapshots until the tracker retires. A failed + /// attempt (for example `ssh -G` during a DNS change) is retried with a growing delay, + /// or at once on the next wake. func start() { - guard connectTask == nil, case .supported(let recipe) = invocation else { return } + guard connectTask == nil, lease == nil, !retired, case .supported(let recipe) = invocation else { return } + nextConnectAt = nil let source = source connectTask = Task { [weak self] in guard let lease = await source.connect(recipe) else { - self?.connectFailed = true - self?.onSnapshot() + guard let self else { return } + self.connectFailed = true + self.connectFailures += 1 + self.nextConnectAt = + self.uptime() + min(Self.maximumConnectBackoff, pow(2, Double(self.connectFailures - 1))) + self.connectTask = nil + self.onSnapshot() return } guard let self, !self.retired else { @@ -163,6 +177,8 @@ final class RemotePaneTracker { return } self.lease = lease + self.connectFailed = false + self.connectFailures = 0 self.lastRequestAt = self.uptime() await lease.refresh() for await snapshot in lease.snapshots { @@ -183,14 +199,17 @@ final class RemotePaneTracker { } } - /// Asks for a snapshot on the next tick, whatever the cadence (a key press or a report). + /// Asks for a snapshot on the next tick, whatever the cadence (a key press or a report); + /// a failed connection is tried again on that tick. func requestSoon() { lastRequestAt = nil + if nextConnectAt != nil { nextConnectAt = uptime() } } /// One tick's view. Requests a refresh when the cadence is due; reads only the latest /// snapshot, so pane ticks never wait for the host. func observe(active: Bool, now: Date) -> Observation { + if let nextConnectAt, uptime() >= nextConnectAt { start() } requestRefreshIfDue(active: active) var verdict: RemoteProcessPresence.Verdict? if let latest, let report = latest.report, diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index e7939725..b4b592ae 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -32,14 +32,28 @@ struct RemoteAgentDetectionTests { private var storedRecipes: [SSHConnectionRecipe] = [] private var storedRefreshes = 0 private var storedCloses = 0 + private var storedFailures = 0 + private var storedAttempts = 0 let connection = ConnectionKey(user: "onevcat", hostname: "mini.local") + var attempts: Int { lock.withLock { storedAttempts } } + /// The next `count` connects fail as an unresolvable recipe would. + func failNext(_ count: Int) { lock.withLock { storedFailures = count } } + var recipes: [SSHConnectionRecipe] { lock.withLock { storedRecipes } } var refreshes: Int { lock.withLock { storedRefreshes } } var closes: Int { lock.withLock { storedCloses } } var source: RemoteSnapshotSource { - RemoteSnapshotSource { [self] recipe in connect(recipe) } + RemoteSnapshotSource { [self] recipe in + let fails = lock.withLock { + storedAttempts += 1 + guard storedFailures > 0 else { return false } + storedFailures -= 1 + return true + } + return fails ? nil : connect(recipe) + } } private func connect(_ recipe: SSHConnectionRecipe) -> RemoteConnectionLease { @@ -432,6 +446,58 @@ struct RemoteAgentDetectionTests { #expect(fixture.source.recipes.first?.options == ["-J", "jump"]) } + @Test func aFailedConnectionIsRetriedOnTheNextWake() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + fixture.source.failNext(1) + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.attempts == 1 } + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + // A key press or report wakes the pane: the connection is tried again at once. + tracker.requestSoon() + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + #expect(fixture.source.attempts == 2) + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + } + + @Test func connectionRetriesBackOffWithoutAWake() async throws { + var clock: TimeInterval = 100 + let source = FakeRemoteSource() + source.failNext(3) + let tracker = RemotePaneTracker( + surfaceID: UUID(), ssh: LocalProcessGeneration(pid: 1, startedAt: Date()), + invocation: .supported(SSHConnectionRecipe(destination: "mini")), source: source.source, + uptime: { clock }, onSnapshot: {}) + tracker.start() + await settle { source.attempts == 1 } + for _ in 0..<20 { await Task.yield() } + _ = tracker.observe(active: true, now: Date()) + #expect(source.attempts == 1) + clock += 1 + _ = tracker.observe(active: true, now: Date()) + await settle { source.attempts == 2 } + for _ in 0..<20 { await Task.yield() } + // The next attempt waits twice as long. + clock += 1 + _ = tracker.observe(active: true, now: Date()) + for _ in 0..<20 { await Task.yield() } + #expect(source.attempts == 2) + clock += 1 + _ = tracker.observe(active: true, now: Date()) + await settle { source.attempts == 3 } + // Retiring stops any further attempt. + tracker.retire() + clock += 100 + _ = tracker.observe(active: true, now: Date()) + for _ in 0..<20 { await Task.yield() } + #expect(source.attempts == 3) + } + @Test func unsupportedArgvNeverConnects() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 92ac4154..1abdd795 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -154,7 +154,8 @@ the degraded OSC-only binding and admission refusal. Both stay behind generation (pid and start time); a new `ssh`, a local agent, the flag off or a closed pane retire it and close the lease, and snapshots that arrive later are dropped. - **Tracker** (`RemotePaneTracker`). It leases the host's owner (`ssh -G`, then the - registry) and consumes snapshots on the main actor. + registry) and consumes snapshots on the main actor. A failed connection is retried with a + bounded backoff (1 s doubling to 30 s), and at once on the next wake (follow-up review). - Correlation: `pending`, `correlated`, `ambiguous`, `unmapped`, `probeUnavailable`, `unsupported`. Only `correlated` binds. The session that first carried the pane id is also recognized by its anchor pid: back at the macOS system shell the id is hidden again. -- 2.54.0 From 3f769272b1649ec333c96a18fc648e89a90db1dd Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 16:39:31 +0900 Subject: [PATCH 12/51] Use posix_spawn_file_actions_addchdir in the remote command runner The _np variant is deprecated in macOS 26, the deployment target. --- App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift b/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift index f67bda9e..8358d43d 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteCommandRunner.swift @@ -192,7 +192,7 @@ private nonisolated final class RemoteProcess: @unchecked Sendable { var actions: posix_spawn_file_actions_t? posix_spawn_file_actions_init(&actions) defer { posix_spawn_file_actions_destroy(&actions) } - if let workingDirectory { posix_spawn_file_actions_addchdir_np(&actions, workingDirectory) } + if let workingDirectory { posix_spawn_file_actions_addchdir(&actions, workingDirectory) } posix_spawn_file_actions_adddup2(&actions, pipes.input.fileHandleForReading.fileDescriptor, 0) posix_spawn_file_actions_adddup2(&actions, pipes.output.fileHandleForWriting.fileDescriptor, 1) posix_spawn_file_actions_adddup2(&actions, pipes.error.fileHandleForWriting.fileDescriptor, 2) -- 2.54.0 From a6afec6d247a29233e687ab34cbf14bf14f83f11 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 01:21:35 +0900 Subject: [PATCH 13/51] Mark stale remote agents and refuse them in dispatch and workflows Remote agent entries carry their host, correlation and staleness. An observation the host has not confirmed for 10 s keeps its last state but satisfies no idle, blocked or exit condition. agents dispatch and workflow admission refuse remote agents with REMOTE_AGENT_UNSUPPORTED (docs-ai 082 S3b, behind the flag). --- .../App/WorkflowRuntimeComposition.swift | 3 +- .../CLIService/AgentConditionEvidence.swift | 5 ++ .../AgentDispatchCommandHandler.swift | 13 ++++ .../CLIService/WorkflowRunAdmission.swift | 13 ++++ .../AgentDetection/PaneAgentState.swift | 2 + .../AgentDetection/RemoteAgentContext.swift | 28 ++++++++ .../Models/ActiveAgentEntry.swift | 3 + .../BusinessLogic/RemotePaneTracker.swift | 17 ++++- ...WorktreeTerminalState+AgentDetection.swift | 4 +- .../AgentDispatchCommandHandlerTests.swift | 28 +++++++- App/Tests/RemoteAgentDetectionTests.swift | 64 ++++++++++++++++++- App/Tests/WorkflowRunAdmissionTests.swift | 17 +++++ .../Sources/ProwlCLIShared/ErrorCodes.swift | 2 + docs-ai/082-ssh-remote-agents/001-action.md | 33 +++++++++- docs/components/cli.md | 1 + 15 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 App/Sources/Domain/AgentDetection/RemoteAgentContext.swift diff --git a/App/Sources/App/WorkflowRuntimeComposition.swift b/App/Sources/App/WorkflowRuntimeComposition.swift index a5b857d0..17cbcf65 100644 --- a/App/Sources/App/WorkflowRuntimeComposition.swift +++ b/App/Sources/App/WorkflowRuntimeComposition.swift @@ -505,7 +505,8 @@ extension ProwlApp { token: entry.agent.rawValue, displayName: entry.agent.displayName, sessionIdentity: WorkflowHistorySessionIdentity.resolve( agent: entry.agent, detected: entry.session, - currentSignal: terminalManager.currentAgentSignalEvidence(surfaceID: surfaceID).latestManagedHook)) + currentSignal: terminalManager.currentAgentSignalEvidence(surfaceID: surfaceID).latestManagedHook), + isRemote: entry.remote != nil) } }, pendingDispatchID: { surfaceID in diff --git a/App/Sources/CLIService/AgentConditionEvidence.swift b/App/Sources/CLIService/AgentConditionEvidence.swift index 5c3388cb..9be8f860 100644 --- a/App/Sources/CLIService/AgentConditionEvidence.swift +++ b/App/Sources/CLIService/AgentConditionEvidence.swift @@ -139,9 +139,14 @@ enum AgentConditionEvidence { return .busy(state) } + /// The state of a remote agent the host has not confirmed for a while (docs-ai 082, C3): + /// its last observation stands, but it satisfies no idle, blocked or exit condition. + static let unavailableState = "unavailable" + static func normalizedState(_ snapshot: AgentConditionSnapshot) -> String { guard snapshot.isLive else { return "gone" } guard let agent = snapshot.agent else { return "absent" } + if agent.remote?.isStale == true { return unavailableState } let state = status(for: agent, fallback: .idle).rawValue // An unmatched or unrecognized screen is not idle evidence: a retained // `.idle` may have been stabilized before the layout became unclassifiable. diff --git a/App/Sources/CLIService/AgentDispatchCommandHandler.swift b/App/Sources/CLIService/AgentDispatchCommandHandler.swift index 4b101273..173f67d2 100644 --- a/App/Sources/CLIService/AgentDispatchCommandHandler.swift +++ b/App/Sources/CLIService/AgentDispatchCommandHandler.swift @@ -180,7 +180,19 @@ final class AgentDispatchCommandHandler: CommandHandler { } } + /// The receipt is reported with `prowl agents dispatch-complete` from inside the pane, + /// which an agent on a remote host cannot run (docs-ai 082, L0 item 11). + private func remoteAgentRefusal(_ snapshot: AgentConditionSnapshot, target: TabResolvedTarget) -> CommandResponse? { + guard snapshot.agent?.remote != nil else { return nil } + return failure( + code: CLIErrorCode.remoteAgentUnsupported, + message: "The selected pane's agent runs on a remote host; dispatch needs a local agent to report its receipt.", + details: AgentDispatchErrorDetails(target: TabTarget(from: target), signals: snapshot.signals) + ) + } + private func prepareDelivery(target: TabResolvedTarget) async -> CommandResponse? { + if let refusal = remoteAgentRefusal(conditionSnapshot(target), target: target) { return refusal } // Code security: do not clear local IME input or append into an actively edited composer. if let reason = inputProtection(target) { return failure(code: CLIErrorCode.dispatchTargetBusy, message: reason) @@ -215,6 +227,7 @@ final class AgentDispatchCommandHandler: CommandHandler { details: AgentDispatchErrorDetails(target: TabTarget(from: target), signals: snapshot.signals) ) } + if let refusal = remoteAgentRefusal(snapshot, target: target) { return refusal } switch verdict(for: snapshot) { case .idle: return nil diff --git a/App/Sources/CLIService/WorkflowRunAdmission.swift b/App/Sources/CLIService/WorkflowRunAdmission.swift index 80d2448e..3f781ea8 100644 --- a/App/Sources/CLIService/WorkflowRunAdmission.swift +++ b/App/Sources/CLIService/WorkflowRunAdmission.swift @@ -27,6 +27,8 @@ nonisolated struct WorkflowDetectedAgent: Equatable, Sendable { let token: String let displayName: String var sessionIdentity: String? + /// The agent runs on a remote host (docs-ai 082); it cannot deliver workflow steps. + var isRemote = false } /// Main-actor facts admission reads through closures so it stays testable without the app. @@ -420,6 +422,15 @@ enum WorkflowRunAdmission { self.deliversToCurrent = deliversToCurrent } + /// A workflow participant delivers each step with `prowl workflow deliver` from inside + /// its pane, which an agent on a remote host cannot run (docs-ai 082, L0 item 11). + static func remoteAgentRefusal(_ agent: WorkflowDetectedAgent?, pane: String) -> WorkflowAdmissionFailure? { + guard agent?.isRemote == true else { return nil } + return .init( + code: CLIErrorCode.remoteAgentUnsupported, + message: "\(pane) runs its agent on a remote host; a workflow role needs a local agent to deliver its steps.") + } + mutating func bind(_ role: WorkflowRoleDefinition) -> WorkflowAdmissionFailure? { switch role.source { case .current: bindCurrent(role) @@ -450,6 +461,7 @@ enum WorkflowRunAdmission { return pendingDispatch(dispatchID, pane: "The source pane") } let agent = environment.detectedAgent(paneID) + if let failure = Self.remoteAgentRefusal(agent, pane: "The source pane") { return failure } if deliversToCurrent, agent == nil { return .init( code: CLIErrorCode.agentNotFound, @@ -499,6 +511,7 @@ enum WorkflowRunAdmission { message: "Pane '\(override)' hosts no detected agent for role '\(role.name)'." ) } + if let failure = Self.remoteAgentRefusal(agent, pane: "Pane '\(override)'") { return failure } guard let identity = paneIdentity(paneID, agent: agent, worktree: source.worktree) else { return .init( code: CLIErrorCode.targetNotFound, message: "Pane '\(override)' is no longer available.") diff --git a/App/Sources/Domain/AgentDetection/PaneAgentState.swift b/App/Sources/Domain/AgentDetection/PaneAgentState.swift index 321a568c..8c671900 100644 --- a/App/Sources/Domain/AgentDetection/PaneAgentState.swift +++ b/App/Sources/Domain/AgentDetection/PaneAgentState.swift @@ -13,6 +13,8 @@ struct PaneAgentState: Equatable, Sendable { /// bounds how long a previously resolved session may be retained. var sessionMissStreak: Int = 0 var iconLookupToken: String? + /// Set for an agent on a remote host (docs-ai 082): host, correlation, and staleness. + var remote: RemoteAgentContext? var fallbackState: AgentRawState var decision: AgentStateDecision? var state: AgentRawState diff --git a/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift b/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift new file mode 100644 index 00000000..1ea32368 --- /dev/null +++ b/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift @@ -0,0 +1,28 @@ +import Foundation + +/// What a pane's remote agent observation rests on (docs-ai 082, L0 items 9 and 10): the +/// host, the pane's correlation, and whether the host still confirms the agent. +nonisolated struct RemoteAgentContext: Equatable, Sendable { + /// No confirming snapshot for this long makes the observation stale (C3). + static let staleAfter: TimeInterval = 10 + + var connection: ConnectionKey? + var correlation: RemotePaneCorrelation + /// The last snapshot that confirmed the agent; set only while the observation is stale, + /// so a steady stream of confirmations does not change the published entry. + var lastConfirmedAt: Date? + var isStale: Bool + + /// `user@hostname`, the label a remote pane shows. + var hostLabel: String? { + connection.map { "\($0.user)@\($0.hostname)" } + } + + init(connection: ConnectionKey?, correlation: RemotePaneCorrelation, confirmedAt: Date?, now: Date) { + self.connection = connection + self.correlation = correlation + let isStale = confirmedAt.map { now.timeIntervalSince($0) > Self.staleAfter } ?? true + self.isStale = isStale + lastConfirmedAt = isStale ? confirmedAt : nil + } +} diff --git a/App/Sources/Features/ActiveAgents/Models/ActiveAgentEntry.swift b/App/Sources/Features/ActiveAgents/Models/ActiveAgentEntry.swift index 770eedab..08c33347 100644 --- a/App/Sources/Features/ActiveAgents/Models/ActiveAgentEntry.swift +++ b/App/Sources/Features/ActiveAgents/Models/ActiveAgentEntry.swift @@ -37,6 +37,9 @@ struct ActiveAgentEntry: Identifiable, Equatable, Sendable { /// (docs-ai 053). Detected-but-not-launched agents stay honest: nil, so /// the row never guesses a profile attribution. var launchProfileName: String? + /// Set for an agent on a remote host (docs-ai 082). A stale observation keeps the entry + /// with its last state but proves nothing: conditions and admission refuse it. + var remote: RemoteAgentContext? var displayName: String { launchProfileName ?? Self.displayName(iconLookupToken: iconLookupToken, agent: agent) } diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index 24e3c5cc..86ff18ee 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -88,6 +88,12 @@ final class RemotePaneTracker { var verdict: RemoteProcessPresence.Verdict? /// The arrival-time fence for OSC records of the bound remote agent (L0 item 5). var programStatusFence: Date? + /// When a snapshot last confirmed the bound agent. + var confirmedAt: Date? + + func context(now: Date) -> RemoteAgentContext { + RemoteAgentContext(connection: connection, correlation: correlation, confirmedAt: confirmedAt, now: now) + } } var boundAgent: IdentifiedAgentProcess? { bound } @@ -129,6 +135,7 @@ final class RemotePaneTracker { let bootID: String } private var bound: IdentifiedAgentProcess? + private var confirmedAt: Date? private var presence = RemoteProcessPresence() /// The OSC fence of this `ssh` generation; it outlives the tracker (close and Undo). private(set) var fenceHistory: RemoteFenceHistory @@ -220,7 +227,7 @@ final class RemotePaneTracker { } return Observation( correlation: correlation, connection: lease?.connection, identified: bound, verdict: verdict, - programStatusFence: fenceHistory.fence) + programStatusFence: fenceHistory.fence, confirmedAt: confirmedAt) } /// Applies a new snapshot to the binding and returns its verdict for the bound agent. @@ -243,9 +250,15 @@ final class RemotePaneTracker { guard let current = bound else { return nil } let contains = job?.processes.contains { $0.ref == current.process.ref } ?? false let verdict = presence.observe(snapshot, contains: contains, now: uptime()) - if verdict == .released { + switch verdict { + case .present: + confirmedAt = now + case .released: bound = nil + confirmedAt = nil presence = RemoteProcessPresence() + case .missing, .unchanged: + break } return verdict } diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index f539262f..3a0f613a 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -237,6 +237,7 @@ extension WorktreeTerminalState { ) next.sessionMissStreak = sessionMissStreak next.decision = decision + next.remote = next.agentProcess?.origin == .local ? nil : remote?.context(now: now) // Limit logging to meaningful transitions - agent identity or // stabilized state changes. Raw oscillation and `seen` flips are // routine and would otherwise dominate the log stream. @@ -678,7 +679,8 @@ extension WorktreeTerminalState { rawState: state.fallbackState, displayState: state.displayState, lastChangedAt: state.lastChangedAt, - launchProfileName: launchProfileName(surfaceID: surfaceID, detected: agent) + launchProfileName: launchProfileName(surfaceID: surfaceID, detected: agent), + remote: state.remote ) } diff --git a/App/Tests/AgentDispatchCommandHandlerTests.swift b/App/Tests/AgentDispatchCommandHandlerTests.swift index 6b893645..6cc818f1 100644 --- a/App/Tests/AgentDispatchCommandHandlerTests.swift +++ b/App/Tests/AgentDispatchCommandHandlerTests.swift @@ -308,6 +308,31 @@ struct AgentDispatchCommandHandlerTests { #expect(await closed.handle(envelope: dispatch(pane: target.paneID)).error?.code == CLIErrorCode.agentGone) } + @Test func dispatchRefusesARemoteAgentBeforeTypingAnything() async throws { + let target = resolvedTarget() + var remote = snapshot(target, status: .idle, signal: turnEnded) + var entry = try #require(remote.agent) + entry.remote = RemoteAgentContext( + connection: ConnectionKey(user: "dev", hostname: "box"), correlation: .correlated, confirmedAt: Self.start, + now: Self.start) + remote = AgentConditionSnapshot( + agent: entry, signal: remote.signal, revision: remote.revision, isLive: true, signals: remote.signals) + var issued = 0 + let handler = AgentDispatchCommandHandler( + resolveTarget: { _ in .success(target) }, + // An unverifiable composer must not mask the reason. + inputProtection: { _ in "The input area could not be verified." }, + conditionSnapshot: { _ in remote }, + issueDispatch: { _ in + issued += 1 + return .failure(.surfacePending) + } + ) + let response = await handler.handle(envelope: dispatch(pane: target.paneID)) + #expect(response.error?.code == CLIErrorCode.remoteAgentUnsupported) + #expect(issued == 0) + } + @Test func dispatchRefusesWorkingBlockedAndRuntimeNeedsInputAgents() async throws { struct BusyCase { let status: AgentDisplayState @@ -536,7 +561,8 @@ struct AgentDispatchCommandHandlerTests { await clock.advance(by: .milliseconds(200)) let response = await task.value #expect(response.ok) - #expect(reads == 11) + // One read for the remote-agent refusal, then the idle wait's. + #expect(reads == 12) } @Test func dispatchStabilizationAbortsWhenTheAgentStartsWorking() async throws { diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index b4b592ae..616b8ff5 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -1,5 +1,6 @@ import Foundation import GhosttyKit +import ProwlCLIShared import Testing @testable import Prowl @@ -161,11 +162,11 @@ struct RemoteAgentDetectionTests { } @discardableResult - private func detect(_ fixture: Fixture, screen: AgentRawState = .idle) async -> Bool { + private func detect(_ fixture: Fixture, screen: AgentRawState = .idle, now: Date = Date()) async -> Bool { fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID] = WorktreeTerminalState.AgentScreenScan( agent: .claude, text: "", detection: AgentScreenDetection(state: screen, reason: .legacyDetector)) return await fixture.state.detectAgentState( - for: fixture.surface, tabId: fixture.tabID, resolveSession: noSession, now: Date()) + for: fixture.surface, tabId: fixture.tabID, resolveSession: noSession, now: now) } /// Waits for the tracker's consumer task, which runs on the main actor between test steps. @@ -210,6 +211,44 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.agentDetectionCoordinators[fixture.surfaceID]?.boundProcess?.localGeneration == nil) } + @Test func anUnconfirmedRemoteAgentTurnsStaleAndProvesNothing() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var emitted: [ActiveAgentEntry] = [] + fixture.state.onAgentEntryChanged = { emitted.append($0) } + await bindClaude(fixture, probe: probe) + let fresh = try #require(emitted.last?.remote) + #expect(fresh.hostLabel == "onevcat@mini.local") + #expect(fresh.correlation == .correlated) + #expect(!fresh.isStale) + #expect(fresh.lastConfirmedAt == nil) + + // Eleven seconds without a confirming snapshot (the host stopped answering). + await detect(fixture, now: Date().addingTimeInterval(11)) + let stale = try #require(emitted.last?.remote) + #expect(stale.isStale) + #expect(stale.lastConfirmedAt != nil) + // The entry keeps its agent and last state. + #expect(emitted.last?.agent == .claude) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) + let condition = AgentConditionSnapshot( + agent: emitted.last, signal: nil, revision: 1, isLive: true, signals: .empty) + #expect(AgentConditionEvidence.normalizedState(condition) == AgentConditionEvidence.unavailableState) + #expect( + !AgentConditionEvidence.detectorReports(.idle, normalizedState: AgentConditionEvidence.normalizedState(condition)) + ) + #expect(AgentConditionEvidence.idleVerdict(for: condition) == .busy(AgentConditionEvidence.unavailableState)) + #expect( + !AgentConditionEvidence.heuristicMatches( + condition: .exit, snapshot: condition, normalizedState: AgentConditionEvidence.unavailableState, + baseline: AgentConditionEvidence.Baseline(snapshot: condition))) + + // A confirming snapshot makes it fresh again. + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture) + #expect(emitted.last?.remote?.isStale == false) + } + @Test func twoCompleteSnapshotsWithoutTheAgentReleaseIt() async { let probe = Probe() let fixture = makeFixture(probe: probe) @@ -586,3 +625,24 @@ struct RemoteAgentDetectionTests { #expect(surface.launchEnvironment["PROWL_PANE_ID"] == surface.id.uuidString) } } + +struct RemoteAgentContextTests { + @Test func stalenessFollowsTheLastConfirmation() { + let now = Date(timeIntervalSince1970: 1_000) + let key = ConnectionKey(user: "dev", hostname: "box") + let fresh = RemoteAgentContext( + connection: key, correlation: .correlated, confirmedAt: now.addingTimeInterval(-9), now: now) + #expect(!fresh.isStale) + // A steady stream of confirmations never changes a fresh context. + #expect( + fresh + == RemoteAgentContext( + connection: key, correlation: .correlated, confirmedAt: now.addingTimeInterval(-1), now: now)) + let stale = RemoteAgentContext( + connection: key, correlation: .unmapped, confirmedAt: now.addingTimeInterval(-11), now: now) + #expect(stale.isStale) + #expect(stale.lastConfirmedAt == now.addingTimeInterval(-11)) + #expect(RemoteAgentContext(connection: nil, correlation: .pending, confirmedAt: nil, now: now).isStale) + #expect(stale.hostLabel == "dev@box") + } +} diff --git a/App/Tests/WorkflowRunAdmissionTests.swift b/App/Tests/WorkflowRunAdmissionTests.swift index c57c1aa7..6091886e 100644 --- a/App/Tests/WorkflowRunAdmissionTests.swift +++ b/App/Tests/WorkflowRunAdmissionTests.swift @@ -265,6 +265,23 @@ struct WorkflowRunAdmissionTests { == .string(fixture.tabID.uuidString)) } + @Test func remoteAgentPanesCannotBeWorkflowRoles() throws { + let fixture = try Fixture() + defer { fixture.cleanUp() } + try fixture.write(Self.review, to: "review") + // A pick role on a pane whose agent runs on a remote host. + fixture.agents[fixture.partnerPane]?.isRemote = true + let picked = admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2", "reviewer=Codex"]) + #expect(code(picked) == CLIErrorCode.remoteAgentUnsupported) + // The current role on such a pane. + fixture.agents[fixture.partnerPane]?.isRemote = false + fixture.agents[fixture.authorPane]?.isRemote = true + let current = admit( + fixture, workflow: "review", pane: fixture.authorPane, roles: ["partner=p2", "reviewer=Codex"]) + #expect(code(current) == CLIErrorCode.remoteAgentUnsupported) + } + @Test func aCompleteRequestFreezesEveryBindingAndWritesTheInitialRecord() throws { let fixture = try Fixture() defer { fixture.cleanUp() } diff --git a/Shared/Sources/ProwlCLIShared/ErrorCodes.swift b/Shared/Sources/ProwlCLIShared/ErrorCodes.swift index 9b291591..8458fb9c 100644 --- a/Shared/Sources/ProwlCLIShared/ErrorCodes.swift +++ b/Shared/Sources/ProwlCLIShared/ErrorCodes.swift @@ -109,6 +109,8 @@ nonisolated public enum CLIErrorCode { // Run-time codes of the workflow runner (dsl-spec §9); emitted by `workflow run/deliver` from B3 on. public static let runNotFound = "RUN_NOT_FOUND" public static let paneBusy = "PANE_BUSY" + /// The pane's agent runs on a remote host; it can be observed but not driven by receipts. + public static let remoteAgentUnsupported = "REMOTE_AGENT_UNSUPPORTED" public static let roleMismatch = "ROLE_MISMATCH" public static let stepNotExpecting = "STEP_NOT_EXPECTING" public static let tokenRequired = "TOKEN_REQUIRED" diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 1abdd795..70dd44db 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -9,7 +9,8 @@ sections below record what shipped and what differs from the plan. | --- | --- | --- | | 2026-10-17 | S1: typed process references (C2) and the `ConnectionKey` value type; local-only OS entry points; evidence retirement on an origin change. No behaviour change for local panes. Review Loop clean after 3 rounds | #924 | | 2026-10-17 | S2: host identity (C1), connection owner (C4), snapshot contract (C3), structured commands (C5), probe and parser (C6), all unused by the app. Review Loop clean after 2 rounds | #925 | -| 2026-10-17 | S3a: manual-session correlation behind `PROWL_REMOTE_AGENT_DETECTION` — `LC_PROWL_PANE`, argv grammar, per-pane tracker, identity, snapshot release, remote OSC fence | S3a PR | +| 2026-10-17 | S3a: manual-session correlation behind `PROWL_REMOTE_AGENT_DETECTION` — `LC_PROWL_PANE`, argv grammar, per-pane tracker, identity, snapshot release, remote OSC fence. Review Loop: 4 rounds plus a 2-round follow-up, clean (R1–R10) | #926 | +| 2026-10-18 | S3b: stale projection and refusal of remote agents in conditions, dispatch and workflow admission (behind the flag) | S3b PR | ## S1 — Typed process references @@ -200,3 +201,33 @@ the degraded OSC-only binding and admission refusal. Both stay behind - Debug build CPU: a delegated remote Claude costs 0.1–0.8 % of a core. A screen-rule agent costs as much as it does locally. Before the bound agent was held across ticks, a remote OSC producer could not delegate and read the screen every 300 ms. + +## S3b — Stale projection and remote refusals (behind the flag) + +The plan's S3 remainder is split once more: S3b covers staleness and admission; S3c adds the +degraded OSC-only binding. + +- **Context.** `RemoteAgentContext` (host, correlation, staleness) rides on `PaneAgentState` + and `ActiveAgentEntry` for agents on a remote origin. The tracker records when a snapshot + last confirmed the bound agent. Without a confirmation for 10 s the context is stale and + carries that time. While fresh it carries no time, so a steady probe stream changes no + published entry. +- **Stale proves nothing.** A stale entry keeps its agent and last state. Condition evidence + reports it as `unavailable`, so `agents wait --until idle|blocked` and dispatch readiness + never resolve on it. `exit` still needs the entry to go (an `ssh` exit or the snapshot + rule), not a stale observation. +- **Admission.** + - `agents dispatch` refuses a remote agent with `REMOTE_AGENT_UNSUPPORTED` before typing + anything: the receipt is reported with `prowl agents dispatch-complete` from inside the + pane, which the remote host cannot run. + - Workflow admission refuses a `current` or `pick` role on such a pane with the same code. +- **Measured** in a Debug instance with the flag on: + - `agents dispatch` to a remote Claude in the Debian container returned + `REMOTE_AGENT_UNSUPPORTED` before anything was typed (an unverifiable composer no + longer masks the reason). + - mini, Claude idle through OSC: + - `agents wait --until idle` resolved while the observation was fresh. + - With Prowl's side-channel master stopped (`SIGSTOP` on the `[mux]` process), the + same wait timed out after 12 s, and the entry kept its agent and last state. + - After `SIGCONT`, the wait resolved again. + diff --git a/docs/components/cli.md b/docs/components/cli.md index ad92b9f4..85b25be5 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -979,6 +979,7 @@ Follow the returned self-initiated delivery instruction to submit the briefing. | `WORKFLOW_NOT_FOUND` / `WORKFLOW_DISABLED` | No workflow definition with that id or unique name is visible to the worktree, or it is switched off — re-run `workflow list`. | | `RUN_NOT_FOUND` | No live run with that UUID (`cancel`, manual `deliver`), no record of it in any known worktree (`status`), or the calling pane is not part of an active run (`status` without arguments). | | `PANE_BUSY` / `DISPATCH_PENDING` (`workflow run`) | The source pane or a `--role` pane already belongs to another active run, or still holds a pending dispatch record that must be completed or abandoned first. | +| `REMOTE_AGENT_UNSUPPORTED` (`agents dispatch`, `workflow run`) | The pane's agent runs on a remote host (a manual `ssh` session). It can be observed, but it cannot report a dispatch receipt or deliver workflow steps, because those run `prowl` inside the pane. | | `PROFILE_NOT_FOUND` / `PROFILE_NOT_UNIQUE` (`workflow run`) | No enabled Profile satisfies a `launch` role (pass `--role =`), or the given name matches several. | | `STEP_NOT_EXPECTING` / `TOKEN_REQUIRED` / `TOKEN_INVALID` | `workflow deliver`: the calling pane holds no waiting activation (the step moved on, was skipped, or the run ended before the output was saved), the completion command was run without its token, or the token belongs to an earlier step. Re-run the latest completion command Prowl typed. | | `ROLE_MISMATCH` | `workflow deliver --run --step` named a step other than the one the calling pane is waiting for; pass `--force` to deliver there anyway. | -- 2.54.0 From bd9ff23a8483a8992d08cdb243bb0030c6d28cc0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 01:34:44 +0900 Subject: [PATCH 14/51] Keep human input, acquisition freshness and last state for remote agents - Refuse remote agents only for dispatch, which needs a receipt; agents input (Mirror Send) is still delivered. - Count remote freshness from the confirming snapshot's acquisition. - Keep a remote agent's last state when its decision falls back to unknown, so the entry is not hidden before the snapshot rule releases it. --- .../AgentDispatchCommandHandler.swift | 23 +++++--- .../BusinessLogic/RemotePaneTracker.swift | 3 +- ...WorktreeTerminalState+AgentDetection.swift | 14 ++++- .../AgentDispatchCommandHandlerTests.swift | 19 +++++++ App/Tests/RemoteAgentDetectionTests.swift | 55 +++++++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 10 +++- 6 files changed, 110 insertions(+), 14 deletions(-) diff --git a/App/Sources/CLIService/AgentDispatchCommandHandler.swift b/App/Sources/CLIService/AgentDispatchCommandHandler.swift index 173f67d2..afc92309 100644 --- a/App/Sources/CLIService/AgentDispatchCommandHandler.swift +++ b/App/Sources/CLIService/AgentDispatchCommandHandler.swift @@ -103,7 +103,7 @@ final class AgentDispatchCommandHandler: CommandHandler { if !interactive, let pending = pendingDispatch(target) { return pendingFailure(pending, target: target) } - if let refusal = await prepareDelivery(target: target) { + if let refusal = await prepareDelivery(target: target, needsReceipt: !interactive) { return refusal } @@ -181,9 +181,12 @@ final class AgentDispatchCommandHandler: CommandHandler { } /// The receipt is reported with `prowl agents dispatch-complete` from inside the pane, - /// which an agent on a remote host cannot run (docs-ai 082, L0 item 11). - private func remoteAgentRefusal(_ snapshot: AgentConditionSnapshot, target: TabResolvedTarget) -> CommandResponse? { - guard snapshot.agent?.remote != nil else { return nil } + /// which an agent on a remote host cannot run (docs-ai 082, L0 item 11). Human input + /// (`agents input`) needs no receipt and is delivered as to any agent. + private func remoteAgentRefusal( + _ snapshot: AgentConditionSnapshot, target: TabResolvedTarget, needsReceipt: Bool + ) -> CommandResponse? { + guard needsReceipt, snapshot.agent?.remote != nil else { return nil } return failure( code: CLIErrorCode.remoteAgentUnsupported, message: "The selected pane's agent runs on a remote host; dispatch needs a local agent to report its receipt.", @@ -191,13 +194,15 @@ final class AgentDispatchCommandHandler: CommandHandler { ) } - private func prepareDelivery(target: TabResolvedTarget) async -> CommandResponse? { - if let refusal = remoteAgentRefusal(conditionSnapshot(target), target: target) { return refusal } + private func prepareDelivery(target: TabResolvedTarget, needsReceipt: Bool) async -> CommandResponse? { + if let refusal = remoteAgentRefusal(conditionSnapshot(target), target: target, needsReceipt: needsReceipt) { + return refusal + } // Code security: do not clear local IME input or append into an actively edited composer. if let reason = inputProtection(target) { return failure(code: CLIErrorCode.dispatchTargetBusy, message: reason) } - if let refusal = await awaitIdleAgent(target: target) { return refusal } + if let refusal = await awaitIdleAgent(target: target, needsReceipt: needsReceipt) { return refusal } if let reason = inputProtection(target) { return failure(code: CLIErrorCode.dispatchTargetBusy, message: reason) } @@ -205,7 +210,7 @@ final class AgentDispatchCommandHandler: CommandHandler { } /// Nil when the pane hosts an idle agent; otherwise the structured refusal. - private func awaitIdleAgent(target: TabResolvedTarget) async -> CommandResponse? { + private func awaitIdleAgent(target: TabResolvedTarget, needsReceipt: Bool) async -> CommandResponse? { var elapsedMilliseconds = 0 var stabilizer = AgentConditionEvidence.HeuristicStabilizer() while true { @@ -227,7 +232,7 @@ final class AgentDispatchCommandHandler: CommandHandler { details: AgentDispatchErrorDetails(target: TabTarget(from: target), signals: snapshot.signals) ) } - if let refusal = remoteAgentRefusal(snapshot, target: target) { return refusal } + if let refusal = remoteAgentRefusal(snapshot, target: target, needsReceipt: needsReceipt) { return refusal } switch verdict(for: snapshot) { case .idle: return nil diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index 86ff18ee..31cb277d 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -252,7 +252,8 @@ final class RemotePaneTracker { let verdict = presence.observe(snapshot, contains: contains, now: uptime()) switch verdict { case .present: - confirmedAt = now + // Freshness counts from the snapshot's acquisition, not from when a tick read it. + confirmedAt = now.addingTimeInterval(-max(0, uptime() - snapshot.startedAt)) case .released: bound = nil confirmedAt = nil diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 3a0f613a..feeaa47d 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -218,7 +218,7 @@ extension WorktreeTerminalState { if let withdrawnAt = coordinator.lastProgramStatusWithdrawalAt, capturedAt < withdrawnAt { return true } - let stabilized = decision.state + let stabilized = Self.publishedState(decision.state, agent: agent, previous: previous, remote: remote != nil) let launchObservation = resolvedLaunchObservation(identified: identified, previous: previous) let lastChangedAt = (previous.detectedAgent != agent || previous.state != stabilized) ? now : previous.lastChangedAt var next = PaneAgentState( @@ -292,6 +292,18 @@ extension WorktreeTerminalState { launchProfilesBySurface.removeValue(forKey: surfaceID) } + /// The state a pane publishes. A local unknown hides the entry, but a remote agent keeps + /// its last observation until the snapshot rule or the `ssh` exit releases it (docs-ai + /// 082, C3): a withdrawn OSC root over an unrecognized screen must not read as gone. + static func publishedState( + _ state: AgentRawState, agent: DetectedAgent, previous: PaneAgentState, remote: Bool + ) -> AgentRawState { + guard state == .unknown, remote, previous.detectedAgent == agent, + previous.agentProcess.map({ $0.origin != .local }) ?? false + else { return state } + return previous.state + } + /// The pane's presence after one probe. A remote agent is identified on every tick while /// its tracker holds it; the tracker releases it by the snapshot rule (docs-ai 082, C3) /// instead of the local miss count, and a gone `ssh` releases it at once. A finished diff --git a/App/Tests/AgentDispatchCommandHandlerTests.swift b/App/Tests/AgentDispatchCommandHandlerTests.swift index 6cc818f1..16a578bf 100644 --- a/App/Tests/AgentDispatchCommandHandlerTests.swift +++ b/App/Tests/AgentDispatchCommandHandlerTests.swift @@ -331,6 +331,25 @@ struct AgentDispatchCommandHandlerTests { let response = await handler.handle(envelope: dispatch(pane: target.paneID)) #expect(response.error?.code == CLIErrorCode.remoteAgentUnsupported) #expect(issued == 0) + + // Human input needs no receipt: it reaches the same remote agent as typed text. + var delivered: [String] = [] + let input = AgentDispatchCommandHandler( + resolveTarget: { _ in .success(target) }, + conditionSnapshot: { _ in remote }, + issueDispatch: { _ in + issued += 1 + return .failure(.surfacePending) + }, + deliverPrompt: { _, text in + delivered.append(text) + return true + } + ) + let sent = await input.handle(envelope: envelope(.agentsInput(.init(pane: target.paneID, prompt: "hello")))) + #expect(sent.ok) + #expect(delivered == ["hello"]) + #expect(issued == 0) } @Test func dispatchRefusesWorkingBlockedAndRuntimeNeedsInputAgents() async throws { diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 616b8ff5..0afa9120 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -249,6 +249,61 @@ struct RemoteAgentDetectionTests { #expect(emitted.last?.remote?.isStale == false) } + @Test func freshnessCountsFromTheSnapshotsAcquisition() async { + var clock: TimeInterval = 100 + let source = FakeRemoteSource() + let surfaceID = UUID() + let tracker = RemotePaneTracker( + surfaceID: surfaceID, ssh: LocalProcessGeneration(pid: 1, startedAt: Date()), + invocation: .supported(SSHConnectionRecipe(destination: "mini")), source: source.source, + uptime: { clock }, onSnapshot: {}) + tracker.start() + await settle { source.recipes.count == 1 } + // Acquired at 100, read by a tick at 104. + let acquired = RemoteProbeSnapshot( + connection: source.connection, connectionEpoch: 0, sequence: 1, startedAt: 100, finishedAt: 104, + outcome: .complete, + report: RemoteProbeReport( + operatingSystem: .linux, bootID: "boot", + sessions: [ + RemoteProbeSession( + paneID: surfaceID, anchorPID: 100, tty: "/dev/pts/0", foregroundProcessGroupID: 200, + processes: [remoteProcess(200, name: "claude")]) + ])) + source.push(acquired) + await settle { tracker.latest?.sequence == 1 } + clock = 104 + let read = Date() + let observation = tracker.observe(active: true, now: read) + #expect(observation.identified?.agent == .claude) + // Seven seconds after the read is eleven after the acquisition: stale. + #expect(observation.context(now: read.addingTimeInterval(7)).isStale) + #expect(!observation.context(now: read.addingTimeInterval(5)).isStale) + } + + @Test func aWithdrawnRootOverAnUnknownScreenKeepsTheRemoteEntry() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var removed: [UUID] = [] + fixture.state.onAgentEntryRemoved = { removed.append($0) } + probe.job = sshJob + await detect(fixture, screen: .unknown) + await settle { fixture.source.recipes.count == 1 } + fixture.state.handleProgramStatus( + GhosttyProgramStatusReport(state: .idle, kind: nil, id: "", app: "claude-code"), surfaceID: fixture.surfaceID) + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .idle) + // The root is withdrawn; no snapshot has proven the agent gone. + fixture.state.handleProgramStatus( + GhosttyProgramStatusReport(state: .clear, kind: nil, id: "", app: "claude-code"), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + await detect(fixture, screen: .unknown) + #expect(removed.isEmpty) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .idle) + } + @Test func twoCompleteSnapshotsWithoutTheAgentReleaseIt() async { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 70dd44db..470c6c3c 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -209,17 +209,21 @@ degraded OSC-only binding. - **Context.** `RemoteAgentContext` (host, correlation, staleness) rides on `PaneAgentState` and `ActiveAgentEntry` for agents on a remote origin. The tracker records when a snapshot - last confirmed the bound agent. Without a confirmation for 10 s the context is stale and + last confirmed the bound agent, counted from the snapshot's acquisition, not from when a + tick read it (review round 1). Without a confirmation for 10 s the context is stale and carries that time. While fresh it carries no time, so a steady probe stream changes no published entry. - **Stale proves nothing.** A stale entry keeps its agent and last state. Condition evidence reports it as `unavailable`, so `agents wait --until idle|blocked` and dispatch readiness never resolve on it. `exit` still needs the entry to go (an `ssh` exit or the snapshot - rule), not a stale observation. + rule), not a stale observation. A remote agent whose decision falls back to `unknown` + (for example, a withdrawn OSC root over an unrecognized screen) keeps its last state + instead of hiding the entry (review round 1). - **Admission.** - `agents dispatch` refuses a remote agent with `REMOTE_AGENT_UNSUPPORTED` before typing anything: the receipt is reported with `prowl agents dispatch-complete` from inside the - pane, which the remote host cannot run. + pane, which the remote host cannot run. Human input (`agents input`, which the Mirror + Send uses) needs no receipt and is still delivered (review round 1). - Workflow admission refuses a `current` or `pick` role on such a pane with the same code. - **Measured** in a Debug instance with the flag on: - `agents dispatch` to a remote Claude in the Debian container returned -- 2.54.0 From fe976c7834c19388172af6f8c3ae3f4cf1ffef39 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 01:47:19 +0900 Subject: [PATCH 15/51] List remote agents with an unknown state instead of carrying state over A remote agent stays listed while no evidence decides its state; its context marks the state unknown and conditions read it as unknown. No earlier state is carried over, so a predecessor's Blocked never describes a successor. --- .../CLIService/AgentConditionEvidence.swift | 2 + .../AgentDetection/RemoteAgentContext.swift | 9 ++- .../BusinessLogic/RemotePaneTracker.swift | 5 +- ...WorktreeTerminalState+AgentDetection.swift | 21 ++--- App/Tests/RemoteAgentDetectionTests.swift | 77 ++++++++++++++++++- docs-ai/082-ssh-remote-agents/001-action.md | 9 ++- 6 files changed, 100 insertions(+), 23 deletions(-) diff --git a/App/Sources/CLIService/AgentConditionEvidence.swift b/App/Sources/CLIService/AgentConditionEvidence.swift index 9be8f860..06282fc9 100644 --- a/App/Sources/CLIService/AgentConditionEvidence.swift +++ b/App/Sources/CLIService/AgentConditionEvidence.swift @@ -147,6 +147,8 @@ enum AgentConditionEvidence { guard snapshot.isLive else { return "gone" } guard let agent = snapshot.agent else { return "absent" } if agent.remote?.isStale == true { return unavailableState } + // A remote entry stays listed while its state is unknown; it proves no condition. + if agent.remote?.isStateKnown == false { return "unknown" } let state = status(for: agent, fallback: .idle).rawValue // An unmatched or unrecognized screen is not idle evidence: a retained // `.idle` may have been stabilized before the layout became unclassifiable. diff --git a/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift b/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift index 1ea32368..776278d5 100644 --- a/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift +++ b/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift @@ -12,15 +12,22 @@ nonisolated struct RemoteAgentContext: Equatable, Sendable { /// so a steady stream of confirmations does not change the published entry. var lastConfirmedAt: Date? var isStale: Bool + /// False while no evidence decides the agent's state (an unrecognized screen without an + /// eligible OSC record). The entry stays listed, but its state is evidence for nothing. + var isStateKnown: Bool /// `user@hostname`, the label a remote pane shows. var hostLabel: String? { connection.map { "\($0.user)@\($0.hostname)" } } - init(connection: ConnectionKey?, correlation: RemotePaneCorrelation, confirmedAt: Date?, now: Date) { + init( + connection: ConnectionKey?, correlation: RemotePaneCorrelation, confirmedAt: Date?, now: Date, + stateKnown: Bool = true + ) { self.connection = connection self.correlation = correlation + isStateKnown = stateKnown let isStale = confirmedAt.map { now.timeIntervalSince($0) > Self.staleAfter } ?? true self.isStale = isStale lastConfirmedAt = isStale ? confirmedAt : nil diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index 31cb277d..51aa29ba 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -91,8 +91,9 @@ final class RemotePaneTracker { /// When a snapshot last confirmed the bound agent. var confirmedAt: Date? - func context(now: Date) -> RemoteAgentContext { - RemoteAgentContext(connection: connection, correlation: correlation, confirmedAt: confirmedAt, now: now) + func context(now: Date, stateKnown: Bool = true) -> RemoteAgentContext { + RemoteAgentContext( + connection: connection, correlation: correlation, confirmedAt: confirmedAt, now: now, stateKnown: stateKnown) } } diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index feeaa47d..71202795 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -218,7 +218,7 @@ extension WorktreeTerminalState { if let withdrawnAt = coordinator.lastProgramStatusWithdrawalAt, capturedAt < withdrawnAt { return true } - let stabilized = Self.publishedState(decision.state, agent: agent, previous: previous, remote: remote != nil) + let stabilized = decision.state let launchObservation = resolvedLaunchObservation(identified: identified, previous: previous) let lastChangedAt = (previous.detectedAgent != agent || previous.state != stabilized) ? now : previous.lastChangedAt var next = PaneAgentState( @@ -237,7 +237,8 @@ extension WorktreeTerminalState { ) next.sessionMissStreak = sessionMissStreak next.decision = decision - next.remote = next.agentProcess?.origin == .local ? nil : remote?.context(now: now) + next.remote = + next.agentProcess?.origin == .local ? nil : remote?.context(now: now, stateKnown: stabilized != .unknown) // Limit logging to meaningful transitions - agent identity or // stabilized state changes. Raw oscillation and `seen` flips are // routine and would otherwise dominate the log stream. @@ -292,18 +293,6 @@ extension WorktreeTerminalState { launchProfilesBySurface.removeValue(forKey: surfaceID) } - /// The state a pane publishes. A local unknown hides the entry, but a remote agent keeps - /// its last observation until the snapshot rule or the `ssh` exit releases it (docs-ai - /// 082, C3): a withdrawn OSC root over an unrecognized screen must not read as gone. - static func publishedState( - _ state: AgentRawState, agent: DetectedAgent, previous: PaneAgentState, remote: Bool - ) -> AgentRawState { - guard state == .unknown, remote, previous.detectedAgent == agent, - previous.agentProcess.map({ $0.origin != .local }) ?? false - else { return state } - return previous.state - } - /// The pane's presence after one probe. A remote agent is identified on every tick while /// its tracker holds it; the tracker releases it by the snapshot rule (docs-ai 082, C3) /// instead of the local miss count, and a gone `ssh` releases it at once. A finished @@ -670,7 +659,9 @@ extension WorktreeTerminalState { } func activeAgentEntry(surfaceID: UUID, tabId: TerminalTabID, state: PaneAgentState) -> ActiveAgentEntry? { - guard let agent = state.detectedAgent, state.state != .unknown else { return nil } + // A local unknown hides the entry. A remote agent stays listed with an unknown state + // until the snapshot rule or the `ssh` exit releases it (docs-ai 082, C3). + guard let agent = state.detectedAgent, state.state != .unknown || state.remote != nil else { return nil } let paneIDs = trees[tabId]?.leaves().map(\.id) ?? [] let paneIndex = paneIDs.firstIndex(of: surfaceID).map { $0 + 1 } ?? 1 let tabTitle = tabManager.tabs.first(where: { $0.id == tabId })?.displayTitle ?? "?" diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 0afa9120..03f08f59 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -301,7 +301,82 @@ struct RemoteAgentDetectionTests { await detect(fixture, screen: .unknown) #expect(removed.isEmpty) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) - #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .idle) + // The state is unknown again, not the withdrawn root's: it stays listed and proves nothing. + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.remote?.isStateKnown == false) + } + + /// The condition view of the pane's published entry. + private func condition(_ fixture: Fixture, entries: [ActiveAgentEntry]) -> AgentConditionSnapshot { + AgentConditionSnapshot( + agent: entries.last { $0.surfaceID == fixture.surfaceID }, signal: nil, revision: 1, isLive: true, + signals: .empty) + } + + @Test func aRemoteAgentWithAnUnknownStateIsListedButSatisfiesNoCondition() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var emitted: [ActiveAgentEntry] = [] + var removed: [UUID] = [] + fixture.state.onAgentEntryChanged = { emitted.append($0) } + fixture.state.onAgentEntryRemoved = { removed.append($0) } + probe.job = sshJob + await detect(fixture, screen: .unknown) + await settle { fixture.source.recipes.count == 1 } + // A correlated agent at an unrecognized startup screen, without OSC records. + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture, screen: .unknown) + let entry = try #require(emitted.last) + #expect(entry.agent == .claude) + #expect(entry.remote?.isStateKnown == false) + let fresh = condition(fixture, entries: emitted) + #expect(AgentConditionEvidence.normalizedState(fresh) == "unknown") + #expect( + !AgentConditionEvidence.heuristicMatches( + condition: .exit, snapshot: fresh, normalizedState: "unknown", baseline: .init(snapshot: fresh))) + // Stale later: still listed, still nothing. + await detect(fixture, screen: .unknown, now: Date().addingTimeInterval(11)) + #expect(removed.isEmpty) + let stale = condition(fixture, entries: emitted) + #expect(AgentConditionEvidence.normalizedState(stale) == AgentConditionEvidence.unavailableState) + #expect( + !AgentConditionEvidence.heuristicMatches( + condition: .exit, snapshot: stale, normalizedState: AgentConditionEvidence.unavailableState, + baseline: .init(snapshot: stale))) + } + + @Test func aPredecessorsBlockedStateNeverProvesTheSuccessorBlocked() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var emitted: [ActiveAgentEntry] = [] + fixture.state.onAgentEntryChanged = { emitted.append($0) } + probe.job = sshJob + await detect(fixture, screen: .unknown) + await settle { fixture.source.recipes.count == 1 } + fixture.state.handleProgramStatus( + GhosttyProgramStatusReport(state: .blocked, kind: nil, id: "", app: "claude-code"), + surfaceID: fixture.surfaceID) + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .blocked) + // A successor replaced it between snapshots; its screen is not recognized yet. + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(300, name: "claude"))) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(300)) + let successor = condition(fixture, entries: emitted) + #expect(AgentConditionEvidence.normalizedState(successor) == "unknown") + #expect( + !AgentConditionEvidence.detectorReports( + .blocked, normalizedState: AgentConditionEvidence.normalizedState(successor))) + // A withdrawn Blocked root over an unknown screen proves nothing either. + fixture.state.handleProgramStatus( + GhosttyProgramStatusReport(state: .blocked, kind: nil, id: "", app: "claude-code"), + surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + fixture.state.handleProgramStatus( + GhosttyProgramStatusReport(state: .clear, kind: nil, id: "", app: "claude-code"), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + #expect(AgentConditionEvidence.normalizedState(condition(fixture, entries: emitted)) == "unknown") } @Test func twoCompleteSnapshotsWithoutTheAgentReleaseIt() async { diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 470c6c3c..222300f2 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -216,9 +216,11 @@ degraded OSC-only binding. - **Stale proves nothing.** A stale entry keeps its agent and last state. Condition evidence reports it as `unavailable`, so `agents wait --until idle|blocked` and dispatch readiness never resolve on it. `exit` still needs the entry to go (an `ssh` exit or the snapshot - rule), not a stale observation. A remote agent whose decision falls back to `unknown` - (for example, a withdrawn OSC root over an unrecognized screen) keeps its last state - instead of hiding the entry (review round 1). + rule), not a stale observation. A remote agent whose state is unknown (an unrecognized + screen with no eligible OSC record, for example after a withdrawn root or a successor + start) stays listed: its context marks the state unknown, and conditions read it as + `unknown`. No earlier state is carried over, so a predecessor's Blocked never describes + a successor (review rounds 1–2). - **Admission.** - `agents dispatch` refuses a remote agent with `REMOTE_AGENT_UNSUPPORTED` before typing anything: the receipt is reported with `prowl agents dispatch-complete` from inside the @@ -234,4 +236,3 @@ degraded OSC-only binding. - With Prowl's side-channel master stopped (`SIGSTOP` on the `[mux]` process), the same wait timed out after 12 s, and the entry kept its agent and last state. - After `SIGCONT`, the wait resolved again. - -- 2.54.0 From 88f8066bc45ee63f43655106e1c2525ce8702a75 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 02:02:05 +0900 Subject: [PATCH 16/51] Bind a verified OSC producer in panes the host cannot map When a pane's ssh cannot be probed or mapped, a verified producer's root record that arrived after the ssh started binds the agent with an unprobed remote reference that names the local ssh, never a remote pid. Only the ssh exit releases it; a quiet producer turns stale after 10 s (docs-ai 082 S3c, behind the flag). --- .../Domain/AgentDetection/ProcessRef.swift | 11 +++ .../AgentDetection/RemoteAgentContext.swift | 6 +- .../AgentDetectionCoordinator.swift | 2 +- .../BusinessLogic/RemotePaneTracker.swift | 37 ++++++++- ...WorktreeTerminalState+AgentDetection.swift | 26 +++++- App/Tests/RemoteAgentDetectionTests.swift | 82 +++++++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 26 +++++- 7 files changed, 184 insertions(+), 6 deletions(-) diff --git a/App/Sources/Domain/AgentDetection/ProcessRef.swift b/App/Sources/Domain/AgentDetection/ProcessRef.swift index 1c6887f5..30cbb268 100644 --- a/App/Sources/Domain/AgentDetection/ProcessRef.swift +++ b/App/Sources/Domain/AgentDetection/ProcessRef.swift @@ -43,6 +43,10 @@ nonisolated struct RemoteProcessRef: Hashable, Sendable { nonisolated enum ProcessRef: Hashable, Sendable { case local(pid_t) case remote(RemoteProcessRef) + /// An agent known only from its OSC 7501 records in a pane whose foreground is this local + /// `ssh`, when the host cannot be probed or the pane not mapped (docs-ai 082, L0 item 9). + /// It names no remote process: Prowl never invents a remote pid. + case unprobedRemote(LocalProcessGeneration) var localPID: pid_t? { guard case .local(let pid) = self else { return nil } @@ -55,6 +59,7 @@ nonisolated enum ProcessRef: Hashable, Sendable { switch self { case .local(let pid): pid case .remote(let remote): remote.pid + case .unprobedRemote: 0 } } @@ -62,6 +67,7 @@ nonisolated enum ProcessRef: Hashable, Sendable { switch self { case .local: .local case .remote(let remote): .remote(remote.connection) + case .unprobedRemote(let ssh): .unprobedRemote(ssh) } } } @@ -72,6 +78,8 @@ nonisolated enum ProcessRef: Hashable, Sendable { nonisolated enum ProcessOrigin: Hashable, Sendable { case local case remote(ConnectionKey) + /// Remote behind this local `ssh`, without a probed host identity. + case unprobedRemote(LocalProcessGeneration) } extension ProcessRef: CustomStringConvertible { @@ -79,6 +87,7 @@ extension ProcessRef: CustomStringConvertible { switch self { case .local(let pid): "\(pid)" case .remote(let remote): "\(remote.connection.hostname):\(remote.pid)" + case .unprobedRemote(let ssh): "unprobed(ssh \(ssh.pid))" } } } @@ -95,6 +104,7 @@ nonisolated struct LocalProcessGeneration: Equatable, Hashable, Sendable { nonisolated enum AgentProcessGeneration: Equatable, Hashable, Sendable { case local(LocalProcessGeneration) case remote(RemoteProcessRef) + case unprobedRemote(LocalProcessGeneration) static func local(pid: pid_t, startedAt: Date) -> AgentProcessGeneration { .local(LocalProcessGeneration(pid: pid, startedAt: startedAt)) @@ -109,6 +119,7 @@ nonisolated enum AgentProcessGeneration: Equatable, Hashable, Sendable { switch self { case .local: .local case .remote(let remote): .remote(remote.connection) + case .unprobedRemote(let ssh): .unprobedRemote(ssh) } } } diff --git a/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift b/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift index 776278d5..66af6eda 100644 --- a/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift +++ b/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift @@ -15,6 +15,9 @@ nonisolated struct RemoteAgentContext: Equatable, Sendable { /// False while no evidence decides the agent's state (an unrecognized screen without an /// eligible OSC record). The entry stays listed, but its state is evidence for nothing. var isStateKnown: Bool + /// The agent is known only from its OSC records (L0 item 9): heuristic, no remote + /// process, stale whenever its producer has been quiet for `staleAfter`. + var isUnprobed: Bool /// `user@hostname`, the label a remote pane shows. var hostLabel: String? { @@ -23,11 +26,12 @@ nonisolated struct RemoteAgentContext: Equatable, Sendable { init( connection: ConnectionKey?, correlation: RemotePaneCorrelation, confirmedAt: Date?, now: Date, - stateKnown: Bool = true + stateKnown: Bool = true, isUnprobed: Bool = false ) { self.connection = connection self.correlation = correlation isStateKnown = stateKnown + self.isUnprobed = isUnprobed let isStale = confirmedAt.map { now.timeIntervalSince($0) > Self.staleAfter } ?? true self.isStale = isStale lastConfirmedAt = isStale ? confirmedAt : nil diff --git a/App/Sources/Features/Terminal/BusinessLogic/AgentDetectionCoordinator.swift b/App/Sources/Features/Terminal/BusinessLogic/AgentDetectionCoordinator.swift index 0d17f2c6..50bbd141 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/AgentDetectionCoordinator.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/AgentDetectionCoordinator.swift @@ -222,7 +222,7 @@ final class AgentDetectionCoordinator { switch process { case .local(let generation): return programStatusOriginFence.map { max($0, generation.startedAt) } ?? generation.startedAt - case .remote: + case .remote, .unprobedRemote: return remoteProgramStatusFence case nil: return nil diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index 51aa29ba..539c7574 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -88,12 +88,14 @@ final class RemotePaneTracker { var verdict: RemoteProcessPresence.Verdict? /// The arrival-time fence for OSC records of the bound remote agent (L0 item 5). var programStatusFence: Date? - /// When a snapshot last confirmed the bound agent. + /// When a snapshot last confirmed the bound agent, or for an unprobed binding, when its + /// producer last reported. var confirmedAt: Date? func context(now: Date, stateKnown: Bool = true) -> RemoteAgentContext { RemoteAgentContext( - connection: connection, correlation: correlation, confirmedAt: confirmedAt, now: now, stateKnown: stateKnown) + connection: connection, correlation: correlation, confirmedAt: confirmedAt, now: now, stateKnown: stateKnown, + isUnprobed: identified.map { if case .unprobedRemote = $0.process.ref { true } else { false } } ?? false) } } @@ -136,6 +138,8 @@ final class RemotePaneTracker { let bootID: String } private var bound: IdentifiedAgentProcess? + /// An agent known only from its OSC records (L0 item 9); released only with the `ssh`. + private var unprobed: IdentifiedAgentProcess? private var confirmedAt: Date? private var presence = RemoteProcessPresence() /// The OSC fence of this `ssh` generation; it outlives the tracker (close and Undo). @@ -226,11 +230,40 @@ final class RemotePaneTracker { consumedSequence = (latest.connectionEpoch, latest.sequence) verdict = consume(latest, report: report, now: now) } + // A probed session supersedes the unprobed binding: its own evidence decides from here. + if correlation == .correlated { unprobed = nil } + if bound == nil, let unprobed { + return Observation( + correlation: correlation, connection: lease?.connection, identified: unprobed, verdict: nil, + programStatusFence: ssh.startedAt, confirmedAt: nil) + } return Observation( correlation: correlation, connection: lease?.connection, identified: bound, verdict: verdict, programStatusFence: fenceHistory.fence, confirmedAt: confirmedAt) } + /// Whether a verified producer's report may stand in for a probed agent: the host cannot + /// be probed, the pane is not mapped, or its `ssh` command line is outside the grammar. + var allowsUnprobedBinding: Bool { + guard bound == nil else { return false } + switch correlation { + case .unmapped, .probeUnavailable, .unsupported: return true + case .pending, .correlated, .ambiguous: return false + } + } + + /// Binds `agent` from its OSC root record (L0 item 9). The reference names this `ssh`, + /// never a remote pid. + func bindUnprobed(_ agent: DetectedAgent) -> IdentifiedAgentProcess { + if let unprobed, unprobed.agent == agent { return unprobed } + let identified = IdentifiedAgentProcess( + agent: agent, name: agent.iconLookupToken, + process: ForegroundProcess( + ref: .unprobedRemote(ssh), parentPID: nil, name: agent.iconLookupToken, argv0: nil, cmdline: nil)) + unprobed = identified + return identified + } + /// Applies a new snapshot to the binding and returns its verdict for the bound agent. private func consume(_ snapshot: RemoteProbeSnapshot, report: RemoteProbeReport, now: Date) -> RemoteProcessPresence.Verdict? diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 71202795..65f0090b 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -280,6 +280,8 @@ extension WorktreeTerminalState { ProcessDetection.processStartDate(pid: pid).map { .local(pid: pid, startedAt: $0) } case .remote(let remote): .remote(remote) + case .unprobedRemote(let ssh): + .unprobedRemote(ssh) case nil: nil } @@ -340,11 +342,33 @@ extension WorktreeTerminalState { } let active = surfaceAgentStates[surfaceID]?.detectedAgent != nil || isAgentDetectionSettling(surfaceID: surfaceID, now: now) - let observation = tracker.observe(active: active, now: now) + var observation = tracker.observe(active: active, now: now) remoteFenceHistoryBySurface[surfaceID] = tracker.fenceHistory + if observation.identified == nil, tracker.allowsUnprobedBinding, + let agent = verifiedRootProducer(surfaceID: surfaceID, notBefore: ssh.generation.startedAt) + { + observation.identified = tracker.bindUnprobed(agent) + observation.programStatusFence = ssh.generation.startedAt + } + if case .unprobedRemote = observation.identified?.process.ref { + // OSC has no heartbeat: the producer's last report is the only confirmation. + observation.confirmedAt = programStatusStoresBySurface[surfaceID]?.root?.arrivedAt + } return observation } + /// The verified OSC 7501 producer whose root record arrived on this surface after + /// `notBefore`, if any. "Verified" means its protocol behaviour was checked, not that the + /// payload is authentic. + private func verifiedRootProducer(surfaceID: UUID, notBefore: Date) -> DetectedAgent? { + guard let store = programStatusStoresBySurface[surfaceID], let root = store.root, root.arrivedAt >= notBefore, + let app = store.app(of: root), + let agent = DetectedAgent.allCases.first(where: { $0.programStatusApp == app }) + else { return nil } + let support = programStatusSupportForTesting ?? ProgramStatusSupport.level(for:) + return support(agent) == .verified ? agent : nil + } + /// The terminal's own foreground `ssh` and its arguments (without `argv[0]`), with the /// generation that its tracker belongs to. An `ssh` that another `ssh` of the job started /// (the `-W` transport of `-J`, or one under a `ProxyCommand` shell) is never it. diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 03f08f59..31b65c4f 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -667,6 +667,88 @@ struct RemoteAgentDetectionTests { #expect(source.attempts == 3) } + private var sshGeneration: LocalProcessGeneration { + LocalProcessGeneration(pid: getpid(), startedAt: ProcessDetection.processStartDate(pid: getpid()) ?? .distantPast) + } + + private func report(_ state: GhosttyProgramStatusReport.State, app: String = "claude-code") + -> GhosttyProgramStatusReport + { + GhosttyProgramStatusReport(state: state, kind: nil, id: "", app: app) + } + + @Test func aVerifiedProducerBindsAnUnprobedPaneWithoutARemotePid() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var emitted: [ActiveAgentEntry] = [] + var removed: [UUID] = [] + fixture.state.onAgentEntryChanged = { emitted.append($0) } + fixture.state.onAgentEntryRemoved = { removed.append($0) } + fixture.source.failNext(100) + probe.job = sshJob + await detect(fixture) + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + + fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) + await detect(fixture) + let pane = fixture.state.surfaceAgentStates[fixture.surfaceID] + #expect(pane?.agentProcess == .unprobedRemote(sshGeneration)) + #expect(pane?.decision?.reason.identifier == "osc.working") + let entry = try #require(emitted.last) + #expect(entry.remote?.isUnprobed == true) + #expect(entry.remote?.isStale == false) + #expect(pane?.session == nil) + + // A withdrawal or quiet producer never releases it; the observation turns stale instead. + fixture.state.handleProgramStatus(report(.clear), surfaceID: fixture.surfaceID) + await detect(fixture, now: Date().addingTimeInterval(11)) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) + #expect(removed.isEmpty) + #expect(emitted.last?.remote?.isStale == true) + // Only the `ssh` exit releases it. + probe.job = nil + await detect(fixture) + #expect(removed == [fixture.surfaceID]) + } + + @Test func unprobedBindingNeedsAVerifiedReportFromThisSSH() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + fixture.source.failNext(100) + probe.job = sshJob + await detect(fixture) + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + // A root that arrived before this `ssh` started belongs to something else. + let sshStart = sshGeneration.startedAt + fixture.state.handleProgramStatus(report(.idle), surfaceID: fixture.surfaceID, now: sshStart.addingTimeInterval(-1)) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + // An unverified producer never binds. + fixture.state.programStatusSupportForTesting = { _ in .unverified } + fixture.state.handleProgramStatus(report(.idle), surfaceID: fixture.surfaceID) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + } + + @Test func aProbedSessionSupersedesTheUnprobedBinding() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + // Unmapped: the host is reachable but no session carries this pane's id. + await deliver(fixture, snapshot(1, pane: UUID(), foreground: remoteProcess(200, name: "claude"))) + fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) + // The pane is mapped now and shows the agent: the probed reference takes over. + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + } + @Test func unsupportedArgvNeverConnects() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 222300f2..55b20e11 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -10,7 +10,8 @@ sections below record what shipped and what differs from the plan. | 2026-10-17 | S1: typed process references (C2) and the `ConnectionKey` value type; local-only OS entry points; evidence retirement on an origin change. No behaviour change for local panes. Review Loop clean after 3 rounds | #924 | | 2026-10-17 | S2: host identity (C1), connection owner (C4), snapshot contract (C3), structured commands (C5), probe and parser (C6), all unused by the app. Review Loop clean after 2 rounds | #925 | | 2026-10-17 | S3a: manual-session correlation behind `PROWL_REMOTE_AGENT_DETECTION` — `LC_PROWL_PANE`, argv grammar, per-pane tracker, identity, snapshot release, remote OSC fence. Review Loop: 4 rounds plus a 2-round follow-up, clean (R1–R10) | #926 | -| 2026-10-18 | S3b: stale projection and refusal of remote agents in conditions, dispatch and workflow admission (behind the flag) | S3b PR | +| 2026-10-18 | S3b: stale projection and refusal of remote agents in conditions, dispatch and workflow admission (behind the flag). Review Loop clean after 3 rounds | #927 | +| 2026-10-18 | S3c: degraded OSC-only binding for panes the host cannot map or probe (behind the flag) | S3c PR | ## S1 — Typed process references @@ -236,3 +237,26 @@ degraded OSC-only binding. - With Prowl's side-channel master stopped (`SIGSTOP` on the `[mux]` process), the same wait timed out after 12 s, and the entry kept its agent and last state. - After `SIGCONT`, the wait resolved again. + +## S3c — Degraded OSC-only binding (behind the flag) + +- **When.** The pane's foreground is a local `ssh` whose tracker is `unmapped`, + `probeUnavailable`, or `unsupported`, and no probed agent is bound. The surface's root + record must come from a `.verified` producer and must have arrived after that `ssh` + started. The plan names `unmapped` and `probeUnavailable`; `unsupported` (argv outside the + grammar) is a pane that cannot be probed either. +- **Identity.** `ProcessRef.unprobedRemote(ssh generation)` (with matching + `AgentProcessGeneration` and `ProcessOrigin` cases) names the local `ssh`, never a remote + pid. Its origin is not local, so local providers, hooks, sessions and admission treat it as + remote. The OSC fence is the `ssh` start. +- **Release and confidence.** Only the `ssh` exit releases the binding. A withdrawal or a + quiet producer only ages it: the context is stale 10 s after the producer's last root + report (OSC has no heartbeat). `RemoteAgentContext.isUnprobed` marks it heuristic. Once the + pane is mapped (`correlated`), the probed path takes over. +- **Measured** in a Debug instance with the flag on. `ssh -F /dev/null -t mini` skips the + system `SendEnv`, so the host shows the session as pane `-` and the pane is `unmapped`. + - Claude Code bound through `osc.idle` 1.49 s after the command, then went + `osc.working` → `osc.done`; dispatch was refused. + - After `/exit` the entry stayed listed (by design, stale after 10 s); the `ssh` exit + released it. + -- 2.54.0 From feea6edd8e76cc81b987df1bc2488ad8ca6631fb Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 02:14:24 +0900 Subject: [PATCH 17/51] Rebind unprobed panes to later producers and fence suspended local records - A later verified producer in the same ssh replaces the unprobed binding; only the bound producer's own report confirms it. - Records count from the ssh start, raised to the last time a local agent was seen when it ran while the ssh was suspended. --- ...WorktreeTerminalState+AgentDetection.swift | 50 +++++++++++---- App/Tests/RemoteAgentDetectionTests.swift | 61 +++++++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 6 +- 3 files changed, 102 insertions(+), 15 deletions(-) diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 65f0090b..58192406 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -344,22 +344,46 @@ extension WorktreeTerminalState { surfaceAgentStates[surfaceID]?.detectedAgent != nil || isAgentDetectionSettling(surfaceID: surfaceID, now: now) var observation = tracker.observe(active: active, now: now) remoteFenceHistoryBySurface[surfaceID] = tracker.fenceHistory - if observation.identified == nil, tracker.allowsUnprobedBinding, - let agent = verifiedRootProducer(surfaceID: surfaceID, notBefore: ssh.generation.startedAt) - { - observation.identified = tracker.bindUnprobed(agent) - observation.programStatusFence = ssh.generation.startedAt - } - if case .unprobedRemote = observation.identified?.process.ref { - // OSC has no heartbeat: the producer's last report is the only confirmation. - observation.confirmedAt = programStatusStoresBySurface[surfaceID]?.root?.arrivedAt - } + return bindUnprobedProducer(observation: observation, tracker: tracker, surfaceID: surfaceID) + } + + /// The degraded OSC-only binding (docs-ai 082, L0 item 9): with no probed agent, a + /// verified producer's root record identifies the agent, and a later verified producer + /// replaces it. Records before `unprobedRecordFence` never acquire or confirm it. + private func bindUnprobedProducer( + observation: RemotePaneTracker.Observation, tracker: RemotePaneTracker, surfaceID: UUID + ) -> RemotePaneTracker.Observation { + var observation = observation + let current = observation.identified + let currentIsUnprobed = current.map { if case .unprobedRemote = $0.process.ref { true } else { false } } ?? false + guard currentIsUnprobed || (current == nil && tracker.allowsUnprobedBinding) else { return observation } + let fence = unprobedRecordFence(surfaceID: surfaceID, ssh: tracker.ssh) + if let producer = verifiedRootProducer(surfaceID: surfaceID, notBefore: fence), producer != current?.agent { + observation.identified = tracker.bindUnprobed(producer) + } + guard let bound = observation.identified else { return observation } + observation.programStatusFence = fence + // OSC has no heartbeat: the bound producer's own last eligible report is the only + // confirmation. + observation.confirmedAt = + verifiedRootProducer(surfaceID: surfaceID, notBefore: fence) == bound.agent + ? programStatusStoresBySurface[surfaceID]?.root?.arrivedAt : nil return observation } - /// The verified OSC 7501 producer whose root record arrived on this surface after - /// `notBefore`, if any. "Verified" means its protocol behaviour was checked, not that the - /// payload is authentic. + /// The earliest arrival an unprobed binding counts: the `ssh` start, raised to the last + /// time a local agent was seen on this surface when it ran after the `ssh` started (a + /// suspended `ssh` with a local agent in between). + private func unprobedRecordFence(surfaceID: UUID, ssh: LocalProcessGeneration) -> Date { + guard let origin = programStatusOriginBySurface[surfaceID], origin.origin == .local, + let seen = origin.lastSeenAt, seen > ssh.startedAt + else { return ssh.startedAt } + return seen + } + + /// The verified OSC 7501 producer whose root record arrived on this surface no earlier + /// than `notBefore`, if any. "Verified" means its protocol behaviour was checked, not that + /// the payload is authentic. private func verifiedRootProducer(surfaceID: UUID, notBefore: Date) -> DetectedAgent? { guard let store = programStatusStoresBySurface[surfaceID], let root = store.root, root.arrivedAt >= notBefore, let app = store.app(of: root), diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 31b65c4f..25405290 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -712,6 +712,67 @@ struct RemoteAgentDetectionTests { #expect(removed == [fixture.surfaceID]) } + @Test func aLaterVerifiedProducerReplacesTheUnprobedAgent() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var emitted: [ActiveAgentEntry] = [] + fixture.state.onAgentEntryChanged = { emitted.append($0) } + fixture.source.failNext(100) + probe.job = sshJob + await detect(fixture, screen: .unknown) + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + fixture.state.handleProgramStatus(report(.idle), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) + // Claude exits; Pi starts in the same `ssh` and reports. + fixture.state.handleProgramStatus(report(.clear), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) + fixture.state.handleProgramStatus(report(.working, app: "pi"), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + let pane = fixture.state.surfaceAgentStates[fixture.surfaceID] + #expect(pane?.detectedAgent == .pi) + #expect(pane?.agentProcess == .unprobedRemote(sshGeneration)) + #expect(pane?.decision?.reason.identifier == "osc.working") + #expect(emitted.last?.remote?.isStale == false) + } + + @Test func aLocalAgentBetweenSuspendAndResumeNeverBindsAnUnprobedPane() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + fixture.source.failNext(100) + probe.job = sshJob + await detect(fixture, screen: .unknown) + await settle { fixture.source.attempts == 1 } + // Ctrl-Z: a local Pi runs and leaves an idle root. + probe.job = ForegroundJob( + processGroupID: getpid(), + processes: [ForegroundProcess(pid: getpid(), parentProcessID: 1, name: "pi", argv0: "pi", cmdline: "pi")]) + fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID] = WorktreeTerminalState.AgentScreenScan( + agent: .pi, text: "", detection: AgentScreenDetection(state: .idle, reason: .legacyDetector)) + _ = await fixture.state.detectAgentState( + for: fixture.surface, tabId: fixture.tabID, resolveSession: noSession, now: Date()) + fixture.state.handleProgramStatus(report(.idle, app: "pi"), surfaceID: fixture.surfaceID) + _ = await fixture.state.detectAgentState( + for: fixture.surface, tabId: fixture.tabID, resolveSession: noSession, now: Date()) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .local(getpid())) + // `fg`: the remote side cannot be probed and sends nothing new. + probe.job = sshJob + await detect(fixture, screen: .unknown) + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + await detect(fixture, screen: .unknown) + // The local Pi may still be inside its local presence hold, but its record never + // becomes a remote agent. + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess != .unprobedRemote(sshGeneration)) + // A report from the remote side after the resume binds. + fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) + } + @Test func unprobedBindingNeedsAVerifiedReportFromThisSSH() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 55b20e11..1d0ef48b 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -252,11 +252,13 @@ degraded OSC-only binding. - **Release and confidence.** Only the `ssh` exit releases the binding. A withdrawal or a quiet producer only ages it: the context is stale 10 s after the producer's last root report (OSC has no heartbeat). `RemoteAgentContext.isUnprobed` marks it heuristic. Once the - pane is mapped (`correlated`), the probed path takes over. + pane is mapped (`correlated`), the probed path takes over. A later verified producer in the + same `ssh` (Claude exits, Pi starts) replaces the binding. Only the bound producer's own + report confirms it. Records count from the `ssh` start, raised to the last time a local + agent was seen on the surface when it ran while the `ssh` was suspended (review round 1). - **Measured** in a Debug instance with the flag on. `ssh -F /dev/null -t mini` skips the system `SendEnv`, so the host shows the session as pane `-` and the pane is `unmapped`. - Claude Code bound through `osc.idle` 1.49 s after the command, then went `osc.working` → `osc.done`; dispatch was refused. - After `/exit` the entry stayed listed (by design, stale after 10 s); the `ssh` exit released it. - -- 2.54.0 From 6fe313bc22ce96b3b99ee9243ab29939e3ebe4b4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 02:25:45 +0900 Subject: [PATCH 18/51] Fence degraded bindings by the ssh's foreground interval A tracker that takes over an ssh an earlier tracker followed starts a new foreground interval. Degraded bindings count records from the ssh start, raised to that interval's start and to the retained successor fence. --- .../BusinessLogic/RemotePaneTracker.swift | 16 ++++++++- ...WorktreeTerminalState+AgentDetection.swift | 15 ++------- App/Tests/RemoteAgentDetectionTests.swift | 33 +++++++++++++++++-- docs-ai/082-ssh-remote-agents/001-action.md | 8 +++-- 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index 539c7574..31ba1a82 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -144,6 +144,10 @@ final class RemotePaneTracker { private var presence = RemoteProcessPresence() /// The OSC fence of this `ssh` generation; it outlives the tracker (close and Undo). private(set) var fenceHistory: RemoteFenceHistory + /// When this tracker took over an `ssh` that an earlier tracker followed: the `ssh` left + /// the foreground (a local agent ran while it was suspended) or the pane was closed and + /// restored. Records from before it are not this foreground interval's. + let resumedAt: Date? init( surfaceID: UUID, @@ -151,13 +155,16 @@ final class RemotePaneTracker { invocation: SSHInvocation, fenceHistory: RemoteFenceHistory? = nil, source: RemoteSnapshotSource, + now: Date = Date(), uptime: @escaping () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }, onSnapshot: @escaping @MainActor () -> Void ) { self.surfaceID = surfaceID self.ssh = ssh self.invocation = invocation - self.fenceHistory = fenceHistory.flatMap { $0.ssh == ssh ? $0 : nil } ?? RemoteFenceHistory(ssh: ssh) + let retained = fenceHistory.flatMap { $0.ssh == ssh ? $0 : nil } + self.fenceHistory = retained ?? RemoteFenceHistory(ssh: ssh) + resumedAt = retained == nil ? nil : now self.source = source self.uptime = uptime self.onSnapshot = onSnapshot @@ -252,6 +259,13 @@ final class RemotePaneTracker { } } + /// The earliest arrival a degraded binding counts: the `ssh` start, raised to this + /// foreground interval's start when the `ssh` was resumed, and to the retained successor + /// fence, so a record that an earlier binding of this `ssh` excluded never comes back. + var unprobedRecordFence: Date { + [ssh.startedAt, resumedAt, fenceHistory.fence].compactMap(\.self).max() ?? ssh.startedAt + } + /// Binds `agent` from its OSC root record (L0 item 9). The reference names this `ssh`, /// never a remote pid. func bindUnprobed(_ agent: DetectedAgent) -> IdentifiedAgentProcess { diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 58192406..d51e0c33 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -349,7 +349,8 @@ extension WorktreeTerminalState { /// The degraded OSC-only binding (docs-ai 082, L0 item 9): with no probed agent, a /// verified producer's root record identifies the agent, and a later verified producer - /// replaces it. Records before `unprobedRecordFence` never acquire or confirm it. + /// replaces it. Records before the tracker's `unprobedRecordFence` never acquire or + /// confirm it. private func bindUnprobedProducer( observation: RemotePaneTracker.Observation, tracker: RemotePaneTracker, surfaceID: UUID ) -> RemotePaneTracker.Observation { @@ -357,7 +358,7 @@ extension WorktreeTerminalState { let current = observation.identified let currentIsUnprobed = current.map { if case .unprobedRemote = $0.process.ref { true } else { false } } ?? false guard currentIsUnprobed || (current == nil && tracker.allowsUnprobedBinding) else { return observation } - let fence = unprobedRecordFence(surfaceID: surfaceID, ssh: tracker.ssh) + let fence = tracker.unprobedRecordFence if let producer = verifiedRootProducer(surfaceID: surfaceID, notBefore: fence), producer != current?.agent { observation.identified = tracker.bindUnprobed(producer) } @@ -371,16 +372,6 @@ extension WorktreeTerminalState { return observation } - /// The earliest arrival an unprobed binding counts: the `ssh` start, raised to the last - /// time a local agent was seen on this surface when it ran after the `ssh` started (a - /// suspended `ssh` with a local agent in between). - private func unprobedRecordFence(surfaceID: UUID, ssh: LocalProcessGeneration) -> Date { - guard let origin = programStatusOriginBySurface[surfaceID], origin.origin == .local, - let seen = origin.lastSeenAt, seen > ssh.startedAt - else { return ssh.startedAt } - return seen - } - /// The verified OSC 7501 producer whose root record arrived on this surface no earlier /// than `notBefore`, if any. "Verified" means its protocol behaviour was checked, not that /// the payload is authentic. diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 25405290..28d1e3a5 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -751,12 +751,11 @@ struct RemoteAgentDetectionTests { processes: [ForegroundProcess(pid: getpid(), parentProcessID: 1, name: "pi", argv0: "pi", cmdline: "pi")]) fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID] = WorktreeTerminalState.AgentScreenScan( agent: .pi, text: "", detection: AgentScreenDetection(state: .idle, reason: .legacyDetector)) - _ = await fixture.state.detectAgentState( - for: fixture.surface, tabId: fixture.tabID, resolveSession: noSession, now: Date()) - fixture.state.handleProgramStatus(report(.idle, app: "pi"), surfaceID: fixture.surfaceID) _ = await fixture.state.detectAgentState( for: fixture.surface, tabId: fixture.tabID, resolveSession: noSession, now: Date()) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .local(getpid())) + // Its last root arrives after its last local poll; then it stops without a clear. + fixture.state.handleProgramStatus(report(.idle, app: "pi"), surfaceID: fixture.surfaceID) // `fg`: the remote side cannot be probed and sends nothing new. probe.job = sshJob await detect(fixture, screen: .unknown) @@ -773,6 +772,34 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) } + @Test func aDegradedReacquisitionKeepsTheRetainedSuccessorFence() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + probe.job = sshJob + await detect(fixture, screen: .unknown) + await settle { fixture.source.recipes.count == 1 } + // Claude reports, then the probe identifies a successor (Pi) whose fence excludes it. + fixture.state.handleProgramStatus(report(.idle), surfaceID: fixture.surfaceID) + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture, screen: .unknown) + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(300, name: "pi"))) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .pi) + // Close and undo: the new tracker for the same `ssh` cannot connect. + fixture.state.retireRemotePaneTracker(surfaceID: fixture.surfaceID) + fixture.source.failNext(100) + await detect(fixture, screen: .unknown) + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess != .unprobedRemote(sshGeneration)) + // A report after the reacquisition binds. + fixture.state.handleProgramStatus(report(.working, app: "pi"), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .pi) + } + @Test func unprobedBindingNeedsAVerifiedReportFromThisSSH() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 1d0ef48b..4cf0762a 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -254,8 +254,12 @@ degraded OSC-only binding. report (OSC has no heartbeat). `RemoteAgentContext.isUnprobed` marks it heuristic. Once the pane is mapped (`correlated`), the probed path takes over. A later verified producer in the same `ssh` (Claude exits, Pi starts) replaces the binding. Only the bound producer's own - report confirms it. Records count from the `ssh` start, raised to the last time a local - agent was seen on the surface when it ran while the `ssh` was suspended (review round 1). + report confirms it. Records count from the `ssh` start, raised to the start of the + `ssh`'s current foreground interval and to the retained successor fence. A tracker that + takes over an `ssh` an earlier tracker followed means the `ssh` left the foreground (a + local agent ran while it was suspended) or the pane was closed and restored. Records from + before that interval, or ones an earlier binding excluded, never acquire or confirm the + agent (review rounds 1–2). - **Measured** in a Debug instance with the flag on. `ssh -F /dev/null -t mini` skips the system `SendEnv`, so the host shows the session as pane `-` and the pane is `unmapped`. - Claude Code bound through `osc.idle` 1.49 s after the command, then went -- 2.54.0 From 58264ff58559e1faa0a9ca8b02f34f8ecf7f20f8 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 02:37:42 +0900 Subject: [PATCH 19/51] Keep the degraded binding across Undo and probe gaps The fence history records whether the ssh left the foreground; only then does the next tracker start a new interval. A closed and restored pane keeps its interval and its accepted degraded agent. A tick whose local probe reads no job keeps the tracker. --- .../BusinessLogic/RemotePaneTracker.swift | 48 +++++++++++++---- ...WorktreeTerminalState+AgentDetection.swift | 17 ++++-- App/Tests/RemoteAgentDetectionTests.swift | 54 ++++++++++++++++++- docs-ai/082-ssh-remote-agents/001-action.md | 13 +++-- 4 files changed, 111 insertions(+), 21 deletions(-) diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index 31ba1a82..75a45fac 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -9,11 +9,30 @@ nonisolated struct RemoteFenceHistory: Equatable, Sendable { /// The agent the fence belongs to. private(set) var process: ProcessRef? private(set) var fence: Date? + /// When the `ssh`'s current foreground interval began; nil for the first one, which + /// counts from the `ssh` start. + private(set) var foregroundSince: Date? + /// The `ssh` left the foreground (a local agent took over); the next tracker starts a new + /// interval. A closed and restored pane keeps its interval. + var leftForeground = false + /// The accepted degraded binding (L0 item 9), restored with the pane. + var unprobedAgent: DetectedAgent? init(ssh: LocalProcessGeneration) { self.ssh = ssh } + /// The history a new tracker continues with: a new foreground interval, without the + /// degraded binding, when the `ssh` left the foreground in between. + func resumed(at now: Date) -> RemoteFenceHistory { + guard leftForeground else { return self } + var next = self + next.foregroundSince = now + next.leftForeground = false + next.unprobedAgent = nil + return next + } + mutating func bind(_ process: ProcessRef, confirmedAt now: Date) { guard process != self.process else { return } fence = self.process == nil && fence == nil ? ssh.startedAt : now @@ -144,10 +163,6 @@ final class RemotePaneTracker { private var presence = RemoteProcessPresence() /// The OSC fence of this `ssh` generation; it outlives the tracker (close and Undo). private(set) var fenceHistory: RemoteFenceHistory - /// When this tracker took over an `ssh` that an earlier tracker followed: the `ssh` left - /// the foreground (a local agent ran while it was suspended) or the pane was closed and - /// restored. Records from before it are not this foreground interval's. - let resumedAt: Date? init( surfaceID: UUID, @@ -163,8 +178,7 @@ final class RemotePaneTracker { self.ssh = ssh self.invocation = invocation let retained = fenceHistory.flatMap { $0.ssh == ssh ? $0 : nil } - self.fenceHistory = retained ?? RemoteFenceHistory(ssh: ssh) - resumedAt = retained == nil ? nil : now + self.fenceHistory = retained?.resumed(at: now) ?? RemoteFenceHistory(ssh: ssh) self.source = source self.uptime = uptime self.onSnapshot = onSnapshot @@ -238,7 +252,14 @@ final class RemotePaneTracker { verdict = consume(latest, report: report, now: now) } // A probed session supersedes the unprobed binding: its own evidence decides from here. - if correlation == .correlated { unprobed = nil } + if correlation == .correlated { + unprobed = nil + fenceHistory.unprobedAgent = nil + } + // A restored pane resumes the binding it had accepted. + if unprobed == nil, bound == nil, let agent = fenceHistory.unprobedAgent, correlation != .correlated { + unprobed = makeUnprobed(agent) + } if bound == nil, let unprobed { return Observation( correlation: correlation, connection: lease?.connection, identified: unprobed, verdict: nil, @@ -263,19 +284,24 @@ final class RemotePaneTracker { /// foreground interval's start when the `ssh` was resumed, and to the retained successor /// fence, so a record that an earlier binding of this `ssh` excluded never comes back. var unprobedRecordFence: Date { - [ssh.startedAt, resumedAt, fenceHistory.fence].compactMap(\.self).max() ?? ssh.startedAt + [ssh.startedAt, fenceHistory.foregroundSince, fenceHistory.fence].compactMap(\.self).max() ?? ssh.startedAt } /// Binds `agent` from its OSC root record (L0 item 9). The reference names this `ssh`, /// never a remote pid. func bindUnprobed(_ agent: DetectedAgent) -> IdentifiedAgentProcess { if let unprobed, unprobed.agent == agent { return unprobed } - let identified = IdentifiedAgentProcess( + let identified = makeUnprobed(agent) + unprobed = identified + fenceHistory.unprobedAgent = agent + return identified + } + + private func makeUnprobed(_ agent: DetectedAgent) -> IdentifiedAgentProcess { + IdentifiedAgentProcess( agent: agent, name: agent.iconLookupToken, process: ForegroundProcess( ref: .unprobedRemote(ssh), parentPID: nil, name: agent.iconLookupToken, argv0: nil, cmdline: nil)) - unprobed = identified - return identified } /// Applies a new snapshot to the binding and returns its verdict for the bound agent. diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index d51e0c33..433932e0 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -319,7 +319,17 @@ extension WorktreeTerminalState { private func observeRemoteAgent( job: ForegroundJob?, localIdentified: IdentifiedAgentProcess?, surfaceID: UUID, now: Date ) -> RemotePaneTracker.Observation? { - guard remoteAgentDetectionEnabled, localIdentified == nil, let ssh = job.flatMap(Self.foregroundSSH) else { + guard remoteAgentDetectionEnabled else { + retireRemotePaneTracker(surfaceID: surfaceID) + return nil + } + // A probe gap (no job read) is not evidence that the `ssh` left the foreground. + if job == nil, let tracker = remotePaneTrackers[surfaceID] { + return tracker.observe(active: true, now: now) + } + guard localIdentified == nil, let ssh = job.flatMap(Self.foregroundSSH) else { + // The `ssh` left the foreground: its next tracker starts a new interval. + remoteFenceHistoryBySurface[surfaceID]?.leftForeground = true retireRemotePaneTracker(surfaceID: surfaceID) return nil } @@ -342,9 +352,10 @@ extension WorktreeTerminalState { } let active = surfaceAgentStates[surfaceID]?.detectedAgent != nil || isAgentDetectionSettling(surfaceID: surfaceID, now: now) - var observation = tracker.observe(active: active, now: now) + let observation = bindUnprobedProducer( + observation: tracker.observe(active: active, now: now), tracker: tracker, surfaceID: surfaceID) remoteFenceHistoryBySurface[surfaceID] = tracker.fenceHistory - return bindUnprobedProducer(observation: observation, tracker: tracker, surfaceID: surfaceID) + return observation } /// The degraded OSC-only binding (docs-ai 082, L0 item 9): with no probed agent, a diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 28d1e3a5..9157a946 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -112,6 +112,13 @@ struct RemoteAgentDetectionTests { return Fixture(state: state, tabID: tabID, surface: surface, source: source) } + /// The pane's shell in front again: what the probe reads once the `ssh` exited. + private var shellJob: ForegroundJob { + ForegroundJob( + processGroupID: getpid(), + processes: [ForegroundProcess(pid: getpid(), parentProcessID: 1, name: "zsh", argv0: "zsh", cmdline: "zsh")]) + } + private var sshJob: ForegroundJob { ForegroundJob( processGroupID: getpid(), @@ -560,7 +567,7 @@ struct RemoteAgentDetectionTests { let probe = Probe() let fixture = makeFixture(probe: probe) await bindClaude(fixture, probe: probe) - probe.job = nil + probe.job = shellJob await detect(fixture) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) fixture.source.push(snapshot(9, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) @@ -707,7 +714,7 @@ struct RemoteAgentDetectionTests { #expect(removed.isEmpty) #expect(emitted.last?.remote?.isStale == true) // Only the `ssh` exit releases it. - probe.job = nil + probe.job = shellJob await detect(fixture) #expect(removed == [fixture.surfaceID]) } @@ -800,6 +807,49 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .pi) } + @Test func anUndoneCloseRestoresAQuietUnprobedBinding() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var emitted: [ActiveAgentEntry] = [] + fixture.state.onAgentEntryChanged = { emitted.append($0) } + fixture.source.failNext(100) + probe.job = sshJob + await detect(fixture, screen: .unknown) + var tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + fixture.state.handleProgramStatus(report(.blocked), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) + // Close (retained for undo) and undo; the producer stays quiet. + fixture.state.retireRemotePaneTracker(surfaceID: fixture.surfaceID) + fixture.state.surfaceAgentStates[fixture.surfaceID] = PaneAgentState(lastChangedAt: Date()) + await detect(fixture, screen: .unknown) + tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + await detect(fixture, screen: .unknown) + let pane = fixture.state.surfaceAgentStates[fixture.surfaceID] + #expect(pane?.agentProcess == .unprobedRemote(sshGeneration)) + #expect(pane?.detectedAgent == .claude) + #expect(pane?.state == .blocked) + #expect(emitted.last?.remote?.isStale == false) + } + + @Test func aProbeGapKeepsTheRemoteBinding() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var removed: [UUID] = [] + fixture.state.onAgentEntryRemoved = { removed.append($0) } + await bindClaude(fixture, probe: probe) + // The local probe reads nothing for a tick. + probe.job = nil + await detect(fixture) + probe.job = sshJob + await detect(fixture) + #expect(removed.isEmpty) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + #expect(fixture.source.recipes.count == 1) + } + @Test func unprobedBindingNeedsAVerifiedReportFromThisSSH() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 4cf0762a..eecb47dd 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -255,11 +255,14 @@ degraded OSC-only binding. pane is mapped (`correlated`), the probed path takes over. A later verified producer in the same `ssh` (Claude exits, Pi starts) replaces the binding. Only the bound producer's own report confirms it. Records count from the `ssh` start, raised to the start of the - `ssh`'s current foreground interval and to the retained successor fence. A tracker that - takes over an `ssh` an earlier tracker followed means the `ssh` left the foreground (a - local agent ran while it was suspended) or the pane was closed and restored. Records from - before that interval, or ones an earlier binding excluded, never acquire or confirm the - agent (review rounds 1–2). + `ssh`'s current foreground interval and to the retained successor fence. The interval + starts again only when the `ssh` left the foreground (a local agent took over, so the + fence history is marked `leftForeground`). A closed and restored pane keeps its interval + and its accepted degraded agent, so a quiet producer is listed again after Undo. Records + from before that interval, or ones an earlier binding excluded, never acquire or confirm + the agent (review rounds 1–3). +- **Probe gaps.** A tick whose local probe reads no job keeps the tracker and its binding; + only a job without the `ssh` (or with a local agent) retires it (review round 3). - **Measured** in a Debug instance with the flag on. `ssh -F /dev/null -t mini` skips the system `SendEnv`, so the host shows the session as pane `-` and the pane is `unmapped`. - Claude Code bound through `osc.idle` 1.49 s after the command, then went -- 2.54.0 From 70c5cdbcdb285a0b563c84d4d4cb1c523add1649 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 02:47:35 +0900 Subject: [PATCH 20/51] Treat an empty local probe as a gap on every path A tick whose local probe reads no job never ends the ssh's foreground interval. An existing tracker observes through the shared path (degraded binding, confirmation, history write-back); without one, nothing changes until a real sample. --- ...WorktreeTerminalState+AgentDetection.swift | 57 +++++++++++-------- App/Tests/RemoteAgentDetectionTests.swift | 52 +++++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 7 ++- 3 files changed, 90 insertions(+), 26 deletions(-) diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 433932e0..268dced6 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -323,32 +323,20 @@ extension WorktreeTerminalState { retireRemotePaneTracker(surfaceID: surfaceID) return nil } - // A probe gap (no job read) is not evidence that the `ssh` left the foreground. - if job == nil, let tracker = remotePaneTrackers[surfaceID] { - return tracker.observe(active: true, now: now) - } - guard localIdentified == nil, let ssh = job.flatMap(Self.foregroundSSH) else { - // The `ssh` left the foreground: its next tracker starts a new interval. - remoteFenceHistoryBySurface[surfaceID]?.leftForeground = true - retireRemotePaneTracker(surfaceID: surfaceID) - return nil - } let tracker: RemotePaneTracker - if let existing = remotePaneTrackers[surfaceID], existing.ssh == ssh.generation { - tracker = existing - } else { - retireRemotePaneTracker(surfaceID: surfaceID) - tracker = RemotePaneTracker( - surfaceID: surfaceID, ssh: ssh.generation, - invocation: SSHInvocation.parse( - ssh.arguments, workingDirectory: ProcessDetection.currentDirectory(pid: ssh.generation.pid)), - fenceHistory: remoteFenceHistoryBySurface[surfaceID], - source: remoteSnapshotSourceForTesting ?? .live - ) { [weak self] in - self?.wakeAgentDetectionForRemoteSnapshot(surfaceID: surfaceID) + if let job { + guard localIdentified == nil, let ssh = Self.foregroundSSH(job) else { + // The `ssh` left the foreground: its next tracker starts a new interval. + remoteFenceHistoryBySurface[surfaceID]?.leftForeground = true + retireRemotePaneTracker(surfaceID: surfaceID) + return nil } - remotePaneTrackers[surfaceID] = tracker - tracker.start() + tracker = remotePaneTracker(surfaceID: surfaceID, ssh: ssh) + } else { + // A probe gap (no job read) is not evidence of a foreground change: an existing + // tracker keeps observing, and a missing one waits for the next real sample. + guard let existing = remotePaneTrackers[surfaceID] else { return nil } + tracker = existing } let active = surfaceAgentStates[surfaceID]?.detectedAgent != nil || isAgentDetectionSettling(surfaceID: surfaceID, now: now) @@ -395,6 +383,27 @@ extension WorktreeTerminalState { return support(agent) == .verified ? agent : nil } + /// The pane's tracker for `ssh`, created (with the retained fence history) when the pane + /// has none for that generation. + private func remotePaneTracker( + surfaceID: UUID, ssh: (generation: LocalProcessGeneration, arguments: [String]) + ) -> RemotePaneTracker { + if let existing = remotePaneTrackers[surfaceID], existing.ssh == ssh.generation { return existing } + retireRemotePaneTracker(surfaceID: surfaceID) + let tracker = RemotePaneTracker( + surfaceID: surfaceID, ssh: ssh.generation, + invocation: SSHInvocation.parse( + ssh.arguments, workingDirectory: ProcessDetection.currentDirectory(pid: ssh.generation.pid)), + fenceHistory: remoteFenceHistoryBySurface[surfaceID], + source: remoteSnapshotSourceForTesting ?? .live + ) { [weak self] in + self?.wakeAgentDetectionForRemoteSnapshot(surfaceID: surfaceID) + } + remotePaneTrackers[surfaceID] = tracker + tracker.start() + return tracker + } + /// The terminal's own foreground `ssh` and its arguments (without `argv[0]`), with the /// generation that its tracker belongs to. An `ssh` that another `ssh` of the job started /// (the `-W` transport of `-J`, or one under a `ProxyCommand` shell) is never it. diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 9157a946..d75c475e 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -834,6 +834,58 @@ struct RemoteAgentDetectionTests { #expect(emitted.last?.remote?.isStale == false) } + @Test func aProbeGapRightAfterUndoNeverEndsTheForegroundInterval() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + fixture.source.failNext(100) + probe.job = sshJob + await detect(fixture, screen: .unknown) + var tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + fixture.state.handleProgramStatus(report(.blocked), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + fixture.state.retireRemotePaneTracker(surfaceID: fixture.surfaceID) + fixture.state.surfaceAgentStates[fixture.surfaceID] = PaneAgentState(lastChangedAt: Date()) + // The first sample after the restore reads no job. + probe.job = nil + await detect(fixture, screen: .unknown) + #expect(fixture.state.remoteFenceHistoryBySurface[fixture.surfaceID]?.leftForeground == false) + probe.job = sshJob + await detect(fixture, screen: .unknown) + tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .blocked) + } + + @Test func aProbeGapKeepsTheDegradedConfirmation() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var emitted: [ActiveAgentEntry] = [] + fixture.state.onAgentEntryChanged = { emitted.append($0) } + fixture.source.failNext(100) + probe.job = sshJob + await detect(fixture, screen: .unknown) + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + fixture.state.handleProgramStatus(report(.idle), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + #expect(emitted.last?.remote?.isStale == false) + // A tick whose local probe reads nothing keeps the binding and its fresh confirmation. + probe.job = nil + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.remote?.isStale == false) + // Stale only once the report is older than ten seconds. + await detect(fixture, screen: .unknown, now: Date().addingTimeInterval(11)) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.remote?.isStale == true) + // A newer eligible report during the gap refreshes it. + fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.remote?.isStale == false) + } + @Test func aProbeGapKeepsTheRemoteBinding() async { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index eecb47dd..8b2d18a4 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -261,8 +261,11 @@ degraded OSC-only binding. and its accepted degraded agent, so a quiet producer is listed again after Undo. Records from before that interval, or ones an earlier binding excluded, never acquire or confirm the agent (review rounds 1–3). -- **Probe gaps.** A tick whose local probe reads no job keeps the tracker and its binding; - only a job without the `ssh` (or with a local agent) retires it (review round 3). +- **Probe gaps.** A tick whose local probe reads no job is not a foreground change. An + existing tracker observes through the same path (degraded binding, confirmation, history + write-back); without one, nothing changes until a real sample. Only a job without the + `ssh`, or with a local agent, retires the tracker and starts a new interval (review rounds + 3–4). - **Measured** in a Debug instance with the flag on. `ssh -F /dev/null -t mini` skips the system `SendEnv`, so the host shows the session as pane `-` and the pane is `unmapped`. - Claude Code bound through `osc.idle` 1.49 s after the command, then went -- 2.54.0 From 3d3e2b8b33cfe09e99984a741a8bf7eeeb7328ee Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 02:59:54 +0900 Subject: [PATCH 21/51] Use the tracker's foreground-interval fence for remote OSC state The coordinator's remote fence now comes from the tracker: the degraded record fence, or the successor fence raised to the ssh's foreground interval. The origin-raise based on last-seen times is removed, so the record that acquires a degraded binding also decides its state. --- .../BusinessLogic/RemotePaneTracker.swift | 9 ++++- ...WorktreeTerminalState+AgentDetection.swift | 2 +- .../WorktreeTerminalState+ProgramStatus.swift | 33 ++++--------------- App/Tests/RemoteAgentDetectionTests.swift | 15 +++++++-- docs-ai/082-ssh-remote-agents/001-action.md | 16 ++++++--- 5 files changed, 38 insertions(+), 37 deletions(-) diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index 75a45fac..c7092cac 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -267,7 +267,14 @@ final class RemotePaneTracker { } return Observation( correlation: correlation, connection: lease?.connection, identified: bound, verdict: verdict, - programStatusFence: fenceHistory.fence, confirmedAt: confirmedAt) + programStatusFence: boundRecordFence, confirmedAt: confirmedAt) + } + + /// The arrival fence of a probed agent: its successor fence, raised to the start of the + /// `ssh`'s current foreground interval, so a local agent run while the `ssh` was + /// suspended never decides for the resumed remote one. + var boundRecordFence: Date? { + [fenceHistory.fence, fenceHistory.foregroundSince].compactMap(\.self).max() } /// Whether a verified producer's report may stand in for a probed agent: the host cannot diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 268dced6..f8aa6c15 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -164,7 +164,7 @@ extension WorktreeTerminalState { let coordinator = agentDetectionCoordinators[surfaceID] ?? makeAgentDetectionCoordinator(surfaceID: surfaceID) agentDetectionCoordinators[surfaceID] = coordinator coordinator.programStatusOriginFence = originFence - coordinator.remoteProgramStatusFence = remoteProgramStatusFence(remote?.programStatusFence, surfaceID: surfaceID) + coordinator.remoteProgramStatusFence = remote?.programStatusFence let process = Self.processGeneration(identified) // The tick is delegated only while the schedule says so (a key press puts one full // tick back on the active schedule and keeps it there for the settle window) and the diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift index b306c80a..d9474bba 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift @@ -13,10 +13,6 @@ struct ProgramStatusOrigin: Equatable { let origin: ProcessOrigin /// When this origin replaced another one; nil for the surface's first origin. let since: Date? - /// The last probe that saw an agent of this origin. - var lastSeenAt: Date? - /// When the origin this one replaced was last seen. - var departedLastSeenAt: Date? } extension WorktreeTerminalState { @@ -128,34 +124,17 @@ extension WorktreeTerminalState { /// the change returned, so reports that arrived before that confirmation (also those /// that landed while the probe ran) are left out; the screen decides until the next report. func noteAgentOrigin(_ identified: IdentifiedAgentProcess?, surfaceID: UUID, now: Date) -> Date? { - guard var previous = programStatusOriginBySurface[surfaceID] else { - guard let origin = identified?.process.ref.origin else { return nil } - programStatusOriginBySurface[surfaceID] = ProgramStatusOrigin(origin: origin, since: nil, lastSeenAt: now) + let previous = programStatusOriginBySurface[surfaceID] + guard let origin = identified?.process.ref.origin else { return previous?.since } + guard let previous else { + programStatusOriginBySurface[surfaceID] = ProgramStatusOrigin(origin: origin, since: nil) return nil } - guard let origin = identified?.process.ref.origin else { return previous.since } - guard previous.origin != origin else { - previous.lastSeenAt = now - programStatusOriginBySurface[surfaceID] = previous - return previous.since - } - programStatusOriginBySurface[surfaceID] = ProgramStatusOrigin( - origin: origin, since: now, lastSeenAt: now, departedLastSeenAt: previous.lastSeenAt) + guard previous.origin != origin else { return previous.since } + programStatusOriginBySurface[surfaceID] = ProgramStatusOrigin(origin: origin, since: now) return now } - /// The fence for a bound remote agent: the tracker's, raised to the moment the pane - /// switched to this remote origin when the origin it left was seen after the `ssh` - /// started (a suspended `ssh` with a local agent run in between). An agent that ran before - /// the `ssh` leaves the first remote agent's early reports alone. - func remoteProgramStatusFence(_ fence: Date?, surfaceID: UUID) -> Date? { - guard let fence, let origin = programStatusOriginBySurface[surfaceID], origin.origin != .local, - let since = origin.since, let departed = origin.departedLastSeenAt, - let sshStart = remotePaneTrackers[surfaceID]?.ssh.startedAt, departed > sshStart - else { return fence } - return max(fence, since) - } - /// New producers show up in dogfooding logs: a root record whose `app` is not the /// bound agent's is reported once per surface and app. A known producer's record /// left on a pane with no agent (its process exited, or the probe has not bound it diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index d75c475e..751fb646 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -773,10 +773,13 @@ struct RemoteAgentDetectionTests { // becomes a remote agent. #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess != .unprobedRemote(sshGeneration)) // A report from the remote side after the resume binds. - fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) + fixture.state.handleProgramStatus(report(.blocked), surfaceID: fixture.surfaceID) await detect(fixture, screen: .unknown) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .blocked) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .blocked) } @Test func aDegradedReacquisitionKeepsTheRetainedSuccessorFence() async throws { @@ -805,6 +808,10 @@ struct RemoteAgentDetectionTests { await detect(fixture, screen: .unknown) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .pi) + // The report that bound the agent also decides its state, now and on later ticks. + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.decision?.reason.identifier == "osc.working") + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .working) } @Test func anUndoneCloseRestoresAQuietUnprobedBinding() async throws { @@ -933,10 +940,12 @@ struct RemoteAgentDetectionTests { fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) await detect(fixture) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) - // The pane is mapped now and shows the agent: the probed reference takes over. + // The pane is mapped now and shows the agent: the probed reference takes over and keeps + // the report the degraded binding accepted. await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) - await detect(fixture) + await detect(fixture, screen: .unknown) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.decision?.reason.identifier == "osc.working") } @Test func unsupportedArgvNeverConnects() async throws { diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 8b2d18a4..756a0d5a 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -177,11 +177,11 @@ the degraded OSC-only binding and admission refusal. Both stay behind `ssh` start; a successor counts only reports after the snapshot that confirmed it, timestamped after the tick's local probe returns (review round 1). The fence belongs to the `ssh` generation and lives with the surface's record store (`remoteFenceHistoryBySurface`), - so a tracker recreated by an undone close never moves it back (review round 2). The surface - origin record also keeps when each origin was last seen. When the pane returns to a remote - origin and the origin it left was seen after the `ssh` started, the remote fence rises to - the switch, so a local agent run while the `ssh` was suspended cannot decide for the - resumed remote one (review round 4). The S1 + so a tracker recreated by an undone close never moves it back (review round 2). A local + agent run while the `ssh` was suspended cannot decide for the resumed remote one (review + round 4). Since S3c, this rests on the fence history's foreground interval: a probed agent's + fence is the later of its successor fence and the start of the `ssh`'s current foreground + interval. The S1 origin fence applies to local generations only. - **Measured** in a Debug instance with the flag on: - mini (macOS 26.5), Claude Code 2.1.302 started by hand: appeared 0.62 s after the @@ -261,6 +261,12 @@ degraded OSC-only binding. and its accepted degraded agent, so a quiet producer is listed again after Undo. Records from before that interval, or ones an earlier binding excluded, never acquire or confirm the agent (review rounds 1–3). +- **One boundary.** The coordinator's state fence for a remote agent is the tracker's: + `unprobedRecordFence` for a degraded binding, and the successor fence raised to the + foreground interval for a probed one. The S3a origin-raise (last-seen times on the + surface's origin record) is removed, so the record that acquires a degraded binding also + decides its state. A degraded binding taken over by a probed one keeps the report it + accepted (follow-up review). - **Probe gaps.** A tick whose local probe reads no job is not a foreground change. An existing tracker observes through the same path (degraded binding, confirmation, history write-back); without one, nothing changes until a real sample. Only a job without the -- 2.54.0 From f1eb36c74a22ad1434473dbc8c664af8581ce539 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 03:10:27 +0900 Subject: [PATCH 22/51] Keep a fence history per ssh client of a pane One pane can hold several suspended ssh clients. Keep each generation's history so switching back to a client starts its own foreground interval, and drop the histories of clients that exited. --- ...WorktreeTerminalState+AgentDetection.swift | 20 +++++++-- .../Models/WorktreeTerminalState.swift | 7 +-- App/Tests/RemoteAgentDetectionTests.swift | 45 ++++++++++++++++++- docs-ai/082-ssh-remote-agents/001-action.md | 4 ++ 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index f8aa6c15..4d6b9f9d 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -327,7 +327,9 @@ extension WorktreeTerminalState { if let job { guard localIdentified == nil, let ssh = Self.foregroundSSH(job) else { // The `ssh` left the foreground: its next tracker starts a new interval. - remoteFenceHistoryBySurface[surfaceID]?.leftForeground = true + if let ssh = remotePaneTrackers[surfaceID]?.ssh { + remoteFenceHistoryBySurface[surfaceID]?[ssh]?.leftForeground = true + } retireRemotePaneTracker(surfaceID: surfaceID) return nil } @@ -342,10 +344,22 @@ extension WorktreeTerminalState { surfaceAgentStates[surfaceID]?.detectedAgent != nil || isAgentDetectionSettling(surfaceID: surfaceID, now: now) let observation = bindUnprobedProducer( observation: tracker.observe(active: active, now: now), tracker: tracker, surfaceID: surfaceID) - remoteFenceHistoryBySurface[surfaceID] = tracker.fenceHistory + storeFenceHistory(tracker.fenceHistory, surfaceID: surfaceID) return observation } + /// Keeps `history` for its `ssh` and drops the histories of `ssh` clients that exited. + private func storeFenceHistory(_ history: RemoteFenceHistory, surfaceID: UUID) { + var histories = remoteFenceHistoryBySurface[surfaceID] ?? [:] + histories[history.ssh] = history + if histories.count > 1 { + histories = histories.filter { ssh, _ in + ssh == history.ssh || ProcessDetection.processStartDate(pid: ssh.pid) == ssh.startedAt + } + } + remoteFenceHistoryBySurface[surfaceID] = histories + } + /// The degraded OSC-only binding (docs-ai 082, L0 item 9): with no probed agent, a /// verified producer's root record identifies the agent, and a later verified producer /// replaces it. Records before the tracker's `unprobedRecordFence` never acquire or @@ -394,7 +408,7 @@ extension WorktreeTerminalState { surfaceID: surfaceID, ssh: ssh.generation, invocation: SSHInvocation.parse( ssh.arguments, workingDirectory: ProcessDetection.currentDirectory(pid: ssh.generation.pid)), - fenceHistory: remoteFenceHistoryBySurface[surfaceID], + fenceHistory: remoteFenceHistoryBySurface[surfaceID]?[ssh.generation], source: remoteSnapshotSourceForTesting ?? .live ) { [weak self] in self?.wakeAgentDetectionForRemoteSnapshot(surfaceID: surfaceID) diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift index 86a3ef24..4af247c8 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift @@ -245,9 +245,10 @@ final class WorktreeTerminalState { @ObservationIgnored var programStatusUnmappedAppsLoggedBySurface: [UUID: Set] = [:] /// One tracker per pane whose foreground is an `ssh` (docs-ai 082, L0). @ObservationIgnored var remotePaneTrackers: [UUID: RemotePaneTracker] = [:] - /// The remote OSC fence per surface, kept as long as the surface's record store so an - /// undone close restores it with the records it fences. - @ObservationIgnored var remoteFenceHistoryBySurface: [UUID: RemoteFenceHistory] = [:] + /// The remote OSC fence per surface and `ssh` generation (a pane can hold several + /// suspended `ssh` clients), kept as long as the surface's record store so an undone close + /// restores it with the records it fences. + @ObservationIgnored var remoteFenceHistoryBySurface: [UUID: [LocalProcessGeneration: RemoteFenceHistory]] = [:] @ObservationIgnored var remoteSnapshotSourceForTesting: RemoteSnapshotSource? @ObservationIgnored var remoteAgentDetectionEnabledForTesting: Bool? var remoteAgentDetectionEnabled: Bool { diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 751fb646..bd0febb4 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -856,7 +856,7 @@ struct RemoteAgentDetectionTests { // The first sample after the restore reads no job. probe.job = nil await detect(fixture, screen: .unknown) - #expect(fixture.state.remoteFenceHistoryBySurface[fixture.surfaceID]?.leftForeground == false) + #expect(fixture.state.remoteFenceHistoryBySurface[fixture.surfaceID]?[sshGeneration]?.leftForeground == false) probe.job = sshJob await detect(fixture, screen: .unknown) tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) @@ -893,6 +893,49 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.remote?.isStale == false) } + @Test func switchingBetweenSuspendedSSHClientsKeepsEachForegroundInterval() async throws { + // A second real process stands in for the other `ssh` client, so it has a generation. + let other = Process() + other.executableURL = URL(fileURLWithPath: "/bin/sleep") + other.arguments = ["30"] + try other.run() + defer { other.terminate() } + let sshB = ForegroundJob( + processGroupID: other.processIdentifier, + processes: [ + ForegroundProcess( + pid: other.processIdentifier, parentProcessID: 1, name: "ssh", argv0: "ssh", cmdline: "ssh box", + arguments: ["ssh", "box"]) + ]) + let probe = Probe() + let fixture = makeFixture(probe: probe) + fixture.source.failNext(100) + // `ssh` A runs, then is suspended (the shell is in front). + probe.job = sshJob + await detect(fixture, screen: .unknown) + probe.job = shellJob + await detect(fixture, screen: .unknown) + // `ssh` B runs and its Pi reports; then B is suspended. + probe.job = sshB + await detect(fixture, screen: .unknown) + fixture.state.handleProgramStatus(report(.idle, app: "pi"), surfaceID: fixture.surfaceID) + probe.job = shellJob + await detect(fixture, screen: .unknown) + // A is in front again and cannot be probed: B's report is not A's agent. + probe.job = sshJob + await detect(fixture, screen: .unknown) + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + #expect(tracker.ssh == sshGeneration) + await settle { tracker.correlation == .probeUnavailable(nil) } + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess != .unprobedRemote(sshGeneration)) + // A report from A's own interval binds. + fixture.state.handleProgramStatus(report(.working), surfaceID: fixture.surfaceID) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == .unprobedRemote(sshGeneration)) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) + } + @Test func aProbeGapKeepsTheRemoteBinding() async { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 756a0d5a..ec3eda21 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -261,6 +261,10 @@ degraded OSC-only binding. and its accepted degraded agent, so a quiet producer is listed again after Undo. Records from before that interval, or ones an earlier binding excluded, never acquire or confirm the agent (review rounds 1–3). +- **Per `ssh` client.** Fence histories are kept per surface and `ssh` generation, because + one pane can hold several suspended `ssh` clients; histories of exited clients are + dropped. Switching back to a suspended client starts its own new foreground interval, so + another client's records never bind or confirm its agent (follow-up review round 2). - **One boundary.** The coordinator's state fence for a remote agent is the tracker's: `unprobedRecordFence` for a degraded binding, and the successor fence raised to the foreground interval for a probed one. The S3a origin-raise (last-seen times on the -- 2.54.0 From 344a0d2fc42a058a198e064a04daaa1e3fbd2c54 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 03:19:46 +0900 Subject: [PATCH 23/51] End the previous ssh's interval when another ssh is in front A direct switch between two suspended ssh clients, with no shell sample in between, also ends the previous client's foreground interval. --- .../WorktreeTerminalState+AgentDetection.swift | 6 +++++- App/Tests/RemoteAgentDetectionTests.swift | 17 +++++++++++------ docs-ai/082-ssh-remote-agents/001-action.md | 5 +++-- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 4d6b9f9d..4ff160a6 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -402,7 +402,11 @@ extension WorktreeTerminalState { private func remotePaneTracker( surfaceID: UUID, ssh: (generation: LocalProcessGeneration, arguments: [String]) ) -> RemotePaneTracker { - if let existing = remotePaneTrackers[surfaceID], existing.ssh == ssh.generation { return existing } + if let existing = remotePaneTrackers[surfaceID] { + if existing.ssh == ssh.generation { return existing } + // Another `ssh` is in front: the previous one left the foreground. + remoteFenceHistoryBySurface[surfaceID]?[existing.ssh]?.leftForeground = true + } retireRemotePaneTracker(surfaceID: surfaceID) let tracker = RemotePaneTracker( surfaceID: surfaceID, ssh: ssh.generation, diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index bd0febb4..888e968d 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -893,7 +893,8 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.remote?.isStale == false) } - @Test func switchingBetweenSuspendedSSHClientsKeepsEachForegroundInterval() async throws { + @Test(arguments: [true, false]) + func switchingBetweenSuspendedSSHClientsKeepsEachForegroundInterval(sampleShell: Bool) async throws { // A second real process stands in for the other `ssh` client, so it has a generation. let other = Process() other.executableURL = URL(fileURLWithPath: "/bin/sleep") @@ -910,17 +911,21 @@ struct RemoteAgentDetectionTests { let probe = Probe() let fixture = makeFixture(probe: probe) fixture.source.failNext(100) - // `ssh` A runs, then is suspended (the shell is in front). + // `ssh` A runs, then is suspended (the shell is in front, sampled or not). probe.job = sshJob await detect(fixture, screen: .unknown) - probe.job = shellJob - await detect(fixture, screen: .unknown) + if sampleShell { + probe.job = shellJob + await detect(fixture, screen: .unknown) + } // `ssh` B runs and its Pi reports; then B is suspended. probe.job = sshB await detect(fixture, screen: .unknown) fixture.state.handleProgramStatus(report(.idle, app: "pi"), surfaceID: fixture.surfaceID) - probe.job = shellJob - await detect(fixture, screen: .unknown) + if sampleShell { + probe.job = shellJob + await detect(fixture, screen: .unknown) + } // A is in front again and cannot be probed: B's report is not A's agent. probe.job = sshJob await detect(fixture, screen: .unknown) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index ec3eda21..8039d7ee 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -263,8 +263,9 @@ degraded OSC-only binding. the agent (review rounds 1–3). - **Per `ssh` client.** Fence histories are kept per surface and `ssh` generation, because one pane can hold several suspended `ssh` clients; histories of exited clients are - dropped. Switching back to a suspended client starts its own new foreground interval, so - another client's records never bind or confirm its agent (follow-up review round 2). + dropped. Another client in front (sampled directly, or after a shell sample) ends the + previous client's interval, so switching back starts a new one and another client's + records never bind or confirm its agent (follow-up reviews). - **One boundary.** The coordinator's state fence for a remote agent is the tracker's: `unprobedRecordFence` for a degraded binding, and the successor fence raised to the foreground interval for a probed one. The S3a origin-raise (last-seen times on the -- 2.54.0 From b8e1e1227753832d2101691c1a8e862e83077d8f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 03:40:22 +0900 Subject: [PATCH 24/51] Show the host and connection health of remote agents Active Agents rows show user@host and, apart from the agent's state, its connection health (status unknown, last seen, unavailable) with a reason in the tooltip. prowl agents --json gains a remote object with host, correlation, confidence, freshness and state_known. --- .../CLIService/AgentsCommandHandler.swift | 42 +++++++++- .../Models/RemoteAgentPresentation.swift | 82 +++++++++++++++++++ .../ActiveAgents/Views/ActiveAgentRow.swift | 57 +++++++++++-- .../Views/ActiveAgentRowSupport.swift | 7 +- App/Tests/CLIAgentsCommandHandlerTests.swift | 38 +++++++++ App/Tests/RemoteAgentPresentationTests.swift | 58 +++++++++++++ .../Resources/cli-output-schema.json | 62 ++++++++++++++ CLI/Sources/prowl/Output/OutputRenderer.swift | 13 +++ .../AgentReadOutputRendererTests.swift | 12 +++ .../AgentReasonSchemaTests.swift | 29 +++++++ .../ProwlCLIShared/AgentsCommandPayload.swift | 65 ++++++++++++++- docs-ai/082-ssh-remote-agents/001-action.md | 35 +++++++- docs/components/agent-detection.md | 34 ++++++++ docs/components/cli.md | 19 +++++ skills/prowl-cli/SKILL.md | 2 +- 15 files changed, 540 insertions(+), 15 deletions(-) create mode 100644 App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift create mode 100644 App/Tests/RemoteAgentPresentationTests.swift diff --git a/App/Sources/CLIService/AgentsCommandHandler.swift b/App/Sources/CLIService/AgentsCommandHandler.swift index 5c3932d3..813377d0 100644 --- a/App/Sources/CLIService/AgentsCommandHandler.swift +++ b/App/Sources/CLIService/AgentsCommandHandler.swift @@ -149,13 +149,53 @@ final class AgentsCommandHandler: CommandHandler { source: $0.source.rawValue ) }, - signals: snapshot.signalsBySurfaceID[entry.surfaceID] ?? .empty + signals: snapshot.signalsBySurfaceID[entry.surfaceID] ?? .empty, + remote: entry.remote.map(remotePayload) ) } return AgentsCommandPayload(count: agents.count, agents: agents) } + private func remotePayload(_ remote: RemoteAgentContext) -> AgentsCommandRemote { + let (correlation, reason) = Self.correlation(remote.correlation) + return AgentsCommandRemote( + host: remote.hostLabel, + user: remote.connection?.user, + hostname: remote.connection?.hostname, + port: remote.connection?.port, + correlation: correlation, + correlationReason: reason, + confidence: remote.isUnprobed ? "heuristic" : "probed", + freshness: remote.isStale ? "stale" : "fresh", + lastConfirmedAt: remote.lastConfirmedAt.map(dateFormatter.string(from:)), + stateKnown: remote.isStateKnown + ) + } + + static func correlation(_ correlation: RemotePaneCorrelation) -> (String, String?) { + switch correlation { + case .pending: ("pending", nil) + case .correlated: ("correlated", nil) + case .ambiguous: ("ambiguous", nil) + case .unmapped: ("unmapped", nil) + case .probeUnavailable(let failure): ("probe_unavailable", failure.map(failureReason) ?? "connection_failed") + case .unsupported(let reason): ("unsupported", reason) + } + } + + private static func failureReason(_ failure: RemoteProbeFailure) -> String { + switch failure { + case .authentication: "authentication" + case .hostKey: "host_key" + case .unresolvableHost: "unresolvable_host" + case .unreachable: "unreachable" + case .launch: "launch" + case .exit: "exit" + case .unrecognizedOutput: "unrecognized_output" + } + } + private func makeTerminalContexts(from snapshot: ListRuntimeSnapshot) -> [UUID: TerminalAgentContext] { diff --git a/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift b/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift new file mode 100644 index 00000000..a81abe4e --- /dev/null +++ b/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift @@ -0,0 +1,82 @@ +import Foundation + +/// How the app shows a remote agent's host and connection health (docs-ai 082, L0 item 10). +/// Health is separate from the agent's state: a stale observation keeps the last state and +/// says when the host last confirmed it. +nonisolated enum RemoteAgentHealth: Equatable, Sendable { + /// The host confirms the agent and evidence decides its state. + case live + /// The host confirms the agent, but no evidence decides its state. + case unknownState + /// The host last confirmed the agent at this time. + case lastSeen(Date) + /// The host never confirmed the agent, or cannot be read. + case unavailable + + init(_ remote: RemoteAgentContext) { + if remote.isStale { + self = remote.lastConfirmedAt.map(Self.lastSeen) ?? .unavailable + } else { + self = remote.isStateKnown ? .live : .unknownState + } + } +} + +nonisolated enum RemoteAgentPresentation { + /// `user@hostname`, or a generic label while the connection is not resolved. + static func hostLabel(for remote: RemoteAgentContext) -> String { + remote.hostLabel ?? String(localized: "Remote host") + } + + /// Why the observation rests on what it does, for the row's tooltip. + static func explanation(for remote: RemoteAgentContext) -> String { + let host = hostLabel(for: remote) + let correlation = correlationExplanation(remote.correlation, host: host) + guard remote.isUnprobed else { return correlation } + let unprobed = String( + localized: "The status comes only from the agent's own terminal reports, so it can be out of date.") + return "\(correlation) \(unprobed)" + } + + static func correlationExplanation(_ correlation: RemotePaneCorrelation, host: String) -> String { + switch correlation { + case .pending: + return String(localized: "Prowl is connecting to \(host).") + case .correlated: + return String(localized: "Prowl reads this agent from \(host) over a separate SSH connection.") + case .ambiguous: + return String(localized: "More than one session on \(host) carries this pane's ID.") + case .unmapped: + return String( + localized: """ + \(host) has no session for this pane. The ssh client does not send LC_PROWL_PANE \ + (SendEnv), or the server does not accept it (AcceptEnv). + """) + case .probeUnavailable(let failure): + return String(localized: "Prowl cannot read \(host): \(failureReason(failure)).") + case .unsupported(let reason): + return String(localized: "Prowl does not read hosts for this ssh command: \(reason).") + } + } + + static func failureReason(_ failure: RemoteProbeFailure?) -> String { + switch failure { + case .authentication: + return String(localized: "the key was refused or needs a prompt") + case .hostKey: + return String(localized: "the host key is unknown or changed") + case .unresolvableHost: + return String(localized: "the host name does not resolve") + case .unreachable: + return String(localized: "the host is not reachable") + case .launch: + return String(localized: "ssh could not start") + case .exit(_, let line): + return line.isEmpty ? String(localized: "ssh failed") : line + case .unrecognizedOutput: + return String(localized: "the remote shell printed unexpected output") + case nil: + return String(localized: "the connection failed or timed out") + } + } +} diff --git a/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift b/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift index 29753034..99346132 100644 --- a/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -14,7 +14,7 @@ struct ActiveAgentRow: View { agentIcon VStack(alignment: .leading, spacing: 2) { title - Text(subtitle) + subtitleLine .font(.caption) .foregroundStyle(.secondary) .lineLimit(1) @@ -44,6 +44,49 @@ struct ActiveAgentRow: View { .lineLimit(1) } + /// A remote agent shows its host and, when the host does not confirm it, its connection + /// health before the usual subtitle (docs-ai 082, L0 item 10). + @ViewBuilder + private var subtitleLine: some View { + if let remote = entry.remote { + HStack(spacing: 3) { + Image(systemName: "network") + .imageScale(.small) + .accessibilityHidden(true) + Text(RemoteAgentPresentation.hostLabel(for: remote)) + .layoutPriority(1) + switch RemoteAgentHealth(remote) { + case .live: + EmptyView() + case .unknownState: + separator + Text("Status unknown") + case .lastSeen(let date): + separator + Text("Last seen \(date, style: .relative) ago") + case .unavailable: + separator + Text("Status unavailable") + } + separator + Text(subtitle) + } + } else { + Text(subtitle) + } + } + + private var separator: some View { + Text("·").foregroundStyle(.tertiary) + } + + /// A remote state the host does not confirm, or that no evidence decides, is shown but not + /// emphasized. + private var isStateUnconfirmed: Bool { + guard let remote = entry.remote else { return false } + return RemoteAgentHealth(remote) != .live + } + private var agentIcon: some View { Group { if let icon = entry.iconSource { @@ -58,21 +101,17 @@ struct ActiveAgentRow: View { private var statusPill: some View { HStack(spacing: 4) { - if entry.displayState == .working { - if reduceMotion { - statusText - } else { - BaguaWorkingIndicator() - } + if entry.displayState == .working, !reduceMotion, !isStateUnconfirmed { + BaguaWorkingIndicator() } else { statusText } } - .foregroundStyle(entry.displayState.foregroundStyle) + .foregroundStyle(isStateUnconfirmed ? Color.secondary : entry.displayState.foregroundStyle) } private var statusText: some View { - Text(entry.displayState.label) + Text(entry.remote?.isStateKnown == false ? String(localized: "Unknown") : entry.displayState.label) .font(.caption2.weight(.semibold)) .lineLimit(1) } diff --git a/App/Sources/Features/ActiveAgents/Views/ActiveAgentRowSupport.swift b/App/Sources/Features/ActiveAgents/Views/ActiveAgentRowSupport.swift index ec9234f9..afd87308 100644 --- a/App/Sources/Features/ActiveAgents/Views/ActiveAgentRowSupport.swift +++ b/App/Sources/Features/ActiveAgents/Views/ActiveAgentRowSupport.swift @@ -17,7 +17,9 @@ enum ActiveAgentRowPresentation { branchName: String, showTabTitles: Bool ) -> String { - showTabTitles ? branchName : paneTitle(for: entry) + let text = showTabTitles ? branchName : paneTitle(for: entry) + guard let remote = entry.remote else { return text } + return "\(text)\n\(RemoteAgentPresentation.explanation(for: remote))" } /// The island roster has room for both, so it shows "pane title · branch" regardless of the @@ -57,7 +59,8 @@ struct ActiveAgentRowContextMenu: View { } .help("Clear this agent's unread notifications without switching to it") - if let directory { + // A remote agent's directory is on its host; the local path is the `ssh`'s. + if let directory, entry.remote == nil { Divider() Button("Copy Path") { NSPasteboard.general.clearContents() diff --git a/App/Tests/CLIAgentsCommandHandlerTests.swift b/App/Tests/CLIAgentsCommandHandlerTests.swift index 64eb4ddb..67f3a341 100644 --- a/App/Tests/CLIAgentsCommandHandlerTests.swift +++ b/App/Tests/CLIAgentsCommandHandlerTests.swift @@ -56,6 +56,44 @@ struct CLIAgentsCommandHandlerTests { #expect(payload.agents.first?.rawState == "working") } + @Test func remoteAgentsCarryHostCorrelationAndFreshness() async throws { + let fixture = makePayloadFixture() + var state = fixture.snapshot.repositoriesState + let key = ConnectionKey(user: "alice", hostname: "box.example", port: 2222) + let confirmedAt = Date(timeIntervalSince1970: 1_790_000_000) + state.activeAgents.entries[0].remote = RemoteAgentContext( + connection: key, correlation: .correlated, confirmedAt: confirmedAt, now: confirmedAt.addingTimeInterval(30)) + state.activeAgents.entries[1].remote = RemoteAgentContext( + connection: nil, correlation: .probeUnavailable(.hostKey), confirmedAt: confirmedAt, now: confirmedAt, + stateKnown: false, isUnprobed: true) + let snapshot = AgentsRuntimeSnapshot( + repositoriesState: state, listSnapshot: fixture.snapshot.listSnapshot, + screenDetectionsBySurfaceID: fixture.snapshot.screenDetectionsBySurfaceID) + let handler = AgentsCommandHandler { snapshot } + let response = await handler.handle(envelope: CommandEnvelope(output: .json, command: .agents(AgentsInput()))) + let payload = try #require(try response.data?.decode(as: AgentsCommandPayload.self)) + #expect( + payload.agents[0].remote + == AgentsCommandRemote( + host: "alice@box.example", user: "alice", hostname: "box.example", port: 2222, correlation: "correlated", + confidence: "probed", freshness: "stale", lastConfirmedAt: "2026-09-21T14:13:20Z", stateKnown: true)) + #expect( + payload.agents[1].remote + == AgentsCommandRemote( + host: nil, user: nil, hostname: nil, port: nil, correlation: "probe_unavailable", + correlationReason: "host_key", confidence: "heuristic", freshness: "fresh", stateKnown: false)) + } + + @Test func localAgentsHaveNoRemoteObject() async throws { + let fixture = makePayloadFixture() + let handler = AgentsCommandHandler { fixture.snapshot } + let response = await handler.handle(envelope: CommandEnvelope(output: .json, command: .agents(AgentsInput()))) + let data = try #require(response.data?.bytes) + let raw = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + let agents = try #require(raw["agents"] as? [[String: Any]]) + #expect(agents.allSatisfy { $0["remote"] == nil }) + } + @Test func buildsAgentsPayloadFromActiveEntriesAndTerminalSnapshot() async throws { let fixture = makePayloadFixture() let handler = AgentsCommandHandler { diff --git a/App/Tests/RemoteAgentPresentationTests.swift b/App/Tests/RemoteAgentPresentationTests.swift new file mode 100644 index 00000000..f9d3a191 --- /dev/null +++ b/App/Tests/RemoteAgentPresentationTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing + +@testable import Prowl + +struct RemoteAgentPresentationTests { + private let key = ConnectionKey(user: "alice", hostname: "box.example") + private let now = Date(timeIntervalSince1970: 1_790_000_000) + + @Test func healthIsSeparateFromTheAgentState() { + let live = RemoteAgentContext(connection: key, correlation: .correlated, confirmedAt: now, now: now) + #expect(RemoteAgentHealth(live) == .live) + let unknown = RemoteAgentContext( + connection: key, correlation: .correlated, confirmedAt: now, now: now, stateKnown: false) + #expect(RemoteAgentHealth(unknown) == .unknownState) + let quiet = RemoteAgentContext( + connection: key, correlation: .correlated, confirmedAt: now, now: now.addingTimeInterval(12)) + #expect(RemoteAgentHealth(quiet) == .lastSeen(now)) + let never = RemoteAgentContext(connection: key, correlation: .probeUnavailable(nil), confirmedAt: nil, now: now) + #expect(RemoteAgentHealth(never) == .unavailable) + } + + @Test func theHostLabelFallsBackWhileTheConnectionIsUnresolved() { + let resolved = RemoteAgentContext(connection: key, correlation: .correlated, confirmedAt: now, now: now) + #expect(RemoteAgentPresentation.hostLabel(for: resolved) == "alice@box.example") + let unresolved = RemoteAgentContext( + connection: nil, correlation: .unsupported("-W"), confirmedAt: now, now: now, isUnprobed: true) + #expect(RemoteAgentPresentation.hostLabel(for: unresolved) == "Remote host") + } + + @Test func theExplanationNamesTheReason() { + let hostKey = RemoteAgentContext( + connection: key, correlation: .probeUnavailable(.hostKey), confirmedAt: now, now: now, isUnprobed: true) + #expect( + RemoteAgentPresentation.explanation(for: hostKey) + == "Prowl cannot read alice@box.example: the host key is unknown or changed. " + + "The status comes only from the agent's own terminal reports, so it can be out of date.") + let unmapped = RemoteAgentContext(connection: key, correlation: .unmapped, confirmedAt: now, now: now) + #expect(RemoteAgentPresentation.explanation(for: unmapped).contains("SendEnv")) + let exit = RemotePaneCorrelation.probeUnavailable(.exit(255, "Connection closed by 10.0.0.2 port 22")) + #expect( + RemoteAgentPresentation.correlationExplanation(exit, host: "h") + == "Prowl cannot read h: Connection closed by 10.0.0.2 port 22.") + } + + @Test func theRowTooltipAddsTheExplanationForRemoteAgents() { + var entry = ActiveAgentEntry( + id: UUID(), worktreeID: "/tmp/repo", worktreeName: "repo", workingDirectory: nil, + tabID: TerminalTabID(rawValue: UUID()), paneTitle: "claude", surfaceID: UUID(), paneIndex: 1, + iconLookupToken: "claude", agent: .claude, session: nil, rawState: .idle, displayState: .idle, + lastChangedAt: now) + #expect(ActiveAgentRowPresentation.helpText(for: entry, branchName: "main", showTabTitles: true) == "main") + entry.remote = RemoteAgentContext(connection: key, correlation: .correlated, confirmedAt: now, now: now) + #expect( + ActiveAgentRowPresentation.helpText(for: entry, branchName: "main", showTabTitles: true) + == "main\nProwl reads this agent from alice@box.example over a separate SSH connection.") + } +} diff --git a/CLI/Sources/ProwlCLIContracts/Resources/cli-output-schema.json b/CLI/Sources/ProwlCLIContracts/Resources/cli-output-schema.json index 6a4c905e..86d4679a 100644 --- a/CLI/Sources/ProwlCLIContracts/Resources/cli-output-schema.json +++ b/CLI/Sources/ProwlCLIContracts/Resources/cli-output-schema.json @@ -1103,6 +1103,68 @@ }, "signals": { "$ref": "#/$defs/agentSignals" + }, + "remote": { + "$ref": "#/$defs/agentsRemote" + } + } + }, + "agentsRemote": { + "type": "object", + "additionalProperties": false, + "required": [ + "correlation", + "confidence", + "freshness", + "state_known" + ], + "properties": { + "host": { + "type": "string", + "minLength": 1 + }, + "user": { + "type": "string" + }, + "hostname": { + "type": "string", + "minLength": 1 + }, + "port": { + "type": "integer", + "minimum": 1 + }, + "correlation": { + "enum": [ + "pending", + "correlated", + "ambiguous", + "unmapped", + "probe_unavailable", + "unsupported" + ] + }, + "correlation_reason": { + "type": "string" + }, + "confidence": { + "enum": [ + "probed", + "heuristic" + ] + }, + "freshness": { + "enum": [ + "fresh", + "stale" + ] + }, + "last_confirmed_at": { + "type": "string", + "minLength": 1 + }, + "state_known": { + "type": "boolean" } } }, diff --git a/CLI/Sources/prowl/Output/OutputRenderer.swift b/CLI/Sources/prowl/Output/OutputRenderer.swift index 9779c488..ae6eeec7 100644 --- a/CLI/Sources/prowl/Output/OutputRenderer.swift +++ b/CLI/Sources/prowl/Output/OutputRenderer.swift @@ -330,9 +330,22 @@ enum OutputRenderer { let paneHandle = agent.pane.handle.map { "p\($0)" } ?? agent.pane.id let sessionLabel = agent.session.map { " session=\($0.id) [\($0.confidence)]" } ?? "" return "\(statusLabel) \(agent.name) \(projectLabel) \(agent.tab.title) \(paneHandle)\(sessionLabel)" + + (agent.remote.map(remoteLabel) ?? "") }.joined(separator: "\n") } + /// ` remote=alice@box [stale]`: the host plus every qualifier that makes the state weaker + /// than a fresh probed observation. + static func remoteLabel(_ remote: AgentsCommandRemote) -> String { + var qualifiers: [String] = [] + if remote.confidence != "probed" { qualifiers.append(remote.confidence) } + if remote.freshness != "fresh" { qualifiers.append(remote.freshness) } + if !remote.stateKnown { qualifiers.append("state unknown") } + if remote.correlation != "correlated" { qualifiers.append(remote.correlation) } + let suffix = qualifiers.isEmpty ? "" : " [\(qualifiers.joined(separator: ", "))]" + return " remote=\(remote.host ?? "unresolved")\(suffix)" + } + static func agentSignalWarningLines(_ payload: AgentSignalCommandPayload) -> [String] { (payload.warnings ?? []).map { "warning: [\($0.code.rawValue)] \($0.message)" } } diff --git a/CLI/Tests/ProwlCLITests/AgentReadOutputRendererTests.swift b/CLI/Tests/ProwlCLITests/AgentReadOutputRendererTests.swift index f4191ae1..9d21acae 100644 --- a/CLI/Tests/ProwlCLITests/AgentReadOutputRendererTests.swift +++ b/CLI/Tests/ProwlCLITests/AgentReadOutputRendererTests.swift @@ -5,6 +5,18 @@ import XCTest @testable import prowl final class AgentReadOutputRendererTests: XCTestCase { + func testRemoteLabelNamesTheHostAndEveryWeakerQualifier() { + let fresh = AgentsCommandRemote( + host: "alice@box", user: "alice", hostname: "box", port: 22, correlation: "correlated", + confidence: "probed", freshness: "fresh", stateKnown: true) + XCTAssertEqual(OutputRenderer.remoteLabel(fresh), " remote=alice@box") + let degraded = AgentsCommandRemote( + host: nil, user: nil, hostname: nil, port: nil, correlation: "unmapped", confidence: "heuristic", + freshness: "stale", stateKnown: false) + XCTAssertEqual( + OutputRenderer.remoteLabel(degraded), " remote=unresolved [heuristic, stale, state unknown, unmapped]") + } + func testResultOnlyDataIsVerbatimWithoutSyntheticNewline() { let payload = makePayload( outputMode: .resultOnly, diff --git a/CLI/Tests/ProwlCLITests/AgentReasonSchemaTests.swift b/CLI/Tests/ProwlCLITests/AgentReasonSchemaTests.swift index 110bc73f..30915aac 100644 --- a/CLI/Tests/ProwlCLITests/AgentReasonSchemaTests.swift +++ b/CLI/Tests/ProwlCLITests/AgentReasonSchemaTests.swift @@ -5,6 +5,35 @@ import ProwlCLIShared import XCTest final class AgentReasonSchemaTests: XCTestCase { + func testRemoteAgentObjectMatchesTheClosedSchema() throws { + let remote = AgentsCommandRemote( + host: "alice@box", user: "alice", hostname: "box", port: 22, correlation: "probe_unavailable", + correlationReason: "host_key", confidence: "heuristic", freshness: "stale", + lastConfirmedAt: "2026-10-18T00:00:00Z", stateKnown: false) + let roster = AgentsCommandAgent( + id: "pane", type: "claude", name: "Claude", status: .working, rawState: "working", + lastChangedAt: "2026-10-18T00:00:00Z", + project: .init(name: "Prowl", branch: "main", path: "/tmp/repo"), + worktree: .init(id: "w", name: "main", path: "/tmp/repo", rootPath: "/tmp/repo", kind: "git"), + tab: .init(id: "tab", title: "Terminal", selected: true), + pane: .init(id: "pane", index: 1, title: "ssh", cwd: "/tmp/repo", focused: true), remote: remote) + var schema = try XCTUnwrap( + JSONSerialization.jsonObject(with: ProwlCLIContractBundle.schemaData) as? [String: Any]) + schema.removeValue(forKey: "oneOf") + schema["$ref"] = "#/$defs/agent" + let validator = try Schema(instance: String(decoding: try JSONSerialization.data(withJSONObject: schema), as: UTF8.self)) + let data = try JSONEncoder().encode(roster) + let result = try validator.validate(instance: String(decoding: data, as: UTF8.self)) + XCTAssertTrue(result.isValid, "\(result.errors ?? [])") + var invalid = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any]) + var invalidRemote = try XCTUnwrap(invalid["remote"] as? [String: Any]) + invalidRemote["freshness"] = "old" + invalid["remote"] = invalidRemote + let bad = try validator.validate( + instance: String(decoding: JSONSerialization.data(withJSONObject: invalid), as: UTF8.self)) + XCTAssertFalse(bad.isValid) + } + func testEncodedAgentReasonsMatchBothClosedSchemas() throws { let roster = AgentsCommandAgent( id: "pane", type: "codex", name: "Codex", status: .idle, rawState: "blocked", diff --git a/Shared/Sources/ProwlCLIShared/AgentsCommandPayload.swift b/Shared/Sources/ProwlCLIShared/AgentsCommandPayload.swift index db9ef8b1..6ddde973 100644 --- a/Shared/Sources/ProwlCLIShared/AgentsCommandPayload.swift +++ b/Shared/Sources/ProwlCLIShared/AgentsCommandPayload.swift @@ -25,6 +25,8 @@ public struct AgentsCommandAgent: Codable, Equatable { public let pane: AgentsCommandPane public let session: AgentsCommandSession? public let signals: AgentSignalsPayload? + /// Set for an agent on a remote host (docs-ai 082). + public let remote: AgentsCommandRemote? enum CodingKeys: String, CodingKey { case id @@ -41,6 +43,7 @@ public struct AgentsCommandAgent: Codable, Equatable { case pane case session case signals + case remote } public init( @@ -57,7 +60,8 @@ public struct AgentsCommandAgent: Codable, Equatable { tab: AgentsCommandTab, pane: AgentsCommandPane, session: AgentsCommandSession? = nil, - signals: AgentSignalsPayload? = nil + signals: AgentSignalsPayload? = nil, + remote: AgentsCommandRemote? = nil ) { self.id = id self.type = type @@ -73,6 +77,65 @@ public struct AgentsCommandAgent: Codable, Equatable { self.pane = pane self.session = session self.signals = signals + self.remote = remote + } +} + +/// The host of a remote agent and what its observation rests on (docs-ai 082, L0 item 10). +public struct AgentsCommandRemote: Codable, Equatable { + /// `user@hostname`; absent while the connection is not resolved. + public let host: String? + public let user: String? + public let hostname: String? + public let port: Int? + /// `pending`, `correlated`, `ambiguous`, `unmapped`, `probe_unavailable`, or `unsupported`. + public let correlation: String + /// Why the host cannot be read, or why the `ssh` command is not supported. + public let correlationReason: String? + /// `probed` (a host snapshot shows the process) or `heuristic` (OSC reports only). + public let confidence: String + /// `fresh`, or `stale` when the host has not confirmed the agent recently. + public let freshness: String + /// The last confirmation of a stale observation. + public let lastConfirmedAt: String? + /// False when no evidence decides the agent's state; `status` is then not evidence. + public let stateKnown: Bool + + enum CodingKeys: String, CodingKey { + case host + case user + case hostname + case port + case correlation + case correlationReason = "correlation_reason" + case confidence + case freshness + case lastConfirmedAt = "last_confirmed_at" + case stateKnown = "state_known" + } + + public init( + host: String?, + user: String?, + hostname: String?, + port: Int?, + correlation: String, + correlationReason: String? = nil, + confidence: String, + freshness: String, + lastConfirmedAt: String? = nil, + stateKnown: Bool + ) { + self.host = host + self.user = user + self.hostname = hostname + self.port = port + self.correlation = correlation + self.correlationReason = correlationReason + self.confidence = confidence + self.freshness = freshness + self.lastConfirmedAt = lastConfirmedAt + self.stateKnown = stateKnown } } diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 8039d7ee..76f46842 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -11,7 +11,8 @@ sections below record what shipped and what differs from the plan. | 2026-10-17 | S2: host identity (C1), connection owner (C4), snapshot contract (C3), structured commands (C5), probe and parser (C6), all unused by the app. Review Loop clean after 2 rounds | #925 | | 2026-10-17 | S3a: manual-session correlation behind `PROWL_REMOTE_AGENT_DETECTION` — `LC_PROWL_PANE`, argv grammar, per-pane tracker, identity, snapshot release, remote OSC fence. Review Loop: 4 rounds plus a 2-round follow-up, clean (R1–R10) | #926 | | 2026-10-18 | S3b: stale projection and refusal of remote agents in conditions, dispatch and workflow admission (behind the flag). Review Loop clean after 3 rounds | #927 | -| 2026-10-18 | S3c: degraded OSC-only binding for panes the host cannot map or probe (behind the flag) | S3c PR | +| 2026-10-18 | S3c: degraded OSC-only binding for panes the host cannot map or probe (behind the flag). Review Loop: 4 rounds, then two follow-ups, clean | #928 | +| 2026-10-18 | S4a: host label and connection health on Active Agents rows, `remote` object in `prowl agents --json`, docs (behind the flag) | S4a PR | ## S1 — Typed process references @@ -283,3 +284,35 @@ degraded OSC-only binding. `osc.working` → `osc.done`; dispatch was refused. - After `/exit` the entry stayed listed (by design, stale after 10 s); the `ssh` exit released it. + +## S4a — Host label, health, and CLI fields (behind the flag) + +S4 is split. S4a holds the data and the Active Agents row. S4b holds the pane and tab +label, the ssh-ended marker, and turning the flag on. + +- **Health apart from state.** `RemoteAgentHealth` maps `RemoteAgentContext` to one of + four values: `live`, `unknownState` (confirmed, but no evidence decides), + `lastSeen(date)` (stale), or `unavailable` (never confirmed). The row keeps the agent's + last state. A state that is not live is greyed out, with no working spinner. A + state-unknown pill reads "Unknown". +- **Row.** The subtitle line starts with a network glyph and `user@host` (or "Remote + host" while unresolved). A health text follows: "Status unknown", "Last seen … ago" + (a self-updating relative date), or "Status unavailable". The row's height does not + change. The tooltip adds a reason-specific explanation (`RemoteAgentPresentation`): the + correlation, the probe failure, or the unsupported `ssh` option. For a degraded binding + it adds that the status comes only from the agent's terminal reports. The context menu + hides "Copy Path" and "Reveal in Finder" for a remote agent, because the local path is + the `ssh`'s. +- **CLI.** `AgentsCommandRemote` in `ProwlCLIShared` (closed schema `agentsRemote`) has: + - `host`, `user`, `hostname`, `port` + - `correlation`, plus `correlation_reason` (a code for `probe_unavailable`, the option + for `unsupported`) + - `confidence` (`probed` | `heuristic`) + - `freshness` (`fresh` | `stale`), plus `last_confirmed_at` while stale + - `state_known` + + It is absent for local agents. Text output appends `remote= [qualifiers]`. +- **Docs.** `agent-detection.md` gains "Remote agents over SSH" (how, label and health, + degraded binding, release, limits). `cli.md` documents the `remote` object. The + `prowl-cli` skill lists the fields. + diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 4cd0ee1c..691dcfab 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -397,6 +397,40 @@ generic screen-motion inference. Viewer overlays (Claude's transcript / history- views) are an explicit exception: their chrome covers the live status area, so those frames keep the last trusted state instead of forcing Idle. +## Remote agents over SSH + +When a pane's foreground command is a local `ssh`, Prowl can show the agent that runs on +the host (docs-ai 082, L0). Until this ships on by default, launch Prowl with +`PROWL_REMOTE_AGENT_DETECTION=1` to enable it. Prowl installs nothing on the host and +edits no rc file. + +- **How.** Prowl opens its own SSH connection to the same target (`ssh -G` resolves user, + host name, port, `ProxyJump` and `-F`). It runs a read-only probe over that connection: + about once a second while a remote agent is listed, and every few seconds otherwise. + The probe finds the pane's session by `LC_PROWL_PANE`, which the default OpenSSH + `SendEnv`/`AcceptEnv` settings forward, and reads the session's foreground process. A + verified producer's OSC 7501 reports decide the state, as they do locally; otherwise the + screen decides. +- **Host label and health.** The Active Agents row shows `user@host` with a network + glyph. Connection health is shown apart from the agent's state. If no snapshot has + confirmed the agent for 10 s, the row keeps the last state, greys it out, and adds + "Last seen … ago". "Status unavailable" means the host was never read. "Status unknown" + means the host confirms the agent but no evidence decides its state. The tooltip names + the reason (for example, an unknown host key, or a server that does not accept + `LC_PROWL_PANE`). +- **Degraded binding.** Sometimes the host cannot be read, or has no session for the pane: + `-F` without `SendEnv`, a refused key, or an `ssh` command outside the supported grammar. + In that case a verified producer's OSC 7501 report still lists the agent, with + `heuristic` confidence. Only the `ssh` exit releases such an agent. When its producer is + quiet for 10 s, it becomes stale. +- **Release.** A probed agent is released after two complete fresh snapshots without it + (about 2–3 s after it exits), or at once when the `ssh` exits. +- **Limits.** A remote agent cannot be a `prowl agents dispatch` target that needs a + receipt, or a workflow `current`/`pick` participant: both fail with + `REMOTE_AGENT_UNSUPPORTED`. A stale or state-unknown observation never satisfies an + `idle` or `blocked` wait, or input readiness, until the host confirms the agent again. Local session files, + transcripts and hooks are never read for a remote agent. + ## The state machine **Raw states:** `working`, `blocked`, `idle`, `unknown`. diff --git a/docs/components/cli.md b/docs/components/cli.md index 7a055d27..0190f061 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -234,6 +234,25 @@ Each agent contains: `store_record`). Ambiguous sessions are omitted instead of guessed. A `medium` session id must not be used for automatic resume without additional confirmation. +- `remote`: present only for an agent on a remote host behind the pane's `ssh` (see + [agent detection](agent-detection.md#remote-agents-over-ssh)). It contains: + - `host` (`user@hostname`), `user`, `hostname`, `port`: absent while the connection + is not resolved. + - `correlation`: `pending`, `correlated`, `ambiguous`, `unmapped`, + `probe_unavailable`, or `unsupported`. + - `correlation_reason`: why. For `probe_unavailable` it is a code: `authentication`, + `host_key`, `unresolvable_host`, `unreachable`, `launch`, `exit`, + `unrecognized_output`, or `connection_failed`. For `unsupported` it names the `ssh` + option. + - `confidence`: `probed` (a host snapshot shows the process) or `heuristic` (OSC 7501 + reports only). + - `freshness`: `fresh`, or `stale` when nothing has confirmed the agent for 10 s. + `last_confirmed_at` gives the last confirmation of a stale agent. + - `state_known`: `false` when no evidence decides the state. + + A stale or unknown `status` is not evidence: `agents wait --until idle|blocked` keeps + waiting until the host confirms the agent again. Text output appends `remote=` with the weaker + qualifiers in brackets. `prowl agents` is read-only. Text output is sorted for triage: `Blocked`, `Working`, `Done`, then `Idle`. It prints a pane handle such as `p7`; JSON keeps diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index d230093f..063d554b 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -244,7 +244,7 @@ Every `--json` response is `{ "ok", "command", "schema_version", "data": {...} } Key fields by command: - `list` → `.data.items[]` with `.worktree.{id,name,path,root_path,kind}`, `.tab.{id,title,selected}`, `.pane.{id,title,cwd,focused,agent}`, `.task.status` (`running`|`idle`|null). -- `agents` → `.data.agents[]` with `.status`, `.raw_state`, `.detection_reason`, `.screen_reason`, `.type`, `.name`, `.pane.{id,focused,cwd}`, `.tab`, `.worktree`, `.project.{name,branch,path}`. +- `agents` → `.data.agents[]` with `.status`, `.raw_state`, `.detection_reason`, `.screen_reason`, `.type`, `.name`, `.pane.{id,focused,cwd}`, `.tab`, `.worktree`, `.project.{name,branch,path}`, and `.remote.{host,correlation,confidence,freshness,state_known}` only for an agent behind the pane's `ssh` (a `stale` or `state_known: false` status is not evidence). - `agents read` → `.data.agent`, `.data.blocker.text`, `.data.result.{state,text}` — `pending`, `unavailable`, `missing`, `incomplete`, `too_large` carry no partial text; `pending` is returned whenever the agent is working or blocked, even if an earlier turn completed. - `agents signal` → `.data.pane.{id,worktree_id}`, `.data.signal.{event,source,confidence,binding,at,session_id,detail,claimed_origin}`, optional `.data.warnings[]` (`code=signal_unbound`); optional fields are omitted. - `agents dispatch` → `.data.target` and the new pending `.data.dispatch.{id,state,created_at}`; refusals carry `.error.details.{target,record,observation,signals}`. -- 2.54.0 From 7fb86a93b557998f287c6b4a43217c66064feda5 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 03:54:09 +0900 Subject: [PATCH 25/51] Show remote health under the state pill Health inline on the subtitle line overflowed under the pill when the host label was long. The subtitle line is now one truncating text, and the health text sits under the state. --- .../ActiveAgents/Views/ActiveAgentRow.swift | 57 ++++++++++--------- docs-ai/082-ssh-remote-agents/001-action.md | 9 +-- docs/components/agent-detection.md | 4 +- 3 files changed, 37 insertions(+), 33 deletions(-) diff --git a/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift b/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift index 99346132..bbd88f00 100644 --- a/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -44,8 +44,8 @@ struct ActiveAgentRow: View { .lineLimit(1) } - /// A remote agent shows its host and, when the host does not confirm it, its connection - /// health before the usual subtitle (docs-ai 082, L0 item 10). + /// A remote agent shows its host before the usual subtitle (docs-ai 082, L0 item 10). One + /// text truncates as a unit, so the subtitle goes first. @ViewBuilder private var subtitleLine: some View { if let remote = entry.remote { @@ -53,31 +53,27 @@ struct ActiveAgentRow: View { Image(systemName: "network") .imageScale(.small) .accessibilityHidden(true) - Text(RemoteAgentPresentation.hostLabel(for: remote)) - .layoutPriority(1) - switch RemoteAgentHealth(remote) { - case .live: - EmptyView() - case .unknownState: - separator - Text("Status unknown") - case .lastSeen(let date): - separator - Text("Last seen \(date, style: .relative) ago") - case .unavailable: - separator - Text("Status unavailable") - } - separator - Text(subtitle) + Text(verbatim: "\(RemoteAgentPresentation.hostLabel(for: remote)) \u{00B7} \(subtitle)") } } else { Text(subtitle) } } - private var separator: some View { - Text("·").foregroundStyle(.tertiary) + /// The connection health of a remote agent the host does not confirm, under its state. + @ViewBuilder + private var healthText: some View { + if let remote = entry.remote { + switch RemoteAgentHealth(remote) { + case .live, .unknownState: + // An unknown state is the pill's "Unknown": the connection itself is healthy. + EmptyView() + case .lastSeen(let date): + Text("Last seen \(date, style: .relative) ago") + case .unavailable: + Text("Status unavailable") + } + } } /// A remote state the host does not confirm, or that no evidence decides, is shown but not @@ -100,14 +96,21 @@ struct ActiveAgentRow: View { } private var statusPill: some View { - HStack(spacing: 4) { - if entry.displayState == .working, !reduceMotion, !isStateUnconfirmed { - BaguaWorkingIndicator() - } else { - statusText + VStack(alignment: .trailing, spacing: 1) { + HStack(spacing: 4) { + if entry.displayState == .working, !reduceMotion, !isStateUnconfirmed { + BaguaWorkingIndicator() + } else { + statusText + } } + .foregroundStyle(isStateUnconfirmed ? Color.secondary : entry.displayState.foregroundStyle) + healthText + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) } - .foregroundStyle(isStateUnconfirmed ? Color.secondary : entry.displayState.foregroundStyle) + .fixedSize() } private var statusText: some View { diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 76f46842..f33ce9b4 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -295,10 +295,11 @@ label, the ssh-ended marker, and turning the flag on. `lastSeen(date)` (stale), or `unavailable` (never confirmed). The row keeps the agent's last state. A state that is not live is greyed out, with no working spinner. A state-unknown pill reads "Unknown". -- **Row.** The subtitle line starts with a network glyph and `user@host` (or "Remote - host" while unresolved). A health text follows: "Status unknown", "Last seen … ago" - (a self-updating relative date), or "Status unavailable". The row's height does not - change. The tooltip adds a reason-specific explanation (`RemoteAgentPresentation`): the +- **Row.** The subtitle line starts with a network glyph, then one text `user@host · + subtitle` ("Remote host" while unresolved), which truncates at its tail. The health + text sits under the state pill, so a long host never hides it: "Last seen … ago" (a + self-updating relative date) or "Status unavailable". An offscreen render showed that + health inline on the subtitle line overflowed under the pill. The tooltip adds a reason-specific explanation (`RemoteAgentPresentation`): the correlation, the probe failure, or the unsupported `ssh` option. For a degraded binding it adds that the status comes only from the agent's terminal reports. The context menu hides "Copy Path" and "Reveal in Finder" for a remote agent, because the local path is diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 691dcfab..93a9885f 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -414,8 +414,8 @@ edits no rc file. - **Host label and health.** The Active Agents row shows `user@host` with a network glyph. Connection health is shown apart from the agent's state. If no snapshot has confirmed the agent for 10 s, the row keeps the last state, greys it out, and adds - "Last seen … ago". "Status unavailable" means the host was never read. "Status unknown" - means the host confirms the agent but no evidence decides its state. The tooltip names + "Last seen … ago". "Status unavailable" means the host was never read. A state of + "Unknown" means the host confirms the agent but no evidence decides its state. The tooltip names the reason (for example, an unknown host key, or a server that does not accept `LC_PROWL_PANE`). - **Degraded binding.** Sometimes the host cannot be read, or has no session for the pane: -- 2.54.0 From 93ad0dda8b37d9b9f7498acfc519d430993229c9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 04:01:47 +0900 Subject: [PATCH 26/51] Explain remote agents in the island roster too The island roster rows show the same reason-specific remote explanation as their tooltip. --- .../ActiveAgents/Views/ActiveAgentRowSupport.swift | 10 ++++++++-- .../ActiveAgents/Views/AgentIslandRosterContent.swift | 2 ++ App/Tests/RemoteAgentPresentationTests.swift | 7 +++++++ docs-ai/082-ssh-remote-agents/001-action.md | 5 +++-- 4 files changed, 20 insertions(+), 4 deletions(-) diff --git a/App/Sources/Features/ActiveAgents/Views/ActiveAgentRowSupport.swift b/App/Sources/Features/ActiveAgents/Views/ActiveAgentRowSupport.swift index afd87308..f055f99e 100644 --- a/App/Sources/Features/ActiveAgents/Views/ActiveAgentRowSupport.swift +++ b/App/Sources/Features/ActiveAgents/Views/ActiveAgentRowSupport.swift @@ -18,8 +18,14 @@ enum ActiveAgentRowPresentation { showTabTitles: Bool ) -> String { let text = showTabTitles ? branchName : paneTitle(for: entry) - guard let remote = entry.remote else { return text } - return "\(text)\n\(RemoteAgentPresentation.explanation(for: remote))" + guard let remote = remoteHelpText(for: entry) else { return text } + return "\(text)\n\(remote)" + } + + /// The reason a remote agent's observation rests on what it does; nil for a local agent. + /// The island roster, whose subtitle already shows title and branch, uses it alone. + static func remoteHelpText(for entry: ActiveAgentEntry) -> String? { + entry.remote.map(RemoteAgentPresentation.explanation) } /// The island roster has room for both, so it shows "pane title · branch" regardless of the diff --git a/App/Sources/Features/ActiveAgents/Views/AgentIslandRosterContent.swift b/App/Sources/Features/ActiveAgents/Views/AgentIslandRosterContent.swift index cad0c4ea..d45c01ab 100644 --- a/App/Sources/Features/ActiveAgents/Views/AgentIslandRosterContent.swift +++ b/App/Sources/Features/ActiveAgents/Views/AgentIslandRosterContent.swift @@ -72,6 +72,8 @@ struct AgentIslandRosterContent: View { } } .buttonStyle(.plain) + // An empty help string shows no tooltip, so local rows keep none. + .help(ActiveAgentRowPresentation.remoteHelpText(for: entry) ?? "") .accessibilityValue( store.islandNavigation.selectedEntryID == entry.id ? "Selected" : "" ) diff --git a/App/Tests/RemoteAgentPresentationTests.swift b/App/Tests/RemoteAgentPresentationTests.swift index f9d3a191..347aa2e4 100644 --- a/App/Tests/RemoteAgentPresentationTests.swift +++ b/App/Tests/RemoteAgentPresentationTests.swift @@ -50,9 +50,16 @@ struct RemoteAgentPresentationTests { iconLookupToken: "claude", agent: .claude, session: nil, rawState: .idle, displayState: .idle, lastChangedAt: now) #expect(ActiveAgentRowPresentation.helpText(for: entry, branchName: "main", showTabTitles: true) == "main") + #expect(ActiveAgentRowPresentation.remoteHelpText(for: entry) == nil) entry.remote = RemoteAgentContext(connection: key, correlation: .correlated, confirmedAt: now, now: now) #expect( ActiveAgentRowPresentation.helpText(for: entry, branchName: "main", showTabTitles: true) == "main\nProwl reads this agent from alice@box.example over a separate SSH connection.") + // The island roster shows the same reason on its own. + entry.remote = RemoteAgentContext( + connection: key, correlation: .unmapped, confirmedAt: now, now: now, isUnprobed: true) + let island = try? #require(ActiveAgentRowPresentation.remoteHelpText(for: entry)) + #expect(island?.contains("AcceptEnv") == true) + #expect(island?.contains("terminal reports") == true) } } diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index f33ce9b4..e1c387b5 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -12,7 +12,7 @@ sections below record what shipped and what differs from the plan. | 2026-10-17 | S3a: manual-session correlation behind `PROWL_REMOTE_AGENT_DETECTION` — `LC_PROWL_PANE`, argv grammar, per-pane tracker, identity, snapshot release, remote OSC fence. Review Loop: 4 rounds plus a 2-round follow-up, clean (R1–R10) | #926 | | 2026-10-18 | S3b: stale projection and refusal of remote agents in conditions, dispatch and workflow admission (behind the flag). Review Loop clean after 3 rounds | #927 | | 2026-10-18 | S3c: degraded OSC-only binding for panes the host cannot map or probe (behind the flag). Review Loop: 4 rounds, then two follow-ups, clean | #928 | -| 2026-10-18 | S4a: host label and connection health on Active Agents rows, `remote` object in `prowl agents --json`, docs (behind the flag) | S4a PR | +| 2026-10-18 | S4a: host label and connection health on Active Agents rows, `remote` object in `prowl agents --json`, docs (behind the flag) | #930 | ## S1 — Typed process references @@ -301,7 +301,8 @@ label, the ssh-ended marker, and turning the flag on. self-updating relative date) or "Status unavailable". An offscreen render showed that health inline on the subtitle line overflowed under the pill. The tooltip adds a reason-specific explanation (`RemoteAgentPresentation`): the correlation, the probe failure, or the unsupported `ssh` option. For a degraded binding - it adds that the status comes only from the agent's terminal reports. The context menu + it adds that the status comes only from the agent's terminal reports. The island roster + rows show the same explanation as their tooltip (review round 1). The context menu hides "Copy Path" and "Reveal in Finder" for a remote agent, because the local path is the `ssh`'s. - **CLI.** `AgentsCommandRemote` in `ProwlCLIShared` (closed schema `agentsRemote`) has: -- 2.54.0 From dabae7f668a2a51505fea030c49e372568893276 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 21:38:38 +0900 Subject: [PATCH 27/51] Keep a stale remote Blocked state red OSC has no heartbeat, so a blocked agent stops reporting and goes stale while it still waits for the user. Grey out other unconfirmed states, but keep a known Blocked state emphasized. --- .../Models/RemoteAgentPresentation.swift | 14 ++++++++++++++ .../ActiveAgents/Views/ActiveAgentRow.swift | 12 +++++------- App/Tests/RemoteAgentPresentationTests.swift | 18 ++++++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 6 ++++-- docs/components/agent-detection.md | 4 +++- 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift b/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift index a81abe4e..ba247803 100644 --- a/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift +++ b/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift @@ -28,6 +28,20 @@ nonisolated enum RemoteAgentPresentation { remote.hostLabel ?? String(localized: "Remote host") } + /// Whether the row shows the state as a local agent's. A state the host does not confirm is + /// not emphasized, except a known `blocked`: OSC has no heartbeat, so a blocked agent goes + /// stale while it still waits for the user, and a missed prompt costs more than a false one. + static func emphasizesState(_ state: AgentDisplayState, remote: RemoteAgentContext) -> Bool { + switch RemoteAgentHealth(remote) { + case .live: + return true + case .unknownState: + return false + case .lastSeen, .unavailable: + return state == .blocked && remote.isStateKnown + } + } + /// Why the observation rests on what it does, for the row's tooltip. static func explanation(for remote: RemoteAgentContext) -> String { let host = hostLabel(for: remote) diff --git a/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift b/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift index bbd88f00..6e9dcb2e 100644 --- a/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift +++ b/App/Sources/Features/ActiveAgents/Views/ActiveAgentRow.swift @@ -76,11 +76,9 @@ struct ActiveAgentRow: View { } } - /// A remote state the host does not confirm, or that no evidence decides, is shown but not - /// emphasized. - private var isStateUnconfirmed: Bool { - guard let remote = entry.remote else { return false } - return RemoteAgentHealth(remote) != .live + private var isStateEmphasized: Bool { + guard let remote = entry.remote else { return true } + return RemoteAgentPresentation.emphasizesState(entry.displayState, remote: remote) } private var agentIcon: some View { @@ -98,13 +96,13 @@ struct ActiveAgentRow: View { private var statusPill: some View { VStack(alignment: .trailing, spacing: 1) { HStack(spacing: 4) { - if entry.displayState == .working, !reduceMotion, !isStateUnconfirmed { + if entry.displayState == .working, !reduceMotion, isStateEmphasized { BaguaWorkingIndicator() } else { statusText } } - .foregroundStyle(isStateUnconfirmed ? Color.secondary : entry.displayState.foregroundStyle) + .foregroundStyle(isStateEmphasized ? entry.displayState.foregroundStyle : Color.secondary) healthText .font(.caption2) .foregroundStyle(.secondary) diff --git a/App/Tests/RemoteAgentPresentationTests.swift b/App/Tests/RemoteAgentPresentationTests.swift index 347aa2e4..bb55358e 100644 --- a/App/Tests/RemoteAgentPresentationTests.swift +++ b/App/Tests/RemoteAgentPresentationTests.swift @@ -20,6 +20,24 @@ struct RemoteAgentPresentationTests { #expect(RemoteAgentHealth(never) == .unavailable) } + @Test func onlyAConfirmedStateOrABlockedStateIsEmphasized() { + let live = RemoteAgentContext(connection: key, correlation: .correlated, confirmedAt: now, now: now) + #expect(RemoteAgentPresentation.emphasizesState(.working, remote: live)) + let quiet = RemoteAgentContext( + connection: key, correlation: .correlated, confirmedAt: now, now: now.addingTimeInterval(12)) + #expect(!RemoteAgentPresentation.emphasizesState(.working, remote: quiet)) + #expect(!RemoteAgentPresentation.emphasizesState(.idle, remote: quiet)) + // A blocked agent stops reporting, so it goes stale while it still waits for the user. + #expect(RemoteAgentPresentation.emphasizesState(.blocked, remote: quiet)) + let never = RemoteAgentContext( + connection: key, correlation: .probeUnavailable(.hostKey), confirmedAt: nil, now: now, isUnprobed: true) + #expect(RemoteAgentPresentation.emphasizesState(.blocked, remote: never)) + // The pill reads "Unknown", not "Blocked". + let unknown = RemoteAgentContext( + connection: key, correlation: .correlated, confirmedAt: now, now: now, stateKnown: false) + #expect(!RemoteAgentPresentation.emphasizesState(.blocked, remote: unknown)) + } + @Test func theHostLabelFallsBackWhileTheConnectionIsUnresolved() { let resolved = RemoteAgentContext(connection: key, correlation: .correlated, confirmedAt: now, now: now) #expect(RemoteAgentPresentation.hostLabel(for: resolved) == "alice@box.example") diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index e1c387b5..58d71eff 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -293,8 +293,10 @@ label, the ssh-ended marker, and turning the flag on. - **Health apart from state.** `RemoteAgentHealth` maps `RemoteAgentContext` to one of four values: `live`, `unknownState` (confirmed, but no evidence decides), `lastSeen(date)` (stale), or `unavailable` (never confirmed). The row keeps the agent's - last state. A state that is not live is greyed out, with no working spinner. A - state-unknown pill reads "Unknown". + last state. A state that is not live is greyed out, with no working spinner. A known + `blocked` is the exception and stays red (`RemoteAgentPresentation.emphasizesState`): + OSC has no heartbeat, so a blocked agent goes stale while it waits for the user, and a + missed prompt costs more than a false one. A state-unknown pill reads "Unknown". - **Row.** The subtitle line starts with a network glyph, then one text `user@host · subtitle` ("Remote host" while unresolved), which truncates at its tail. The health text sits under the state pill, so a long host never hides it: "Last seen … ago" (a diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 93a9885f..5d15ff4f 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -414,7 +414,9 @@ edits no rc file. - **Host label and health.** The Active Agents row shows `user@host` with a network glyph. Connection health is shown apart from the agent's state. If no snapshot has confirmed the agent for 10 s, the row keeps the last state, greys it out, and adds - "Last seen … ago". "Status unavailable" means the host was never read. A state of + "Last seen … ago". "Status unavailable" means the host was never read. A **Blocked** + state stays red: OSC has no heartbeat, so a blocked agent goes quiet while it waits + for you. A state of "Unknown" means the host confirms the agent but no evidence decides its state. The tooltip names the reason (for example, an unknown host key, or a server that does not accept `LC_PROWL_PANE`). -- 2.54.0 From 3f3c013162f197776062d21f0e6406c44058d22f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 04:17:55 +0900 Subject: [PATCH 28/51] Label remote panes and turn remote detection on A pane whose foreground ssh reaches a host shows user@host in its corner, and its tab shows a network glyph. When that ssh exits, the corner says so for five seconds. Remote agent detection is now on by default; PROWL_REMOTE_AGENT_DETECTION=0 or the remoteAgentDetectionEnabled default turns it off. --- .../Domain/AgentDetection/ProcessRef.swift | 3 + .../AgentDetection/RemoteAgentContext.swift | 2 +- .../AgentDetection/RemotePaneIndicator.swift | 14 ++++ .../Terminal/Models/TerminalTabItem.swift | 2 + .../Terminal/Models/TerminalTabManager.swift | 5 ++ ...WorktreeTerminalState+AgentDetection.swift | 32 ++++++++++ .../WorktreeTerminalState+Surfaces.swift | 1 + .../TabBar/Views/TerminalTabLabelView.swift | 7 ++ .../TabBar/Views/TerminalTabView.swift | 8 ++- .../Terminal/Views/RemotePaneBadge.swift | 52 +++++++++++++++ .../Views/TerminalSplitTreeView.swift | 3 + .../Ghostty/GhosttySurfaceState.swift | 2 + .../RemoteHost/RemoteAgentDetection.swift | 13 ++-- App/Tests/RemoteAgentDetectionTests.swift | 64 +++++++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 29 ++++++++- docs/components/active-agents.md | 8 +++ docs/components/agent-detection.md | 13 ++-- docs/components/terminal.md | 5 ++ 18 files changed, 252 insertions(+), 11 deletions(-) create mode 100644 App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift create mode 100644 App/Sources/Features/Terminal/Views/RemotePaneBadge.swift diff --git a/App/Sources/Domain/AgentDetection/ProcessRef.swift b/App/Sources/Domain/AgentDetection/ProcessRef.swift index 30cbb268..de77ef37 100644 --- a/App/Sources/Domain/AgentDetection/ProcessRef.swift +++ b/App/Sources/Domain/AgentDetection/ProcessRef.swift @@ -15,6 +15,9 @@ nonisolated struct ConnectionKey: Hashable, Sendable, Codable { /// The `-F` configuration file, or nil for the default user and system files. let configFile: String? + /// `user@hostname`, the label a remote pane and its agents show. + var label: String { "\(user)@\(hostname)" } + init(user: String, hostname: String, port: Int = 22, jumpChain: [String] = [], configFile: String? = nil) { self.user = user self.hostname = hostname diff --git a/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift b/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift index 66af6eda..96d5334b 100644 --- a/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift +++ b/App/Sources/Domain/AgentDetection/RemoteAgentContext.swift @@ -21,7 +21,7 @@ nonisolated struct RemoteAgentContext: Equatable, Sendable { /// `user@hostname`, the label a remote pane shows. var hostLabel: String? { - connection.map { "\($0.user)@\($0.hostname)" } + connection?.label } init( diff --git a/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift b/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift new file mode 100644 index 00000000..2c792d36 --- /dev/null +++ b/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift @@ -0,0 +1,14 @@ +import Foundation + +/// What a pane shows about its foreground `ssh` (docs-ai 082, L0 item 10). +nonisolated enum RemotePaneIndicator: Hashable, Sendable { + /// The pane's foreground `ssh` reaches this host (`user@hostname`). + case connected(String) + /// That `ssh` exited, so the pane is back in its local shell. + case ended(String) + + var connectedHost: String? { + guard case .connected(let host) = self else { return nil } + return host + } +} diff --git a/App/Sources/Features/Terminal/Models/TerminalTabItem.swift b/App/Sources/Features/Terminal/Models/TerminalTabItem.swift index 5c99225b..ace3fc32 100644 --- a/App/Sources/Features/Terminal/Models/TerminalTabItem.swift +++ b/App/Sources/Features/Terminal/Models/TerminalTabItem.swift @@ -27,6 +27,8 @@ struct TerminalTabItem: Identifiable, Equatable, Sendable { var isDirty: Bool var isTitleLocked: Bool var iconLock: TerminalTabIconLock + /// The hosts that the tab's panes reach over `ssh` (docs-ai 082), in pane order. + var remoteHosts: [String] = [] var displayTitle: String { customTitle ?? title } diff --git a/App/Sources/Features/Terminal/Models/TerminalTabManager.swift b/App/Sources/Features/Terminal/Models/TerminalTabManager.swift index d952c4e0..7447f8ef 100644 --- a/App/Sources/Features/Terminal/Models/TerminalTabManager.swift +++ b/App/Sources/Features/Terminal/Models/TerminalTabManager.swift @@ -266,6 +266,11 @@ final class TerminalTabManager { tabs[index].isDirty = isDirty } + func updateRemoteHosts(_ id: TerminalTabID, hosts: [String]) { + guard let index = tabs.firstIndex(where: { $0.id == id }), tabs[index].remoteHosts != hosts else { return } + tabs[index].remoteHosts = hosts + } + func reorderTabs(_ orderedIds: [TerminalTabID]) { let existingIds = Set(tabs.map(\.id)) let incomingIds = Set(orderedIds) diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 4ff160a6..345fb303 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -321,6 +321,7 @@ extension WorktreeTerminalState { ) -> RemotePaneTracker.Observation? { guard remoteAgentDetectionEnabled else { retireRemotePaneTracker(surfaceID: surfaceID) + setRemoteIndicator(nil, surfaceID: surfaceID) return nil } let tracker: RemotePaneTracker @@ -329,6 +330,7 @@ extension WorktreeTerminalState { // The `ssh` left the foreground: its next tracker starts a new interval. if let ssh = remotePaneTrackers[surfaceID]?.ssh { remoteFenceHistoryBySurface[surfaceID]?[ssh]?.leftForeground = true + endRemoteIndicator(ssh: ssh, surfaceID: surfaceID) } retireRemotePaneTracker(surfaceID: surfaceID) return nil @@ -345,9 +347,39 @@ extension WorktreeTerminalState { let observation = bindUnprobedProducer( observation: tracker.observe(active: active, now: now), tracker: tracker, surfaceID: surfaceID) storeFenceHistory(tracker.fenceHistory, surfaceID: surfaceID) + if let connection = observation.connection { + setRemoteIndicator(.connected(connection.label), surfaceID: surfaceID) + } return observation } + /// The pane's `ssh` left the foreground: an exited one leaves an ended marker, while a + /// suspended one (still running) just drops the label. + private func endRemoteIndicator(ssh: LocalProcessGeneration, surfaceID: UUID) { + guard let host = surfaces[surfaceID]?.bridge.state.remoteIndicator?.connectedHost else { return } + let exited = ProcessDetection.processStartDate(pid: ssh.pid) != ssh.startedAt + setRemoteIndicator(exited ? .ended(host) : nil, surfaceID: surfaceID) + } + + func setRemoteIndicator(_ indicator: RemotePaneIndicator?, surfaceID: UUID) { + guard let view = surfaces[surfaceID], view.bridge.state.remoteIndicator != indicator else { return } + view.bridge.state.remoteIndicator = indicator + if let tabId = tabId(containing: surfaceID) { updateRemoteHosts(for: tabId) } + } + + /// The tab shows the hosts its panes reach; tree changes (moved or closed panes) refresh it + /// through `updateRunningState`. + func updateRemoteHosts(for tabId: TerminalTabID) { + guard let tree = trees[tabId] else { return } + var hosts: [String] = [] + for surface in tree.leaves() { + if let host = surface.bridge.state.remoteIndicator?.connectedHost, !hosts.contains(host) { + hosts.append(host) + } + } + tabManager.updateRemoteHosts(tabId, hosts: hosts) + } + /// Keeps `history` for its `ssh` and drops the histories of `ssh` clients that exited. private func storeFenceHistory(_ history: RemoteFenceHistory, surfaceID: UUID) { var histories = remoteFenceHistoryBySurface[surfaceID] ?? [:] diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift index 36b40012..f3197ea5 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+Surfaces.swift @@ -860,6 +860,7 @@ extension WorktreeTerminalState { } tabIsRunningById[tabId] = isRunningNow tabManager.updateDirty(tabId, isDirty: isRunningNow) + updateRemoteHosts(for: tabId) emitTaskStatusIfChanged() } diff --git a/App/Sources/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift b/App/Sources/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift index 683cbc20..0254a694 100644 --- a/App/Sources/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift +++ b/App/Sources/Features/Terminal/TabBar/Views/TerminalTabLabelView.swift @@ -18,6 +18,13 @@ struct TerminalTabLabelView: View { if tab.isDirty || tab.icon != nil { TerminalTabIconBadge(tab: tab, isActive: isActive) } + if !tab.remoteHosts.isEmpty { + // A pane of this tab is on a remote host (docs-ai 082); the tooltip names it. + Image(systemName: "network") + .imageScale(.small) + .foregroundStyle(isActive ? TerminalTabBarColors.activeText : TerminalTabBarColors.inactiveText) + .accessibilityHidden(true) + } Text(tab.displayTitle) .font(.caption) .lineLimit(1) diff --git a/App/Sources/Features/Terminal/TabBar/Views/TerminalTabView.swift b/App/Sources/Features/Terminal/TabBar/Views/TerminalTabView.swift index 501b7883..d78e3bfb 100644 --- a/App/Sources/Features/Terminal/TabBar/Views/TerminalTabView.swift +++ b/App/Sources/Features/Terminal/TabBar/Views/TerminalTabView.swift @@ -27,6 +27,12 @@ struct TerminalTabView: View { @Environment(CommandKeyObserver.self) private var commandKeyObserver @Environment(\.resolvedKeybindings) private var resolvedKeybindings + private var helpText: String { + guard !tab.remoteHosts.isEmpty else { return String(localized: "Open tab \(tab.displayTitle)") } + let hosts = tab.remoteHosts.formatted(.list(type: .and)) + return String(localized: "Open tab \(tab.displayTitle) (connected to \(hosts))") + } + var body: some View { ZStack(alignment: .leading) { Button(action: onSelect) { @@ -48,7 +54,7 @@ struct TerminalTabView: View { ) .frame(width: fixedWidth) .contentShape(.rect) - .help("Open tab \(tab.displayTitle)") + .help(helpText) .accessibilityLabel(tab.displayTitle) .allowsHitTesting(!isEditing) .opacity(isEditing ? 0 : 1) diff --git a/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift b/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift new file mode 100644 index 00000000..05e3f925 --- /dev/null +++ b/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// The host a pane's foreground `ssh` reaches, in the pane's corner (docs-ai 082, L0 +/// item 10). When that `ssh` exits, it says so briefly, then goes away. +struct RemotePaneBadge: View { + static let endedMarkerDuration: Duration = .seconds(5) + + let indicator: RemotePaneIndicator? + @State private var isEndedMarkerExpired = false + + var body: some View { + Group { + switch indicator { + case .connected(let host): + label(Text(verbatim: host), systemImage: "network") + .accessibilityLabel(Text("Connected to \(host)")) + case .ended(let host): + if !isEndedMarkerExpired { + label(Text("Disconnected from \(host)"), systemImage: "network.slash") + .transition(.opacity) + } + case nil: + EmptyView() + } + } + .animation(.easeOut(duration: 0.3), value: isEndedMarkerExpired) + .task(id: indicator) { + isEndedMarkerExpired = false + guard case .ended = indicator else { return } + try? await Task.sleep(for: Self.endedMarkerDuration) + isEndedMarkerExpired = true + } + .allowsHitTesting(false) + } + + private func label(_ text: Text, systemImage: String) -> some View { + Label { + text.lineLimit(1).truncationMode(.middle) + } icon: { + Image(systemName: systemImage) + .imageScale(.small) + .accessibilityHidden(true) + } + .labelStyle(.titleAndIcon) + .font(.caption2) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.regularMaterial, in: .capsule) + .padding(6) + } +} diff --git a/App/Sources/Features/Terminal/Views/TerminalSplitTreeView.swift b/App/Sources/Features/Terminal/Views/TerminalSplitTreeView.swift index 3657e459..90e5c7fd 100644 --- a/App/Sources/Features/Terminal/Views/TerminalSplitTreeView.swift +++ b/App/Sources/Features/Terminal/Views/TerminalSplitTreeView.swift @@ -176,6 +176,9 @@ struct TerminalSplitTreeView: View { .overlay(alignment: .top) { GhosttySurfaceProgressOverlay(state: surfaceView.bridge.state) } + .overlay(alignment: .bottomTrailing) { + RemotePaneBadge(indicator: surfaceView.bridge.state.remoteIndicator) + } .overlay(alignment: .topTrailing) { if surfaceView.bridge.state.searchNeedle != nil { GhosttySurfaceSearchOverlay(surfaceView: surfaceView) diff --git a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceState.swift b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceState.swift index 0ca3023e..fc231880 100644 --- a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceState.swift +++ b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceState.swift @@ -23,6 +23,8 @@ final class GhosttySurfaceState { var colorChangeR: UInt8? var colorChangeG: UInt8? var colorChangeB: UInt8? + /// The host of the pane's foreground `ssh`, set by agent detection (docs-ai 082). + var remoteIndicator: RemotePaneIndicator? var searchNeedle: String? var searchTotal: Int? var searchSelected: Int? diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift b/App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift index 996ca439..00477db1 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift @@ -1,13 +1,18 @@ import Foundation -/// The switch for SSH-transparent detection (docs-ai 082, L0). Off until the L0 UX ships; -/// `PROWL_REMOTE_AGENT_DETECTION=1` or the `remoteAgentDetectionEnabled` default turns it -/// on for dogfooding. +/// The switch for SSH-transparent detection (docs-ai 082, L0). On by default since the L0 UX +/// shipped; `PROWL_REMOTE_AGENT_DETECTION=0` or a `false` `remoteAgentDetectionEnabled` +/// default turns it off. nonisolated enum RemoteAgentDetection { static let defaultsKey = "remoteAgentDetectionEnabled" static let environmentKey = "PROWL_REMOTE_AGENT_DETECTION" static var isEnabled: Bool { - ProcessInfo.processInfo.environment[environmentKey] == "1" || UserDefaults.standard.bool(forKey: defaultsKey) + isEnabled(environment: ProcessInfo.processInfo.environment, defaults: .standard) + } + + static func isEnabled(environment: [String: String], defaults: UserDefaults) -> Bool { + if let value = environment[environmentKey] { return value != "0" } + return defaults.object(forKey: defaultsKey) as? Bool ?? true } } diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 888e968d..f42c6406 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -218,6 +218,58 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.agentDetectionCoordinators[fixture.surfaceID]?.boundProcess?.localGeneration == nil) } + @Test func aRemotePaneShowsItsHostOnThePaneAndItsTab() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindClaude(fixture, probe: probe) + #expect(fixture.surface.bridge.state.remoteIndicator == .connected("onevcat@mini.local")) + #expect(fixture.state.tabManager.tabs.first?.remoteHosts == ["onevcat@mini.local"]) + // A suspended `ssh` (still running) only drops the label. + probe.job = shellJob + await detect(fixture) + #expect(fixture.surface.bridge.state.remoteIndicator == nil) + #expect(fixture.state.tabManager.tabs.first?.remoteHosts == []) + } + + @Test func anExitedSSHLeavesAnEndedMarker() async throws { + let ssh = Process() + ssh.executableURL = URL(fileURLWithPath: "/bin/sleep") + ssh.arguments = ["30"] + try ssh.run() + defer { if ssh.isRunning { ssh.terminate() } } + let pid = ssh.processIdentifier + let probe = Probe() + let fixture = makeFixture(probe: probe) + probe.job = ForegroundJob( + processGroupID: pid, + processes: [ + ForegroundProcess( + pid: pid, parentProcessID: 1, name: "ssh", argv0: "ssh", cmdline: "ssh -p 2222 mini", + arguments: ["ssh", "-p", "2222", "mini"]) + ]) + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "zsh"))) + await detect(fixture) + #expect(fixture.surface.bridge.state.remoteIndicator == .connected("onevcat@mini.local")) + ssh.terminate() + ssh.waitUntilExit() + probe.job = shellJob + await detect(fixture) + #expect(fixture.surface.bridge.state.remoteIndicator == .ended("onevcat@mini.local")) + #expect(fixture.state.tabManager.tabs.first?.remoteHosts == []) + } + + @Test func turningDetectionOffClearsTheRemoteLabel() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindClaude(fixture, probe: probe) + fixture.state.remoteAgentDetectionEnabledForTesting = false + await detect(fixture) + #expect(fixture.surface.bridge.state.remoteIndicator == nil) + #expect(fixture.state.tabManager.tabs.first?.remoteHosts == []) + } + @Test func anUnconfirmedRemoteAgentTurnsStaleAndProvesNothing() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) @@ -1085,6 +1137,18 @@ struct RemoteAgentDetectionTests { } } +struct RemoteAgentDetectionSwitchTests { + @Test func detectionIsOnUnlessTurnedOff() throws { + let defaults = try #require(UserDefaults(suiteName: "RemoteAgentDetectionSwitchTests-\(UUID().uuidString)")) + #expect(RemoteAgentDetection.isEnabled(environment: [:], defaults: defaults)) + #expect(!RemoteAgentDetection.isEnabled(environment: ["PROWL_REMOTE_AGENT_DETECTION": "0"], defaults: defaults)) + defaults.set(false, forKey: RemoteAgentDetection.defaultsKey) + #expect(!RemoteAgentDetection.isEnabled(environment: [:], defaults: defaults)) + // The environment wins over the default, both ways. + #expect(RemoteAgentDetection.isEnabled(environment: ["PROWL_REMOTE_AGENT_DETECTION": "1"], defaults: defaults)) + } +} + struct RemoteAgentContextTests { @Test func stalenessFollowsTheLastConfirmation() { let now = Date(timeIntervalSince1970: 1_000) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 58d71eff..d84ab5b1 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -12,7 +12,8 @@ sections below record what shipped and what differs from the plan. | 2026-10-17 | S3a: manual-session correlation behind `PROWL_REMOTE_AGENT_DETECTION` — `LC_PROWL_PANE`, argv grammar, per-pane tracker, identity, snapshot release, remote OSC fence. Review Loop: 4 rounds plus a 2-round follow-up, clean (R1–R10) | #926 | | 2026-10-18 | S3b: stale projection and refusal of remote agents in conditions, dispatch and workflow admission (behind the flag). Review Loop clean after 3 rounds | #927 | | 2026-10-18 | S3c: degraded OSC-only binding for panes the host cannot map or probe (behind the flag). Review Loop: 4 rounds, then two follow-ups, clean | #928 | -| 2026-10-18 | S4a: host label and connection health on Active Agents rows, `remote` object in `prowl agents --json`, docs (behind the flag) | #930 | +| 2026-10-18 | S4a: host label and connection health on Active Agents rows, `remote` object in `prowl agents --json`, docs (behind the flag). Review Loop clean after 2 rounds | #930 | +| 2026-10-18 | S4b: pane and tab host label, ssh-ended marker; remote detection on by default | S4b PR | ## S1 — Typed process references @@ -320,3 +321,29 @@ label, the ssh-ended marker, and turning the flag on. degraded binding, release, limits). `cli.md` documents the `remote` object. The `prowl-cli` skill lists the fields. +## S4b — Pane and tab label, ssh-ended marker, on by default + +- **Indicator.** `RemotePaneIndicator` (`connected(host)` or `ended(host)`) lives on the + surface's `GhosttySurfaceState`, so every host of a pane (tab content, Canvas, Shelf) + can read it without new plumbing. Agent detection sets it: + - `connected(user@hostname)` once the tracker's connection resolves; + - when a non-nil foreground job no longer shows the `ssh`: `ended(host)` if that `ssh` + generation exited, or nil if it still runs (suspended); + - nil when the flag turns off. + + A probe gap changes nothing. Writes are guarded, so equal values never notify. +- **Pane.** `RemotePaneBadge` sits in the bottom-trailing corner: a material capsule with a + network glyph and the host. It does not take hits. After an exit it reads "Disconnected + from host" with `network.slash` for five seconds (a view `.task`), then fades out. +- **Tab.** `TerminalTabItem.remoteHosts` lists the distinct hosts of the tab's panes in + pane order, and `updateRemoteHosts` refreshes it on every indicator change and from + `updateRunningState` (tree changes such as a moved or closed pane). The tab label shows + a network glyph before the title, and the tooltip reads "Open tab … (connected to + hosts)". +- **On by default.** `RemoteAgentDetection.isEnabled(environment:defaults:)`: the + environment value wins (`0` off, anything else on). Otherwise the + `remoteAgentDetectionEnabled` default decides, and it defaults to on. +- **Docs.** The `agent-detection.md` section drops the opt-in and documents the kill + switch, the pane and tab label, and the ended marker. `active-agents.md` documents the + remote row. `terminal.md` documents the tab glyph and pane corner. + diff --git a/docs/components/active-agents.md b/docs/components/active-agents.md index 3fd74f93..80acbdb0 100644 --- a/docs/components/active-agents.md +++ b/docs/components/active-agents.md @@ -52,6 +52,14 @@ Rows appear in the order agents are first detected. (See A row whose pane is bound to an active workflow run replaces its subtitle with `in · ` for the life of the run. +An agent on a remote host behind the pane's `ssh` starts its subtitle with a network +glyph and `user@host`. When the host has not confirmed the agent for 10 s, the pill keeps +the last state in grey, and "Last seen … ago" (or "Status unavailable") appears under it. +A grey **Unknown** pill means the host confirms the agent but nothing decides its state. +The tooltip, in the panel and in the island roster, gives the reason. The context menu has +no Copy Path or Reveal in Finder for a remote agent. See +[agent detection](agent-detection.md#remote-agents-over-ssh). + ## Interactions - **Click a row** → focuses that worktree + tab + pane and brings Prowl forward. A diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 5d15ff4f..b73567fa 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -399,10 +399,10 @@ frames keep the last trusted state instead of forcing Idle. ## Remote agents over SSH -When a pane's foreground command is a local `ssh`, Prowl can show the agent that runs on -the host (docs-ai 082, L0). Until this ships on by default, launch Prowl with -`PROWL_REMOTE_AGENT_DETECTION=1` to enable it. Prowl installs nothing on the host and -edits no rc file. +When a pane's foreground command is a local `ssh`, Prowl shows the agent that runs on the +host (docs-ai 082, L0). Prowl installs nothing on the host and edits no rc file. To turn +the feature off, run `defaults write com.onevcat.prowl remoteAgentDetectionEnabled -bool +false` and relaunch Prowl, or launch it with `PROWL_REMOTE_AGENT_DETECTION=0`. - **How.** Prowl opens its own SSH connection to the same target (`ssh -G` resolves user, host name, port, `ProxyJump` and `-F`). It runs a read-only probe over that connection: @@ -411,6 +411,11 @@ edits no rc file. `SendEnv`/`AcceptEnv` settings forward, and reads the session's foreground process. A verified producer's OSC 7501 reports decide the state, as they do locally; otherwise the screen decides. +- **Pane and tab.** A pane whose foreground `ssh` reaches a host shows `user@host` in its + bottom-trailing corner, and its tab shows a network glyph before the title (the tooltip + names the hosts). When that `ssh` exits and the pane is back in its local shell, the + corner reads "Disconnected from user@host" for five seconds. A suspended `ssh` (still + running) only removes the label. - **Host label and health.** The Active Agents row shows `user@host` with a network glyph. Connection health is shown apart from the agent's state. If no snapshot has confirmed the agent for 10 s, the row keeps the last state, greys it out, and adds diff --git a/docs/components/terminal.md b/docs/components/terminal.md index 2e1023e9..171b2d0b 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -101,6 +101,11 @@ and a profile that sets launch-scoped environment variables runs as `env VAR=… claude`, whose first token is `env`. The slot stays claimable, so a later recognised command in the same tab still takes it over. +A tab with a pane whose foreground `ssh` reaches a remote host also shows a network glyph +before its title, and the tooltip names the host. The pane itself shows `user@host` in its +bottom-trailing corner, and "Disconnected from user@host" for five seconds after that `ssh` +exits. See [agent detection](agent-detection.md#remote-agents-over-ssh). + ## Shell integration & status (OSC sequences) Ghostty's shell integration drives several Prowl features via terminal escape -- 2.54.0 From b07354fcb736044bd717f12d268e2ebb72446df6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 04:20:24 +0900 Subject: [PATCH 29/51] Record the S4b PR in the 082 action log --- docs-ai/082-ssh-remote-agents/001-action.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index d84ab5b1..68d936af 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -13,7 +13,7 @@ sections below record what shipped and what differs from the plan. | 2026-10-18 | S3b: stale projection and refusal of remote agents in conditions, dispatch and workflow admission (behind the flag). Review Loop clean after 3 rounds | #927 | | 2026-10-18 | S3c: degraded OSC-only binding for panes the host cannot map or probe (behind the flag). Review Loop: 4 rounds, then two follow-ups, clean | #928 | | 2026-10-18 | S4a: host label and connection health on Active Agents rows, `remote` object in `prowl agents --json`, docs (behind the flag). Review Loop clean after 2 rounds | #930 | -| 2026-10-18 | S4b: pane and tab host label, ssh-ended marker; remote detection on by default | S4b PR | +| 2026-10-18 | S4b: pane and tab host label, ssh-ended marker; remote detection on by default | #931 | ## S1 — Typed process references -- 2.54.0 From 7a79ed060cdaf45e500dab13003e1e1ee0646947 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 04:36:03 +0900 Subject: [PATCH 30/51] Reconcile the remote pane label on every sampled job The label records its ssh generation and is reconciled without a tracker, so a pane restored after its ssh exited and an unresolvable replacement ssh both lose the old host. The ended marker carries its exit time and expires in the state, so a later view mount never shows an old exit again. --- .../AgentDetection/RemotePaneIndicator.swift | 20 +++- ...WorktreeTerminalState+AgentDetection.swift | 35 +++++-- .../Terminal/Views/RemotePaneBadge.swift | 16 +-- App/Tests/RemoteAgentDetectionTests.swift | 99 +++++++++++++++---- docs-ai/082-ssh-remote-agents/001-action.md | 26 +++-- 5 files changed, 148 insertions(+), 48 deletions(-) diff --git a/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift b/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift index 2c792d36..c29b8cee 100644 --- a/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift +++ b/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift @@ -2,13 +2,23 @@ import Foundation /// What a pane shows about its foreground `ssh` (docs-ai 082, L0 item 10). nonisolated enum RemotePaneIndicator: Hashable, Sendable { - /// The pane's foreground `ssh` reaches this host (`user@hostname`). - case connected(String) - /// That `ssh` exited, so the pane is back in its local shell. - case ended(String) + /// How long the pane says that its `ssh` ended. + static let endedMarkerDuration: TimeInterval = 5 + + /// The pane's foreground `ssh` (this local generation) reaches this host. + case connected(host: String, ssh: LocalProcessGeneration) + /// That `ssh` exited at this time, so the pane is back in its local shell. + case ended(host: String, endedAt: Date) var connectedHost: String? { - guard case .connected(let host) = self else { return nil } + guard case .connected(let host, _) = self else { return nil } return host } + + /// The ended marker describes one exit: it shows for `endedMarkerDuration` after it, + /// however often a view mounts. + func showsEndedMarker(now: Date) -> Bool { + guard case .ended(_, let endedAt) = self else { return false } + return now.timeIntervalSince(endedAt) < Self.endedMarkerDuration + } } diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 345fb303..74c7d7b0 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -330,9 +330,9 @@ extension WorktreeTerminalState { // The `ssh` left the foreground: its next tracker starts a new interval. if let ssh = remotePaneTrackers[surfaceID]?.ssh { remoteFenceHistoryBySurface[surfaceID]?[ssh]?.leftForeground = true - endRemoteIndicator(ssh: ssh, surfaceID: surfaceID) } retireRemotePaneTracker(surfaceID: surfaceID) + reconcileRemoteIndicator(foregroundSSH: nil, connection: nil, surfaceID: surfaceID, now: now) return nil } tracker = remotePaneTracker(surfaceID: surfaceID, ssh: ssh) @@ -347,18 +347,35 @@ extension WorktreeTerminalState { let observation = bindUnprobedProducer( observation: tracker.observe(active: active, now: now), tracker: tracker, surfaceID: surfaceID) storeFenceHistory(tracker.fenceHistory, surfaceID: surfaceID) - if let connection = observation.connection { - setRemoteIndicator(.connected(connection.label), surfaceID: surfaceID) + if job != nil { + reconcileRemoteIndicator( + foregroundSSH: tracker.ssh, connection: observation.connection, surfaceID: surfaceID, now: now) } return observation } - /// The pane's `ssh` left the foreground: an exited one leaves an ended marker, while a - /// suspended one (still running) just drops the label. - private func endRemoteIndicator(ssh: LocalProcessGeneration, surfaceID: UUID) { - guard let host = surfaces[surfaceID]?.bridge.state.remoteIndicator?.connectedHost else { return } - let exited = ProcessDetection.processStartDate(pid: ssh.pid) != ssh.startedAt - setRemoteIndicator(exited ? .ended(host) : nil, surfaceID: surfaceID) + /// Sets the pane's label from a sample that read the foreground job (a probe gap changes + /// nothing). It depends on no tracker, so a pane restored by Undo is reconciled too. + /// - The foreground `ssh` with a resolved connection: connected to that host. + /// - Another (or unresolved) `ssh`, or a shell: the label of an earlier `ssh` goes. An + /// exited one leaves the ended marker for its duration; a suspended one leaves nothing. + private func reconcileRemoteIndicator( + foregroundSSH: LocalProcessGeneration?, connection: ConnectionKey?, surfaceID: UUID, now: Date + ) { + let current = surfaces[surfaceID]?.bridge.state.remoteIndicator + if let foregroundSSH, let connection { + setRemoteIndicator(.connected(host: connection.label, ssh: foregroundSSH), surfaceID: surfaceID) + return + } + switch current { + case .connected(let host, let ssh) where ssh != foregroundSSH: + let exited = ProcessDetection.processStartDate(pid: ssh.pid) != ssh.startedAt + setRemoteIndicator(exited ? .ended(host: host, endedAt: now) : nil, surfaceID: surfaceID) + case .ended where current?.showsEndedMarker(now: now) == false: + setRemoteIndicator(nil, surfaceID: surfaceID) + default: + break + } } func setRemoteIndicator(_ indicator: RemotePaneIndicator?, surfaceID: UUID) { diff --git a/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift b/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift index 05e3f925..eb6bf2d2 100644 --- a/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift +++ b/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift @@ -1,21 +1,20 @@ import SwiftUI /// The host a pane's foreground `ssh` reaches, in the pane's corner (docs-ai 082, L0 -/// item 10). When that `ssh` exits, it says so briefly, then goes away. +/// item 10). When that `ssh` exits, it says so for `RemotePaneIndicator.endedMarkerDuration` +/// after the exit, then goes away; a later mount of the view does not show it again. struct RemotePaneBadge: View { - static let endedMarkerDuration: Duration = .seconds(5) - let indicator: RemotePaneIndicator? @State private var isEndedMarkerExpired = false var body: some View { Group { switch indicator { - case .connected(let host): + case .connected(let host, _): label(Text(verbatim: host), systemImage: "network") .accessibilityLabel(Text("Connected to \(host)")) - case .ended(let host): - if !isEndedMarkerExpired { + case .ended(let host, _): + if !isEndedMarkerExpired, indicator?.showsEndedMarker(now: Date()) == true { label(Text("Disconnected from \(host)"), systemImage: "network.slash") .transition(.opacity) } @@ -26,8 +25,9 @@ struct RemotePaneBadge: View { .animation(.easeOut(duration: 0.3), value: isEndedMarkerExpired) .task(id: indicator) { isEndedMarkerExpired = false - guard case .ended = indicator else { return } - try? await Task.sleep(for: Self.endedMarkerDuration) + guard case .ended(_, let endedAt) = indicator else { return } + let remaining = endedAt.addingTimeInterval(RemotePaneIndicator.endedMarkerDuration).timeIntervalSinceNow + if remaining > 0 { try? await Task.sleep(for: .seconds(remaining)) } isEndedMarkerExpired = true } .allowsHitTesting(false) diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index f42c6406..bca65f78 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -222,7 +222,7 @@ struct RemoteAgentDetectionTests { let probe = Probe() let fixture = makeFixture(probe: probe) await bindClaude(fixture, probe: probe) - #expect(fixture.surface.bridge.state.remoteIndicator == .connected("onevcat@mini.local")) + #expect(fixture.surface.bridge.state.remoteIndicator?.connectedHost == "onevcat@mini.local") #expect(fixture.state.tabManager.tabs.first?.remoteHosts == ["onevcat@mini.local"]) // A suspended `ssh` (still running) only drops the label. probe.job = shellJob @@ -231,32 +231,97 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.tabManager.tabs.first?.remoteHosts == []) } - @Test func anExitedSSHLeavesAnEndedMarker() async throws { - let ssh = Process() - ssh.executableURL = URL(fileURLWithPath: "/bin/sleep") - ssh.arguments = ["30"] - try ssh.run() - defer { if ssh.isRunning { ssh.terminate() } } - let pid = ssh.processIdentifier - let probe = Probe() - let fixture = makeFixture(probe: probe) - probe.job = ForegroundJob( + /// A spawned process that stands in for a local `ssh` client, so it has its own generation. + private func spawnSSH(_ arguments: [String] = ["ssh", "-p", "2222", "mini"]) throws -> (Process, ForegroundJob) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sleep") + process.arguments = ["30"] + try process.run() + let pid = process.processIdentifier + let job = ForegroundJob( processGroupID: pid, processes: [ ForegroundProcess( - pid: pid, parentProcessID: 1, name: "ssh", argv0: "ssh", cmdline: "ssh -p 2222 mini", - arguments: ["ssh", "-p", "2222", "mini"]) + pid: pid, parentProcessID: 1, name: "ssh", argv0: "ssh", cmdline: arguments.joined(separator: " "), + arguments: arguments) ]) + return (process, job) + } + + private func connect(_ fixture: Fixture, probe: Probe, job: ForegroundJob) async { + probe.job = job await detect(fixture) - await settle { fixture.source.recipes.count == 1 } - await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "zsh"))) + await settle { fixture.state.remotePaneTrackers[fixture.surfaceID] != nil } + await settle { !fixture.source.recipes.isEmpty } + await deliver( + fixture, + snapshot( + (fixture.state.remotePaneTrackers[fixture.surfaceID]?.latest?.sequence ?? 0) + 1, pane: fixture.surfaceID, + foreground: remoteProcess(200, name: "zsh"))) await detect(fixture) - #expect(fixture.surface.bridge.state.remoteIndicator == .connected("onevcat@mini.local")) + } + + @Test func anExitedSSHLeavesAnEndedMarkerForItsDuration() async throws { + let (ssh, job) = try spawnSSH() + defer { if ssh.isRunning { ssh.terminate() } } + let probe = Probe() + let fixture = makeFixture(probe: probe) + await connect(fixture, probe: probe, job: job) + #expect(fixture.surface.bridge.state.remoteIndicator?.connectedHost == "onevcat@mini.local") + ssh.terminate() + ssh.waitUntilExit() + probe.job = shellJob + let exitedAt = Date() + await detect(fixture, now: exitedAt) + // Detection stamps the sample no earlier than the wall clock. + guard case .ended(let host, let endedAt) = fixture.surface.bridge.state.remoteIndicator else { + Issue.record("no ended marker") + return + } + #expect(host == "onevcat@mini.local") + #expect(endedAt >= exitedAt) + #expect(fixture.state.tabManager.tabs.first?.remoteHosts == []) + // After its duration the marker is gone from the state, not only from one view. + await detect(fixture, now: endedAt.addingTimeInterval(RemotePaneIndicator.endedMarkerDuration + 1)) + #expect(fixture.surface.bridge.state.remoteIndicator == nil) + } + + @Test func theEndedMarkerDescribesOneExit() { + let endedAt = Date(timeIntervalSince1970: 1_000) + let ended = RemotePaneIndicator.ended(host: "h", endedAt: endedAt) + #expect(ended.showsEndedMarker(now: endedAt.addingTimeInterval(4))) + // A view mounted later (a tab switch, a new split) does not show it again. + #expect(!ended.showsEndedMarker(now: endedAt.addingTimeInterval(5))) + #expect(!RemotePaneIndicator.connected(host: "h", ssh: sshGeneration).showsEndedMarker(now: endedAt)) + } + + @Test func aRestoredPaneWhoseSSHExitedDropsTheConnectedLabel() async throws { + let (ssh, job) = try spawnSSH() + defer { if ssh.isRunning { ssh.terminate() } } + let probe = Probe() + let fixture = makeFixture(probe: probe) + await connect(fixture, probe: probe, job: job) + // Close (the tracker retires, the surface is retained), the `ssh` exits, then Undo. + fixture.state.retireRemotePaneTracker(surfaceID: fixture.surfaceID) ssh.terminate() ssh.waitUntilExit() probe.job = shellJob await detect(fixture) - #expect(fixture.surface.bridge.state.remoteIndicator == .ended("onevcat@mini.local")) + #expect(fixture.surface.bridge.state.remoteIndicator?.connectedHost == nil) + #expect(fixture.state.tabManager.tabs.first?.remoteHosts == []) + } + + @Test func aReplacementSSHNeverInheritsThePreviousHost() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindClaude(fixture, probe: probe) + #expect(fixture.surface.bridge.state.remoteIndicator?.connectedHost == "onevcat@mini.local") + // The next sampled job is another `ssh` that cannot be resolved (`-N` is unsupported). + let (other, job) = try spawnSSH(["ssh", "-N", "-L", "8080:localhost:80", "hostB"]) + defer { other.terminate() } + probe.job = job + await detect(fixture) + #expect(fixture.surface.bridge.state.remoteIndicator == nil) #expect(fixture.state.tabManager.tabs.first?.remoteHosts == []) } diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 68d936af..81079c4d 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -323,18 +323,26 @@ label, the ssh-ended marker, and turning the flag on. ## S4b — Pane and tab label, ssh-ended marker, on by default -- **Indicator.** `RemotePaneIndicator` (`connected(host)` or `ended(host)`) lives on the - surface's `GhosttySurfaceState`, so every host of a pane (tab content, Canvas, Shelf) - can read it without new plumbing. Agent detection sets it: - - `connected(user@hostname)` once the tracker's connection resolves; - - when a non-nil foreground job no longer shows the `ssh`: `ended(host)` if that `ssh` - generation exited, or nil if it still runs (suspended); - - nil when the flag turns off. +- **Indicator.** `RemotePaneIndicator` lives on the surface's `GhosttySurfaceState`, so + every host of a pane (tab content, Canvas, Shelf) can read it without new plumbing. Its + cases are `connected(host, ssh: generation)` and `ended(host, at:)`. + `reconcileRemoteIndicator` sets it on every sample that read a job, and needs no tracker: + - the foreground `ssh` with a resolved connection: `connected` to that generation; + - otherwise (another or unresolved `ssh`, or a shell): a `connected` of another + generation becomes `ended(at: now)` if that `ssh` exited, or nil if it still runs + (suspended); + - an `ended` older than five seconds becomes nil; + - the flag turning off: nil. - A probe gap changes nothing. Writes are guarded, so equal values never notify. + A probe gap changes nothing. Writes are guarded, so equal values never notify. Review + round 1 made the indicator independent of the tracker: a pane restored by Undo after + its `ssh` exited, and an unresolvable replacement `ssh`, both lost the old label. It + also made the ended marker carry its exit time: a view that mounted later showed an old + exit again. - **Pane.** `RemotePaneBadge` sits in the bottom-trailing corner: a material capsule with a network glyph and the host. It does not take hits. After an exit it reads "Disconnected - from host" with `network.slash` for five seconds (a view `.task`), then fades out. + from host" with `network.slash` until five seconds after the exit (`showsEndedMarker`, + plus a view `.task` for the remainder), then fades out. - **Tab.** `TerminalTabItem.remoteHosts` lists the distinct hosts of the tab's panes in pane order, and `updateRemoteHosts` refreshes it on every indicator change and from `updateRunningState` (tree changes such as a moved or closed pane). The tab label shows -- 2.54.0 From aabe525bff911f0e5b8e6916848e73998a979f56 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 04:47:59 +0900 Subject: [PATCH 31/51] Say why a remote pane's agents are not read The pane label carries the correlation issue (unmapped, probe unavailable, ambiguous, unsupported) whether or not an agent is listed. The label shows a warning glyph, and its tooltip gives the reason. --- .../AgentDetection/RemotePaneIndicator.swift | 16 +++++- .../BusinessLogic/RemotePaneTracker.swift | 2 +- ...WorktreeTerminalState+AgentDetection.swift | 24 +++++--- .../Terminal/Views/RemotePaneBadge.swift | 23 +++++++- .../RemoteHost/RemoteProbeSnapshot.swift | 2 +- App/Tests/RemoteAgentDetectionTests.swift | 57 ++++++++++++++++++- docs-ai/082-ssh-remote-agents/001-action.md | 7 +++ docs/components/agent-detection.md | 6 +- docs/components/terminal.md | 3 +- 9 files changed, 119 insertions(+), 21 deletions(-) diff --git a/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift b/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift index c29b8cee..6e20f019 100644 --- a/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift +++ b/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift @@ -5,13 +5,15 @@ nonisolated enum RemotePaneIndicator: Hashable, Sendable { /// How long the pane says that its `ssh` ended. static let endedMarkerDuration: TimeInterval = 5 - /// The pane's foreground `ssh` (this local generation) reaches this host. - case connected(host: String, ssh: LocalProcessGeneration) + /// The pane's foreground `ssh` (this local generation) reaches this host. `issue` is the + /// correlation that keeps Prowl from reading the pane's agents, whether or not an agent + /// is listed (L0-8); nil while it correlates or is still pending. + case connected(host: String, ssh: LocalProcessGeneration, issue: RemotePaneCorrelation? = nil) /// That `ssh` exited at this time, so the pane is back in its local shell. case ended(host: String, endedAt: Date) var connectedHost: String? { - guard case .connected(let host, _) = self else { return nil } + guard case .connected(let host, _, _) = self else { return nil } return host } @@ -21,4 +23,12 @@ nonisolated enum RemotePaneIndicator: Hashable, Sendable { guard case .ended(_, let endedAt) = self else { return false } return now.timeIntervalSince(endedAt) < Self.endedMarkerDuration } + + /// The correlation states a pane reports as an issue. + static func issue(_ correlation: RemotePaneCorrelation) -> RemotePaneCorrelation? { + switch correlation { + case .pending, .correlated: nil + case .ambiguous, .unmapped, .probeUnavailable, .unsupported: correlation + } + } } diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index c7092cac..121e8188 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -72,7 +72,7 @@ nonisolated struct RemoteSnapshotSource: Sendable { /// How a pane relates to the remote host's sessions (docs-ai 082, L0 item 1). Prowl never /// picks an arbitrary session: anything but `correlated` leaves the pane without a probed /// remote agent. -nonisolated enum RemotePaneCorrelation: Equatable, Sendable { +nonisolated enum RemotePaneCorrelation: Hashable, Sendable { /// The connection is being resolved or the first snapshot has not arrived. case pending /// Exactly one session on the host carries this pane's id. diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 74c7d7b0..7f139e19 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -332,7 +332,7 @@ extension WorktreeTerminalState { remoteFenceHistoryBySurface[surfaceID]?[ssh]?.leftForeground = true } retireRemotePaneTracker(surfaceID: surfaceID) - reconcileRemoteIndicator(foregroundSSH: nil, connection: nil, surfaceID: surfaceID, now: now) + reconcileRemoteIndicator(foregroundSSH: nil, observation: nil, surfaceID: surfaceID, now: now) return nil } tracker = remotePaneTracker(surfaceID: surfaceID, ssh: ssh) @@ -349,26 +349,32 @@ extension WorktreeTerminalState { storeFenceHistory(tracker.fenceHistory, surfaceID: surfaceID) if job != nil { reconcileRemoteIndicator( - foregroundSSH: tracker.ssh, connection: observation.connection, surfaceID: surfaceID, now: now) + foregroundSSH: tracker.ssh, observation: observation, surfaceID: surfaceID, now: now) } return observation } /// Sets the pane's label from a sample that read the foreground job (a probe gap changes /// nothing). It depends on no tracker, so a pane restored by Undo is reconciled too. - /// - The foreground `ssh` with a resolved connection: connected to that host. - /// - Another (or unresolved) `ssh`, or a shell: the label of an earlier `ssh` goes. An + /// - The foreground `ssh` with a resolved connection, or with a correlation issue: connected, + /// with that issue, so a pane without a listed agent still says why (L0-8). + /// - Another (or pending) `ssh`, or a shell: the label of an earlier `ssh` goes. An /// exited one leaves the ended marker for its duration; a suspended one leaves nothing. private func reconcileRemoteIndicator( - foregroundSSH: LocalProcessGeneration?, connection: ConnectionKey?, surfaceID: UUID, now: Date + foregroundSSH: LocalProcessGeneration?, observation: RemotePaneTracker.Observation?, surfaceID: UUID, + now: Date ) { let current = surfaces[surfaceID]?.bridge.state.remoteIndicator - if let foregroundSSH, let connection { - setRemoteIndicator(.connected(host: connection.label, ssh: foregroundSSH), surfaceID: surfaceID) - return + if let foregroundSSH, let observation { + let issue = RemotePaneIndicator.issue(observation.correlation) + if observation.connection != nil || issue != nil { + let host = observation.connection?.label ?? String(localized: "Remote host") + setRemoteIndicator(.connected(host: host, ssh: foregroundSSH, issue: issue), surfaceID: surfaceID) + return + } } switch current { - case .connected(let host, let ssh) where ssh != foregroundSSH: + case .connected(let host, let ssh, _) where ssh != foregroundSSH: let exited = ProcessDetection.processStartDate(pid: ssh.pid) != ssh.startedAt setRemoteIndicator(exited ? .ended(host: host, endedAt: now) : nil, surfaceID: surfaceID) case .ended where current?.showsEndedMarker(now: now) == false: diff --git a/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift b/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift index eb6bf2d2..9ff8c9f7 100644 --- a/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift +++ b/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift @@ -10,13 +10,22 @@ struct RemotePaneBadge: View { var body: some View { Group { switch indicator { - case .connected(let host, _): + case .connected(let host, _, nil): label(Text(verbatim: host), systemImage: "network") .accessibilityLabel(Text("Connected to \(host)")) + .allowsHitTesting(false) + case .connected(let host, _, let issue?): + // The reason is the tooltip, so this capsule (and only it) takes the pointer. + let explanation = RemotePaneBadge.issueExplanation(issue, host: host) + label(Text(verbatim: host), systemImage: "exclamationmark.triangle.fill", multicolor: true) + .help(explanation) + .accessibilityLabel(Text("Connected to \(host). Agents are not detected.")) + .accessibilityHint(Text(verbatim: explanation)) case .ended(let host, _): if !isEndedMarkerExpired, indicator?.showsEndedMarker(now: Date()) == true { label(Text("Disconnected from \(host)"), systemImage: "network.slash") .transition(.opacity) + .allowsHitTesting(false) } case nil: EmptyView() @@ -30,15 +39,23 @@ struct RemotePaneBadge: View { if remaining > 0 { try? await Task.sleep(for: .seconds(remaining)) } isEndedMarkerExpired = true } - .allowsHitTesting(false) } - private func label(_ text: Text, systemImage: String) -> some View { + /// Why Prowl does not read this pane's agents, with what still works. + static func issueExplanation(_ issue: RemotePaneCorrelation, host: String) -> String { + let reason = RemoteAgentPresentation.correlationExplanation(issue, host: host) + let fallback = String( + localized: "Agents in this pane are detected only when they report their status themselves (OSC 7501).") + return "\(reason) \(fallback)" + } + + private func label(_ text: Text, systemImage: String, multicolor: Bool = false) -> some View { Label { text.lineLimit(1).truncationMode(.middle) } icon: { Image(systemName: systemImage) .imageScale(.small) + .symbolRenderingMode(multicolor ? .multicolor : .monochrome) .accessibilityHidden(true) } .labelStyle(.titleAndIcon) diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeSnapshot.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeSnapshot.swift index 3847d9b3..9347439e 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteProbeSnapshot.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeSnapshot.swift @@ -2,7 +2,7 @@ import Foundation /// Why a probe produced no usable report (docs-ai 082, C3). Each reason maps to a /// different fix for the user, so the UX can name it. -nonisolated enum RemoteProbeFailure: Equatable, Sendable { +nonisolated enum RemoteProbeFailure: Hashable, Sendable { /// The key was refused or no key was available without a prompt (`BatchMode`). case authentication /// The host key is unknown or changed. diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index bca65f78..93bd6f11 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -321,8 +321,61 @@ struct RemoteAgentDetectionTests { defer { other.terminate() } probe.job = job await detect(fixture) - #expect(fixture.surface.bridge.state.remoteIndicator == nil) - #expect(fixture.state.tabManager.tabs.first?.remoteHosts == []) + // It is labelled as itself: an `ssh` Prowl cannot read, never as A. + guard case .connected(let host, let ssh, let issue) = fixture.surface.bridge.state.remoteIndicator else { + Issue.record("no label for the replacement ssh") + return + } + #expect(host == "Remote host") + #expect(ssh.pid == other.processIdentifier) + if case .unsupported = issue { + } else { + Issue.record("expected an unsupported issue, got \(String(describing: issue))") + } + #expect(fixture.state.tabManager.tabs.first?.remoteHosts == ["Remote host"]) + } + + @Test func aPaneWithoutAnAgentStillReportsWhyItIsNotRead() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + // The host answers, but no session carries this pane's id, and no producer reports. + await deliver(fixture, snapshot(1, pane: nil, foreground: nil)) + await detect(fixture) + #expect( + fixture.surface.bridge.state.remoteIndicator + == .connected( + host: "onevcat@mini.local", ssh: sshGeneration, issue: .unmapped)) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + // Once the pane maps, the issue goes. + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(101, name: "zsh"))) + await detect(fixture) + #expect( + fixture.surface.bridge.state.remoteIndicator + == .connected( + host: "onevcat@mini.local", ssh: sshGeneration, issue: nil)) + } + + @Test func anUnreachableHostIsReportedWithoutAnAgent() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + fixture.source.failNext(100) + probe.job = sshJob + await detect(fixture) + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + await settle { tracker.correlation == .probeUnavailable(nil) } + await detect(fixture) + guard case .connected(_, _, .probeUnavailable?) = fixture.surface.bridge.state.remoteIndicator else { + Issue.record("expected a probe-unavailable issue") + return + } + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + #expect( + RemotePaneBadge.issueExplanation(.probeUnavailable(.authentication), host: "h") + == "Prowl cannot read h: the key was refused or needs a prompt. " + + "Agents in this pane are detected only when they report their status themselves (OSC 7501).") } @Test func turningDetectionOffClearsTheRemoteLabel() async { diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 81079c4d..a960d78d 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -339,6 +339,13 @@ label, the ssh-ended marker, and turning the flag on. its `ssh` exited, and an unresolvable replacement `ssh`, both lost the old label. It also made the ended marker carry its exit time: a view that mounted later showed an old exit again. +- **Issue (review round 2, L0-8).** `connected` also carries `issue`, which is the + correlation when it is `ambiguous`, `unmapped`, `probeUnavailable`, or `unsupported`. A + foreground `ssh` with an issue is labelled even without a resolved connection ("Remote + host"). Before this, a pane with no listed agent had no visible reason. The badge then + shows a multicolor warning glyph. Only that capsule takes the pointer, so its tooltip + (`RemotePaneBadge.issueExplanation`, reusing `RemoteAgentPresentation`) can say why, and + that only OSC 7501 producers are detected. - **Pane.** `RemotePaneBadge` sits in the bottom-trailing corner: a material capsule with a network glyph and the host. It does not take hits. After an exit it reads "Disconnected from host" with `network.slash` until five seconds after the exit (`showsEndedMarker`, diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index b73567fa..7a8d3a8a 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -415,7 +415,11 @@ false` and relaunch Prowl, or launch it with `PROWL_REMOTE_AGENT_DETECTION=0`. bottom-trailing corner, and its tab shows a network glyph before the title (the tooltip names the hosts). When that `ssh` exits and the pane is back in its local shell, the corner reads "Disconnected from user@host" for five seconds. A suspended `ssh` (still - running) only removes the label. + running) only removes the label. When Prowl cannot read the pane's agents (the host has + no session for the pane, the side channel fails, or the `ssh` command is not supported), + the label shows a yellow warning glyph instead, whether or not an agent is listed. Its + tooltip gives the reason, and says that only agents that report their own status + (OSC 7501) are detected. - **Host label and health.** The Active Agents row shows `user@host` with a network glyph. Connection health is shown apart from the agent's state. If no snapshot has confirmed the agent for 10 s, the row keeps the last state, greys it out, and adds diff --git a/docs/components/terminal.md b/docs/components/terminal.md index 171b2d0b..a06afab6 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -104,7 +104,8 @@ later recognised command in the same tab still takes it over. A tab with a pane whose foreground `ssh` reaches a remote host also shows a network glyph before its title, and the tooltip names the host. The pane itself shows `user@host` in its bottom-trailing corner, and "Disconnected from user@host" for five seconds after that `ssh` -exits. See [agent detection](agent-detection.md#remote-agents-over-ssh). +exits. A yellow warning glyph in that label means Prowl cannot read the pane's agents; its +tooltip says why. See [agent detection](agent-detection.md#remote-agents-over-ssh). ## Shell integration & status (OSC sequences) -- 2.54.0 From 9679c7c09f006bca8a44fd78ca1f81f3c3e12510 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 04:56:29 +0900 Subject: [PATCH 32/51] Expose a pane's remote state through its accessibility element The split container lists only the pane views as accessibility children, so the badge's metadata is unreachable. The pane's label and help now carry the host, the ended state and the issue explanation. --- .../Models/RemoteAgentPresentation.swift | 23 +++++++++++++++++++ .../Terminal/Views/RemotePaneBadge.swift | 10 +------- .../GhosttySurfaceView+Accessibility.swift | 11 ++++++++- App/Tests/RemoteAgentDetectionTests.swift | 22 +++++++++++++++++- docs-ai/082-ssh-remote-agents/001-action.md | 5 ++++ 5 files changed, 60 insertions(+), 11 deletions(-) diff --git a/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift b/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift index ba247803..11624bfb 100644 --- a/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift +++ b/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift @@ -73,6 +73,29 @@ nonisolated enum RemoteAgentPresentation { } } + /// Why Prowl does not read a pane's agents, with what still works. + static func paneIssueExplanation(_ issue: RemotePaneCorrelation, host: String) -> String { + let reason = correlationExplanation(issue, host: host) + let fallback = String( + localized: "Agents in this pane are detected only when they report their status themselves (OSC 7501).") + return "\(reason) \(fallback)" + } + + /// What the pane's own accessibility element says about its `ssh`: the badge is not in the + /// terminal's accessibility tree, so the pane carries the same information. + static func paneAccessibility(_ indicator: RemotePaneIndicator?, now: Date) -> (label: String?, help: String?) { + switch indicator { + case .connected(let host, _, nil): + return (String(localized: "connected to \(host)"), nil) + case .connected(let host, _, let issue?): + return (String(localized: "connected to \(host)"), paneIssueExplanation(issue, host: host)) + case .ended(let host, _) where indicator?.showsEndedMarker(now: now) == true: + return (String(localized: "disconnected from \(host)"), nil) + case .ended, nil: + return (nil, nil) + } + } + static func failureReason(_ failure: RemoteProbeFailure?) -> String { switch failure { case .authentication: diff --git a/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift b/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift index 9ff8c9f7..7ca98bdc 100644 --- a/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift +++ b/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift @@ -16,7 +16,7 @@ struct RemotePaneBadge: View { .allowsHitTesting(false) case .connected(let host, _, let issue?): // The reason is the tooltip, so this capsule (and only it) takes the pointer. - let explanation = RemotePaneBadge.issueExplanation(issue, host: host) + let explanation = RemoteAgentPresentation.paneIssueExplanation(issue, host: host) label(Text(verbatim: host), systemImage: "exclamationmark.triangle.fill", multicolor: true) .help(explanation) .accessibilityLabel(Text("Connected to \(host). Agents are not detected.")) @@ -41,14 +41,6 @@ struct RemotePaneBadge: View { } } - /// Why Prowl does not read this pane's agents, with what still works. - static func issueExplanation(_ issue: RemotePaneCorrelation, host: String) -> String { - let reason = RemoteAgentPresentation.correlationExplanation(issue, host: host) - let fallback = String( - localized: "Agents in this pane are detected only when they report their status themselves (OSC 7501).") - return "\(reason) \(fallback)" - } - private func label(_ text: Text, systemImage: String, multicolor: Bool = false) -> some View { Label { text.lineLimit(1).truncationMode(.middle) diff --git a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift index d9f096a1..cb948143 100644 --- a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift +++ b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift @@ -22,6 +22,13 @@ extension GhosttySurfaceView { } override func accessibilityLabel() -> String? { + let label = baseAccessibilityLabel + guard let remote = RemoteAgentPresentation.paneAccessibility(bridge.state.remoteIndicator, now: Date()).label + else { return label } + return "\(label), \(remote)" + } + + private var baseAccessibilityLabel: String { let title = bridge.state.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if !title.isEmpty { return title @@ -38,7 +45,9 @@ extension GhosttySurfaceView { } override func accessibilityHelp() -> String? { - accessibilityPaneIndexHelp + let remote = RemoteAgentPresentation.paneAccessibility(bridge.state.remoteIndicator, now: Date()).help + let parts = [accessibilityPaneIndexHelp, remote].compactMap(\.self) + return parts.isEmpty ? nil : parts.joined(separator: " ") } override func accessibilitySelectedTextRange() -> NSRange { diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 93bd6f11..4b6f7060 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -358,6 +358,26 @@ struct RemoteAgentDetectionTests { host: "onevcat@mini.local", ssh: sshGeneration, issue: nil)) } + @Test func thePaneAccessibilityElementCarriesTheRemoteState() { + let surface = GhosttySurfaceView( + runtime: GhosttyRuntime(), workingDirectory: URL(fileURLWithPath: "/tmp", isDirectory: true), fontSize: nil, + context: GHOSTTY_SURFACE_CONTEXT_TAB, skipsSurfaceCreationForTesting: true) + surface.bridge.state.title = "Claude Code" + surface.setAccessibilityPaneIndex(index: 1, total: 2) + #expect(surface.accessibilityLabel() == "Claude Code") + #expect(surface.accessibilityHelp() == "Pane 1 of 2") + surface.bridge.state.remoteIndicator = .connected(host: "dev@box", ssh: sshGeneration, issue: .unmapped) + #expect(surface.accessibilityLabel() == "Claude Code, connected to dev@box") + #expect(surface.accessibilityHelp()?.hasPrefix("Pane 1 of 2 dev@box has no session for this pane.") == true) + surface.bridge.state.remoteIndicator = .ended(host: "dev@box", endedAt: Date()) + #expect(surface.accessibilityLabel() == "Claude Code, disconnected from dev@box") + // An old exit is not announced. + surface.bridge.state.remoteIndicator = .ended(host: "dev@box", endedAt: Date().addingTimeInterval(-10)) + #expect(surface.accessibilityLabel() == "Claude Code") + surface.bridge.state.remoteIndicator = nil + #expect(surface.accessibilityHelp() == "Pane 1 of 2") + } + @Test func anUnreachableHostIsReportedWithoutAnAgent() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) @@ -373,7 +393,7 @@ struct RemoteAgentDetectionTests { } #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) #expect( - RemotePaneBadge.issueExplanation(.probeUnavailable(.authentication), host: "h") + RemoteAgentPresentation.paneIssueExplanation(.probeUnavailable(.authentication), host: "h") == "Prowl cannot read h: the key was refused or needs a prompt. " + "Agents in this pane are detected only when they report their status themselves (OSC 7501).") } diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index a960d78d..afe84512 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -346,6 +346,11 @@ label, the ssh-ended marker, and turning the flag on. shows a multicolor warning glyph. Only that capsule takes the pointer, so its tooltip (`RemotePaneBadge.issueExplanation`, reusing `RemoteAgentPresentation`) can say why, and that only OSC 7501 producers are detected. +- **Accessibility (review round 3).** The split container lists only the pane views as AX + children, so the badge's own metadata cannot be reached. The pane's AX element carries + the state itself, through `RemoteAgentPresentation.paneAccessibility`. The label gains + ", connected to host" or ", disconnected from host" (only while the ended marker shows), + and the help gains the issue explanation after "Pane n of m". - **Pane.** `RemotePaneBadge` sits in the bottom-trailing corner: a material capsule with a network glyph and the host. It does not take hits. After an exit it reads "Disconnected from host" with `network.slash` until five seconds after the exit (`showsEndedMarker`, -- 2.54.0 From 45fab958218a697e35fdb8caf7e2821d99dfc631 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 05:06:40 +0900 Subject: [PATCH 33/51] Announce changes to a pane's spoken remote state When the pane's accessibility label or help changes with its remote indicator, post titleChanged for the pane, as the title path does. --- ...WorktreeTerminalState+AgentDetection.swift | 12 ++++++++ .../Models/WorktreeTerminalState.swift | 1 + App/Tests/RemoteAgentDetectionTests.swift | 30 +++++++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 5 +++- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 7f139e19..160fd60c 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import GhosttyKit @@ -386,8 +387,19 @@ extension WorktreeTerminalState { func setRemoteIndicator(_ indicator: RemotePaneIndicator?, surfaceID: UUID) { guard let view = surfaces[surfaceID], view.bridge.state.remoteIndicator != indicator else { return } + let now = Date() + let spoken = RemoteAgentPresentation.paneAccessibility(view.bridge.state.remoteIndicator, now: now) view.bridge.state.remoteIndicator = indicator if let tabId = tabId(containing: surfaceID) { updateRemoteHosts(for: tabId) } + // The pane's accessibility label and help carry the remote state; like a title change, + // a change there is announced, or a focused VoiceOver user never hears it. + if RemoteAgentPresentation.paneAccessibility(indicator, now: now) != spoken { + if let poster = accessibilityPosterForTesting { + poster(view, .titleChanged) + } else { + NSAccessibility.post(element: view, notification: .titleChanged) + } + } } /// The tab shows the hosts its panes reach; tree changes (moved or closed panes) refresh it diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift index 4af247c8..4b4c48a3 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift @@ -251,6 +251,7 @@ final class WorktreeTerminalState { @ObservationIgnored var remoteFenceHistoryBySurface: [UUID: [LocalProcessGeneration: RemoteFenceHistory]] = [:] @ObservationIgnored var remoteSnapshotSourceForTesting: RemoteSnapshotSource? @ObservationIgnored var remoteAgentDetectionEnabledForTesting: Bool? + @ObservationIgnored var accessibilityPosterForTesting: (@MainActor (Any, NSAccessibility.Notification) -> Void)? var remoteAgentDetectionEnabled: Bool { remoteAgentDetectionEnabledForTesting ?? RemoteAgentDetection.isEnabled } diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 4b6f7060..4b418c9f 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import GhosttyKit import ProwlCLIShared @@ -378,6 +379,35 @@ struct RemoteAgentDetectionTests { #expect(surface.accessibilityHelp() == "Pane 1 of 2") } + @Test func aChangedSpokenRemoteStateIsAnnounced() async throws { + let (ssh, job) = try spawnSSH() + defer { if ssh.isRunning { ssh.terminate() } } + let probe = Probe() + let fixture = makeFixture(probe: probe) + var posts: [NSAccessibility.Notification] = [] + fixture.state.accessibilityPosterForTesting = { _, notification in posts.append(notification) } + probe.job = job + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + // Healthy, then an issue: two announcements. + await deliver(fixture, snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(101, name: "zsh"))) + await detect(fixture) + #expect(posts == [.titleChanged]) + // The pane's session is gone (neither its id nor its pinned anchor is on the host). + await deliver(fixture, snapshot(2, pane: nil, foreground: nil, anchor: 999)) + await detect(fixture) + #expect(posts == [.titleChanged, .titleChanged]) + // An equal sample says nothing new. + await detect(fixture) + #expect(posts.count == 2) + // The `ssh` ends: announced. + ssh.terminate() + ssh.waitUntilExit() + probe.job = shellJob + await detect(fixture) + #expect(posts.count == 3) + } + @Test func anUnreachableHostIsReportedWithoutAnAgent() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index afe84512..cd88f87d 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -350,7 +350,10 @@ label, the ssh-ended marker, and turning the flag on. children, so the badge's own metadata cannot be reached. The pane's AX element carries the state itself, through `RemoteAgentPresentation.paneAccessibility`. The label gains ", connected to host" or ", disconnected from host" (only while the ended marker shows), - and the help gains the issue explanation after "Pane n of m". + and the help gains the issue explanation after "Pane n of m". When the spoken label or + help changes, `setRemoteIndicator` posts `.titleChanged` for the pane, as the title path + does, so a focused VoiceOver user hears it (follow-up review). An equal sample posts + nothing. - **Pane.** `RemotePaneBadge` sits in the bottom-trailing corner: a material capsule with a network glyph and the host. It does not take hits. After an exit it reads "Disconnected from host" with `network.slash` until five seconds after the exit (`showsEndedMarker`, -- 2.54.0 From c81c80090ed81db98a1e7a6ee63092c37c488a32 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 05:28:35 +0900 Subject: [PATCH 34/51] Map tmux clients to their active pane in the remote probe Probe format 2 adds records for a tmux client in a session's foreground: its active pane (through the server the client talks to, never the inherited environment), that pane's foreground job, and its visible screen, capped at 64 KiB. --- .../RemoteHost/RemoteProbeParser.swift | 114 +++++-- .../RemoteHost/RemoteProbeReport.swift | 43 ++- .../RemoteHost/RemoteProbeScript.swift | 306 ++++++++++++------ App/Tests/RemoteProbeParserTests.swift | 134 +++++++- docs-ai/082-ssh-remote-agents/001-action.md | 44 ++- 5 files changed, 505 insertions(+), 136 deletions(-) diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift index ef81823c..34bf0d31 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift @@ -2,9 +2,9 @@ import Foundation /// Parses the framed output of `RemoteProbeScript` (docs-ai 082, C6). /// -/// Only a report with a matching header, the host record, well-formed records, and an -/// `END` record whose counts match is complete. Anything else is partial, and a partial -/// report never proves that a process is gone. +/// Only a report with a matching header, the host record, well-formed records, a screen +/// for every tmux pane, and an `END` record whose counts match is complete. Anything else +/// is partial, and a partial report never proves that a process is gone. nonisolated enum RemoteProbeParser { enum Result: Equatable, Sendable { case complete(RemoteProbeReport) @@ -12,7 +12,7 @@ nonisolated enum RemoteProbeParser { case unrecognized } - static let formatVersion = "1" + static let formatVersion = "2" static func parse(_ text: String) -> Result { parse(Data(text.utf8)) @@ -142,10 +142,20 @@ nonisolated enum RemoteProbeParser { } } + /// The counts an `END` record declares. + private struct Counts { + let sessions: Int + let processes: Int + let clients: Int + let paneProcesses: Int + } + private struct ReportBuilder { var host: (system: RemoteProbeReport.OperatingSystem, bootID: String)? var sessions: [SessionBuilder] = [] - var ended: (sessions: Int, processes: Int)? + /// The session whose tmux pane the latest `X` record opened; `Y`, `Z` and `C` belong to it. + var tmuxSession: Int? + var ended: Counts? var malformed = false /// Applies one record; false when it is malformed or out of place. @@ -153,22 +163,64 @@ nonisolated enum RemoteProbeParser { guard ended == nil else { return false } switch record.first { case "H": return applyHost(record) - case "S": return applySession(record) + case "S": return tmuxSession == nil && applySession(record) case "P": - guard !sessions.isEmpty, let process = RemoteProbeParser.process(record) else { return false } - sessions[sessions.count - 1].processes.append(process) - return true - case "D": return applyDirectory(record) - case "END": - guard record.count == 3, let sessionCount = Int(record[1]), let processCount = Int(record[2]) else { + guard tmuxSession == nil, !sessions.isEmpty, let process = RemoteProbeParser.process(record) else { return false } - ended = (sessionCount, processCount) + sessions[sessions.count - 1].processes.append(process) + return true + case "D": + guard tmuxSession == nil, !sessions.isEmpty else { return false } + return Self.applyDirectory(record, to: &sessions[sessions.count - 1].processes) + case "X": return applyTmuxClient(record) + case "Y": + guard let index = tmuxSession, let process = RemoteProbeParser.process(record) else { return false } + sessions[index].tmux?.processes.append(process) + return true + case "Z": + guard let index = tmuxSession, var tmux = sessions[index].tmux, + Self.applyDirectory(record, to: &tmux.processes) + else { return false } + sessions[index].tmux = tmux return true + case "C": return applyScreen(record) + case "END": return applyEnd(record) default: return false } } + private mutating func applyEnd(_ record: [String]) -> Bool { + guard record.count == 5, let sessionCount = Int(record[1]), let processCount = Int(record[2]), + let clientCount = Int(record[3]), let paneProcessCount = Int(record[4]) + else { return false } + ended = Counts( + sessions: sessionCount, processes: processCount, clients: clientCount, paneProcesses: paneProcessCount) + return true + } + + /// `X` names its session by anchor pid, so it may follow every session; one per session. + private mutating func applyTmuxClient(_ record: [String]) -> Bool { + guard record.count == 8, let anchor = Int32(record[2]), let client = Int32(record[3]), + let tpgid = Int32(record[7]), record[6] == "0" || record[6] == "1", + let index = sessions.firstIndex(where: { $0.anchorPID == anchor }), sessions[index].tmux == nil + else { return false } + sessions[index].tmux = TmuxBuilder( + clientPID: client, paneID: record[4], tty: record[5] == "-" ? nil : record[5], isInMode: record[6] == "1", + foregroundProcessGroupID: tpgid) + tmuxSession = index + return true + } + + private mutating func applyScreen(_ record: [String]) -> Bool { + guard record.count == 4, record[2] == "0" || record[2] == "1", let index = tmuxSession, + sessions[index].tmux?.screen == nil + else { return false } + sessions[index].tmux?.screen = record[3] + sessions[index].tmux?.isScreenTruncated = record[2] == "1" + return true + } + private mutating func applyHost(_ record: [String]) -> Bool { guard host == nil, record.count == 3, let system = RemoteProbeReport.OperatingSystem(rawValue: record[1]), record[2] != "-" @@ -186,9 +238,10 @@ nonisolated enum RemoteProbeParser { return true } - private mutating func applyDirectory(_ record: [String]) -> Bool { - guard record.count == 5, let pid = Int32(record[2]), !sessions.isEmpty, - let index = sessions[sessions.count - 1].processes.lastIndex(where: { $0.pid == pid }) + /// A `D` or `Z` record: the working directory of the latest process with that pid. + private static func applyDirectory(_ record: [String], to processes: inout [ProcessBuilder]) -> Bool { + guard record.count == 5, let pid = Int32(record[2]), + let index = processes.lastIndex(where: { $0.pid == pid }) else { return false } let path: String switch record[3] { @@ -196,7 +249,7 @@ nonisolated enum RemoteProbeParser { case "l": path = RemoteProbeParser.lsofName(record[4]) default: return false } - sessions[sessions.count - 1].processes[index].workingDirectory = path + processes[index].workingDirectory = path return true } @@ -204,24 +257,47 @@ nonisolated enum RemoteProbeParser { guard let host else { return .partial(nil) } let report = RemoteProbeReport( operatingSystem: host.system, bootID: host.bootID, sessions: sessions.map { $0.build() }) + let panes = sessions.compactMap(\.tmux) guard !malformed, let ended, ended.sessions == sessions.count, - ended.processes == sessions.reduce(0, { $0 + $1.processes.count }) + ended.processes == sessions.reduce(0, { $0 + $1.processes.count }), ended.clients == panes.count, + ended.paneProcesses == panes.reduce(0, { $0 + $1.processes.count }), + // A pane whose screen record is missing was cut off. + panes.allSatisfy({ $0.screen != nil }) else { return .partial(report) } return .complete(report) } } + private struct TmuxBuilder { + let clientPID: Int32 + let paneID: String + let tty: String? + let isInMode: Bool + let foregroundProcessGroupID: Int32 + var processes: [ProcessBuilder] = [] + var screen: String? + var isScreenTruncated = false + + func build() -> RemoteProbeTmuxPane { + RemoteProbeTmuxPane( + clientPID: clientPID, paneID: paneID, tty: tty, isInMode: isInMode, + foregroundProcessGroupID: foregroundProcessGroupID, processes: processes.map { $0.build() }, + screen: screen, isScreenTruncated: isScreenTruncated) + } + } + private struct SessionBuilder { let paneID: UUID? let anchorPID: Int32 let tty: String? let foregroundProcessGroupID: Int32 var processes: [ProcessBuilder] = [] + var tmux: TmuxBuilder? func build() -> RemoteProbeSession { RemoteProbeSession( paneID: paneID, anchorPID: anchorPID, tty: tty, foregroundProcessGroupID: foregroundProcessGroupID, - processes: processes.map { $0.build() }) + processes: processes.map { $0.build() }, tmux: tmux?.build()) } } } diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift index 714c053c..c454a1c9 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift @@ -24,6 +24,28 @@ nonisolated struct RemoteProbeSession: Equatable, Sendable { let tty: String? let foregroundProcessGroupID: Int32 let processes: [RemoteProbeProcess] + /// The tmux pane this session shows, when a tmux client is in its foreground. + var tmux: RemoteProbeTmuxPane? +} + +/// The active pane of a tmux client in a session's foreground (docs-ai 082, L0 item 6). The +/// client maps to its pane through the server it talks to, never through the inherited +/// environment, which names the server's first client. +nonisolated struct RemoteProbeTmuxPane: Equatable, Sendable { + static let screenLimit = 64 * 1024 + + let clientPID: Int32 + /// tmux's pane id, such as `%3`. + let paneID: String + let tty: String? + /// The pane shows a mode (copy mode, a chooser) instead of its program. + let isInMode: Bool + let foregroundProcessGroupID: Int32 + let processes: [RemoteProbeProcess] + /// The pane's visible screen, as `capture-pane` prints it. + let screen: String? + /// The screen passed `screenLimit` and was cut. + let isScreenTruncated: Bool } /// The parsed body of one probe run. @@ -42,10 +64,27 @@ extension RemoteProbeSession { /// The session's foreground job as the classifier reads it: remote references that /// carry the host's boot id and each process's native start token. nonisolated func foregroundJob(connection: ConnectionKey, bootID: String) -> ForegroundJob? { - let members = processes.filter { $0.processGroupID == foregroundProcessGroupID } + RemoteProbeProcess.job( + processes, processGroupID: foregroundProcessGroupID, connection: connection, bootID: bootID) + } +} + +extension RemoteProbeTmuxPane { + /// The pane's foreground job, as `RemoteProbeSession.foregroundJob` builds it. + nonisolated func foregroundJob(connection: ConnectionKey, bootID: String) -> ForegroundJob? { + RemoteProbeProcess.job( + processes, processGroupID: foregroundProcessGroupID, connection: connection, bootID: bootID) + } +} + +extension RemoteProbeProcess { + nonisolated static func job( + _ processes: [RemoteProbeProcess], processGroupID: Int32, connection: ConnectionKey, bootID: String + ) -> ForegroundJob? { + let members = processes.filter { $0.processGroupID == processGroupID } guard !members.isEmpty else { return nil } return ForegroundJob( - processGroupID: foregroundProcessGroupID, + processGroupID: processGroupID, processes: members.map { process in let argv0 = process.arguments?.first ?? process.commandLine.split(separator: " ").first.map(String.init) return ForegroundProcess( diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift index aa82c393..0bb49735 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift @@ -1,8 +1,9 @@ import Foundation /// The remote process probe (docs-ai 082, C6): a POSIX `sh` script that Prowl pipes to -/// `sh -s` over its side channel. It installs nothing and reads only the process table, -/// so the host needs no Prowl file, configuration, or hook. `RemoteProbeParser` reads +/// `sh -s` over its side channel. It installs nothing; it reads the process table and, for +/// a tmux client in a session's foreground, asks that client's tmux server for its active +/// pane and visible screen. The host needs no Prowl file, configuration, or hook. `RemoteProbeParser` reads /// its output; the format is described in the script header. nonisolated enum RemoteProbeScript { static let text = #""" @@ -19,12 +20,20 @@ nonisolated enum RemoteProbeScript { # s = one space-joined string # D r = raw bytes, l = lsof's escaped # display form (macOS) - # END

+ # X + # a tmux client in that session's + # foreground, and its active pane + # Y, Z as P and D, for that pane's + # foreground job + # C that pane's visible screen + # END

# A snapshot without END is incomplete. LC_ALL=C export LC_ALL uid=$(id -u) - echo "PROWL-PROBE 1" + cap=65536 + echo "PROWL-PROBE 2" + clients= if [ -r /proc/self/stat ]; then boot=$(cat /proc/sys/kernel/random/boot_id 2>/dev/null) @@ -46,6 +55,28 @@ nonisolated enum RemoteProbeScript { # pid ppid pgrp tpgid starttime comm printf "%s %s %s %s %s %s\n", substr(line, 1, open - 2), f[2], f[3], f[6], f[20], comm }') + # member : one P or Y record, with its D or Z record. + member() { + # The trailing x keeps trailing newlines that command substitution would strip. + argv=$(tr '\0' '\037' 2>/dev/null < "/proc/$3/cmdline"; printf x) + argv=${argv%x} + cwd=$(readlink "/proc/$3/cwd" 2>/dev/null && printf x) + [ -n "$cwd" ] && cwd=${cwd%?x} + # Untrusted strings reach awk through ENVIRON: `-v` would decode backslash escapes. + printf '%s\n' "$table" | PROWL_PANE="$2" PROWL_ARGV="$argv" PROWL_CWD="$cwd" \ + awk -v m="$3" -v up="$up" -v hz="$hz" -v rec="$1" ' + function esc(s) { + gsub(/%/, "%25", s); gsub(/\t/, "%09", s); gsub(/\n/, "%0A", s); gsub(/\r/, "%0D", s); return s + } + $1 == m { + pane = ENVIRON["PROWL_PANE"]; argv = ENVIRON["PROWL_ARGV"]; cwd = ENVIRON["PROWL_CWD"] + comm = $0; sub(/^[^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ /, "", comm) + sub(/\037$/, "", argv) + printf "%s\t%s\t%s\t%s\t%s\t%s\t%d\t%s\tv\t%s\n", \ + rec, esc(pane), $1, $2, $3, $5, up - $5 / hz, esc(comm), esc(argv) + if (cwd != "") printf "%s\t%s\t%s\tr\t%s\n", (rec == "P" ? "D" : "Z"), esc(pane), $1, esc(cwd) + }' + } anchors=$(printf '%s\n' "$table" | awk ' { pid[$1] = $1; ppid[$1] = $2; comm[$1] = $6; tpgid[$1] = $4 } END { @@ -64,111 +95,184 @@ nonisolated enum RemoteProbeScript { printf 'S\t%s\t%s\t%s\t%s\n' "${pane:--}" "$apid" "${tty:--}" "$tpgid" scount=$((scount + 1)) for m in $(printf '%s\n' "$table" | awk -v g="$tpgid" '$3 == g { print $1 }'); do - # The trailing x keeps trailing newlines that command substitution would strip. - argv=$(tr '\0' '\037' 2>/dev/null < "/proc/$m/cmdline"; printf x) - argv=${argv%x} - cwd=$(readlink "/proc/$m/cwd" 2>/dev/null && printf x) - [ -n "$cwd" ] && cwd=${cwd%?x} - # Untrusted strings reach awk through ENVIRON: `-v` would decode backslash escapes. - printf '%s\n' "$table" | PROWL_PANE="${pane:--}" PROWL_ARGV="$argv" PROWL_CWD="$cwd" \ - awk -v m="$m" -v up="$up" -v hz="$hz" ' - function esc(s) { - gsub(/%/, "%25", s); gsub(/\t/, "%09", s); gsub(/\n/, "%0A", s); gsub(/\r/, "%0D", s); return s - } - $1 == m { - pane = ENVIRON["PROWL_PANE"]; argv = ENVIRON["PROWL_ARGV"]; cwd = ENVIRON["PROWL_CWD"] - comm = $0; sub(/^[^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ /, "", comm) - sub(/\037$/, "", argv) - printf "P\t%s\t%s\t%s\t%s\t%s\t%d\t%s\tv\t%s\n", \ - esc(pane), $1, $2, $3, $5, up - $5 / hz, esc(comm), esc(argv) - if (cwd != "") printf "D\t%s\t%s\tr\t%s\n", esc(pane), $1, esc(cwd) - }' + member P "${pane:--}" "$m" pcount=$((pcount + 1)) + # tmux renames its client `tmux: client` on Linux. + comm=$(printf '%s\n' "$table" | awk -v m="$m" '$1 == m { sub(/^[^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ /, ""); print }') + case "$comm" in + tmux | "tmux: client") + clients="$clients$apid ${pane:--} $m $(readlink "/proc/$m/exe" 2>/dev/null) + " + ;; + esac done done - printf 'END\t%s\t%s\n' "$scount" "$pcount" - exit 0 - fi - - # macOS: the environment of platform binaries (the /bin/zsh login shell) is - # hidden, so the anchor is "parent is sshd-session with a terminal" and the pane - # id comes from the foreground job's own environment. - boot=$(sysctl -n kern.boottime 2>/dev/null | sed -n 's/^{ sec = \([0-9]*\), usec = \([0-9]*\) }.*/\1.\2/p') - echo "H Darwin ${boot:--}" - names=$(ps -A -o pid=,ucomm=) - table=$(ps -A -ww -o pid=,ppid=,pgid=,tpgid=,uid=,tty=,etime=,lstart=,args=) - members=$(printf '%s\n' "$table" | awk -v uid="$uid" ' - { pid = $1; ppid[pid] = $2; tpgid[pid] = $4; owner[pid] = $5; tty[pid] = $6; pgid[pid] = $3 - a = $0; for (i = 1; i <= 12; i++) sub(/^ *[^ ]+/, "", a); sub(/^ +/, "", a); args[pid] = a } - END { - for (p in ppid) { - if (owner[p] != uid || tty[p] == "??") continue - if (args[ppid[p]] !~ /^sshd(-session)?: /) continue - for (m in ppid) if (pgid[m] == tpgid[p] && tty[m] == tty[p]) print p, tpgid[p], tty[p], m - } - }') - pids=$(printf '%s\n' "$members" | awk 'NF { printf "%s%s", sep, $4; sep = "," }') - envs= - cwds= - if [ -n "$pids" ]; then - envs=$(ps -wwE -o pid=,command= -p "$pids" 2>/dev/null | awk ' - { v = "-" - for (i = 2; i <= NF; i++) if ($i ~ /^LC_PROWL_PANE=/) { v = substr($i, 15); break } - print $1, v }') - cwds=$(lsof -a -d cwd -Fpn -p "$pids" 2>/dev/null) - fi - { - printf '%s\n' "$names" | sed 's/^/N /' - printf '%s\n' "$table" | sed 's/^/T /' - printf '%s\n' "$envs" | sed 's/^/V /' - printf '%s\n' "$cwds" | sed 's/^/W /' - printf '%s\n' "$members" | sed 's/^/M /' - } | awk ' - function esc(s) { - gsub(/%/, "%25", s); gsub(/\t/, "%09", s); gsub(/\n/, "%0A", s); gsub(/\r/, "%0D", s); return s + # pane_job : the members of a tmux pane's foreground job. + pane_job() { + printf '%s\n' "$table" | awk -v g="$1" '$3 == g { print $1 }' } - function seconds(e, n, a, s, d, x, i) { - d = 0; if (e ~ /-/) { split(e, x, "-"); d = x[1]; e = x[2] } - n = split(e, a, ":"); s = 0 - for (i = 1; i <= n; i++) s = s * 60 + a[i] - return s + d * 86400 + pane_tpgid() { + printf '%s\n' "$table" | awk -v p="$1" '$1 == p { print $4 }' } - $1 == "N" { p = $2; c = $0; sub(/^N +[0-9]+ /, "", c); sub(/ +$/, "", c); name[p] = c; next } - $1 == "T" { - p = $2; ppid[p] = $3; pgid[p] = $4; et[p] = $8 - start[p] = $9 " " $10 " " $11 " " $12 " " $13 - a = $0; for (i = 1; i <= 13; i++) sub(/^ *[^ ]+/, "", a); sub(/^ +/, "", a); args[p] = a - next + else + # macOS: the environment of platform binaries (the /bin/zsh login shell) is + # hidden, so the anchor is "parent is sshd-session with a terminal" and the pane + # id comes from the foreground job's own environment. + boot=$(sysctl -n kern.boottime 2>/dev/null | sed -n 's/^{ sec = \([0-9]*\), usec = \([0-9]*\) }.*/\1.\2/p') + echo "H Darwin ${boot:--}" + names=$(ps -A -o pid=,ucomm=) + table=$(ps -A -ww -o pid=,ppid=,pgid=,tpgid=,uid=,tty=,etime=,lstart=,args=) + # render : S (for P only), P or Y, and D or Z records; for P it also + # prints "#COUNT

" and "#TMUX " for tmux clients. + render() { + rpids=$(printf '%s\n' "$2" | awk 'NF { printf "%s%s", sep, $4; sep = "," }') + envs= + cwds= + if [ -n "$rpids" ]; then + envs=$(ps -wwE -o pid=,command= -p "$rpids" 2>/dev/null | awk ' + { v = "-" + for (i = 2; i <= NF; i++) if ($i ~ /^LC_PROWL_PANE=/) { v = substr($i, 15); break } + print $1, v }') + cwds=$(lsof -a -d cwd -Fpn -p "$rpids" 2>/dev/null) + fi + { + printf '%s\n' "$names" | sed 's/^/N /' + printf '%s\n' "$table" | sed 's/^/T /' + printf '%s\n' "$envs" | sed 's/^/V /' + printf '%s\n' "$cwds" | sed 's/^/W /' + printf '%s\n' "$2" | sed 's/^/M /' + } | awk -v rec="$1" ' + function esc(s) { + gsub(/%/, "%25", s); gsub(/\t/, "%09", s); gsub(/\n/, "%0A", s); gsub(/\r/, "%0D", s); return s + } + function seconds(e, n, a, s, d, x, i) { + d = 0; if (e ~ /-/) { split(e, x, "-"); d = x[1]; e = x[2] } + n = split(e, a, ":"); s = 0 + for (i = 1; i <= n; i++) s = s * 60 + a[i] + return s + d * 86400 + } + $1 == "N" { p = $2; c = $0; sub(/^N +[0-9]+ /, "", c); sub(/ +$/, "", c); name[p] = c; next } + $1 == "T" { + p = $2; ppid[p] = $3; pgid[p] = $4; et[p] = $8 + start[p] = $9 " " $10 " " $11 " " $12 " " $13 + a = $0; for (i = 1; i <= 13; i++) sub(/^ *[^ ]+/, "", a); sub(/^ +/, "", a); args[p] = a + next + } + $1 == "V" { pane[$2] = $3; next } + $1 == "W" { + if ($2 ~ /^p/) wp = substr($2, 2) + else if ($2 ~ /^n/) { c = $0; sub(/^W n/, "", c); cwd[wp] = c } + next + } + $1 == "M" && NF == 5 { + a = $2; m = $5 + if (!(a in seen)) { order[++na] = a; seen[a] = 1; tg[a] = $3; tt[a] = $4 } + mem[a] = mem[a] " " m + if (pane[m] != "" && pane[m] != "-") apane[a] = pane[m] + next + } + END { + pc = 0 + for (i = 1; i <= na; i++) { + a = order[i]; pn = (a in apane) ? apane[a] : "-" + if (rec == "P") printf "S\t%s\t%s\t/dev/%s\t%s\n", esc(pn), a, tt[a], tg[a] + if (rec == "Y") pn = a + n = split(mem[a], ms, " ") + for (j = 1; j <= n; j++) { + m = ms[j] + printf "%s\t%s\t%s\t%s\t%s\t%s\t%d\t%s\ts\t%s\n", \ + rec, esc(pn), m, ppid[m], pgid[m], esc(start[m]), seconds(et[m]), esc(name[m]), esc(args[m]) + if (m in cwd) printf "%s\t%s\t%s\tl\t%s\n", (rec == "P" ? "D" : "Z"), esc(pn), m, esc(cwd[m]) + if (rec == "P" && name[m] == "tmux") printf "#TMUX %s %s %s\n", a, pn, m + pc++ + } + } + if (rec == "P") printf "#COUNT %d %d\n", na, pc + }' } - $1 == "V" { pane[$2] = $3; next } - $1 == "W" { - if ($2 ~ /^p/) wp = substr($2, 2) - else if ($2 ~ /^n/) { c = $0; sub(/^W n/, "", c); cwd[wp] = c } - next + members=$(printf '%s\n' "$table" | awk -v uid="$uid" ' + { pid = $1; ppid[pid] = $2; tpgid[pid] = $4; owner[pid] = $5; tty[pid] = $6; pgid[pid] = $3 + a = $0; for (i = 1; i <= 12; i++) sub(/^ *[^ ]+/, "", a); sub(/^ +/, "", a); args[pid] = a } + END { + for (p in ppid) { + if (owner[p] != uid || tty[p] == "??") continue + if (args[ppid[p]] !~ /^sshd(-session)?: /) continue + for (m in ppid) if (pgid[m] == tpgid[p] && tty[m] == tty[p]) print p, tpgid[p], tty[p], m + } + }') + body=$(render P "$members") + printf '%s\n' "$body" | grep -v '^#' | grep -v '^$' + set -- $(printf '%s\n' "$body" | sed -n 's/^#COUNT //p') + scount=${1:-0} + pcount=${2:-0} + for c in $(printf '%s\n' "$body" | sed -n 's/^#TMUX \([^ ]*\) \([^ ]*\) \([^ ]*\)$/\1:\2:\3/p'); do + exe=$(lsof -a -p "${c##*:}" -d txt -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1) + clients="$clients$(printf '%s' "$c" | tr ':' ' ') $exe + " + done + # pane_job : the members of a tmux pane's foreground job. + pane_job() { + printf '%s\n' "$table" | awk -v g="$1" -v t="${2#/dev/}" '$3 == g && $6 == t { print $1 }' } - $1 == "M" && NF == 5 { - a = $2; m = $5 - if (!(a in seen)) { order[++na] = a; seen[a] = 1; tg[a] = $3; tt[a] = $4 } - mem[a] = mem[a] " " m - if (pane[m] != "" && pane[m] != "-") apane[a] = pane[m] - next + pane_tpgid() { + printf '%s\n' "$table" | awk -v p="$1" '$1 == p { print $4 }' } - END { - pc = 0 - for (i = 1; i <= na; i++) { - a = order[i]; pn = (a in apane) ? apane[a] : "-" - printf "S\t%s\t%s\t/dev/%s\t%s\n", esc(pn), a, tt[a], tg[a] - n = split(mem[a], ms, " ") - for (j = 1; j <= n; j++) { - m = ms[j] - printf "P\t%s\t%s\t%s\t%s\t%s\t%d\t%s\ts\t%s\n", \ - esc(pn), m, ppid[m], pgid[m], esc(start[m]), seconds(et[m]), esc(name[m]), esc(args[m]) - if (m in cwd) printf "D\t%s\t%s\tl\t%s\n", esc(pn), m, esc(cwd[m]) - pc++ - } - } - printf "END\t%d\t%d\n", na, pc - }' + fi + + # tmux: map each client in a session's foreground to its active pane through the + # server it talks to (the inherited environment names the first client, so it is + # never used), then report that pane's foreground job and visible screen. + xcount=0 + ycount=0 + tdir="${TMUX_TMPDIR:-/tmp}/tmux-$uid" + while read -r apid pane cpid exe; do + [ -n "$exe" ] && [ -x "$exe" ] || continue + found= + for sock in "$tdir"/*; do + [ -S "$sock" ] || continue + # Spaces, not TAB: under LC_ALL=C tmux prints control characters as `_`. None of + # these fields can hold a space. + found=$("$exe" -S "$sock" list-clients -F '#{client_pid} #{pane_id} #{pane_tty} #{pane_in_mode} #{pane_pid}' \ + 2>/dev/null | awk -v p="$cpid" '$1 == p { print; exit }') + [ -n "$found" ] && break + done + [ -n "$found" ] || continue + # shellcheck disable=SC2086 + set -- $found + tpane=$2 + ptty=$3 + mode=$4 + ppid=$5 + fg=$(pane_tpgid "$ppid") + [ -n "$fg" ] || fg=-1 + printf 'X\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$pane" "$apid" "$cpid" "$tpane" "$ptty" "${mode:-0}" "$fg" + xcount=$((xcount + 1)) + job=$(pane_job "$fg" "$ptty") + if [ -r /proc/self/stat ]; then + for m in $job; do member Y "$pane" "$m"; done + elif [ -n "$job" ]; then + render Y "$(printf '%s\n' "$job" | awk -v a="$pane" -v g="$fg" -v t="${ptty#/dev/}" '{ print a, g, t, $1 }')" + fi + ycount=$((ycount + $(printf '%s\n' "$job" | awk 'NF { n++ } END { print n + 0 }'))) + # The x keeps a trailing newline; one byte past the cap marks the text truncated. + screen=$("$exe" -S "$sock" capture-pane -p -t "$tpane" 2>/dev/null | head -c $((cap + 1)); printf x) + screen=${screen%x} + truncated=0 + if [ "$(printf '%s' "$screen" | wc -c)" -gt "$cap" ]; then + truncated=1 + screen=$(printf '%s' "$screen" | head -c "$cap"; printf x) + screen=${screen%x} + fi + printf '%s' "$screen" | PROWL_PANE="$pane" awk -v t="$truncated" ' + BEGIN { RS = "\001"; ORS = "" } + { s = $0; gsub(/%/, "%25", s); gsub(/\t/, "%09", s); gsub(/\n/, "%0A", s); gsub(/\r/, "%0D", s) + p = ENVIRON["PROWL_PANE"]; gsub(/%/, "%25", p); gsub(/\t/, "%09", p) + printf "C\t%s\t%s\t%s\n", p, t, s; done = 1 } + END { if (!done) printf "C\t%s\t%s\t\n", ENVIRON["PROWL_PANE"], t }' + done <'` with distinct pane ids. nonisolated static let macOSAgents = [ - "PROWL-PROBE\t1", + "PROWL-PROBE\t2", "H\tDarwin\t1783703386.707560", "S\t22222222-2222-4222-8222-222222222222\t59282\t/dev/ttys000\t59282", "P\t22222222-2222-4222-8222-222222222222\t59282\t59281\t59282\tSat Oct 17 20:50:38 2026\t20\tcodex\ts\tcodex", @@ -17,14 +17,14 @@ struct RemoteProbeParserTests { "S\t11111111-1111-4111-8111-111111111111\t59286\t/dev/ttys001\t59286", "P\t11111111-1111-4111-8111-111111111111\t59286\t59284\t59286\tSat Oct 17 20:50:38 2026\t20\t2.1.302\ts\tclaude", "D\t11111111-1111-4111-8111-111111111111\t59286\tl\t/private/tmp", - "END\t2\t2", + "END\t2\t2\t0\t0", ].joined(separator: "\n") + "\n" /// Recorded in a Debian trixie container (OpenSSH 10.0p2). The second session runs /// `exec -a claude sleep 30` in a directory whose name holds a space, a tab, and `%`. nonisolated static let linux = [ - "PROWL-PROBE\t1", + "PROWL-PROBE\t2", "H\tLinux\td8e2436d-c605-4e3b-92d7-57b6424c1e78", "S\t44444444-4444-4444-8444-444444444444\t179\t/dev/pts/1\t179", "P\t44444444-4444-4444-8444-444444444444\t179\t178\t179\t29059914\t3\tsleep\tv\tsleep\u{1F}25", @@ -32,7 +32,49 @@ struct RemoteProbeParserTests { "S\t33333333-3333-4333-8333-333333333333\t123\t/dev/pts/0\t123", "P\t33333333-3333-4333-8333-333333333333\t123\t116\t123\t29057954\t22\tsleep\tv\tclaude\u{1F}30", "D\t33333333-3333-4333-8333-333333333333\t123\tr\t/tmp/odd dir/t%09ab%25x", - "END\t2\t2", + "END\t2\t2\t0\t0", + ].joined(separator: "\n") + "\n" + + /// Recorded on the macOS host: Claude Code inside `tmux -L s5spike` in an `ssh -t` + /// session. The client in the session's foreground maps to pane `%0`, whose foreground + /// job is Claude. + nonisolated static let macOSTmux = + [ + "PROWL-PROBE\t2", + "H\tDarwin\t1783703386.719173", + "S\t55555555-5555-4555-8555-555555555555\t19755\t/dev/ttys000\t19854", + "P\t55555555-5555-4555-8555-555555555555\t19854\t19755\t19854\tSun Oct 18 05:09:52 2026\t27" + + "4\ttmux\ts\ttmux -L s5spike new -s spike", + "D\t55555555-5555-4555-8555-555555555555\t19854\tl\t/private/tmp", + "X\t55555555-5555-4555-8555-555555555555\t19755\t19854\t%0\t/dev/ttys001\t0\t20003", + "Y\t55555555-5555-4555-8555-555555555555\t20003\t19862\t20003\tSun Oct 18 05:09:55 2026\t271\t2.1.302\ts\tclaude", + "Z\t55555555-5555-4555-8555-555555555555\t20003\tl\t/private/tmp", + "C\t55555555-5555-4555-8555-555555555555\t0\t%0A Claude Code v2.1.302%0A ▐▛███▛█ " + + " Fable 5.1 with high eff…%0A▝▜██████▀ Claude Max%0A ▝▝ ▝▝ /private/tmp%0A%0A%0A%0A%0" + + "A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A────────────────────" + + "───────────────────%0A❯ Try \"how do I log an error?\"%0A─────────────────────────────────" + + "──────%0A ⏵⏵ auto mode on (shift+tab to%0A", + "END\t1\t1\t1\t1", + ].joined(separator: "\n") + "\n" + + /// Recorded in the Debian container: `sleep 600` inside the default tmux server, whose + /// client Linux renames `tmux: client`. + nonisolated static let linuxTmux = + [ + "PROWL-PROBE\t2", + "H\tLinux\td8e2436d-c605-4e3b-92d7-57b6424c1e78", + "S\t66666666-6666-4666-8666-666666666666\t17638\t/dev/pts/0\t17727", + "P\t66666666-6666-4666-8666-666666666666\t17727\t17638\t17727\t32075522\t15\ttmux: client\t" + + "v\ttmux\u{1F}new\u{1F}-s\u{1F}lspike", + "D\t66666666-6666-4666-8666-666666666666\t17727\tr\t/home/dev", + "X\t66666666-6666-4666-8666-666666666666\t17638\t17727\t%0\t/dev/pts/1\t0\t17762", + "Y\t66666666-6666-4666-8666-666666666666\t17762\t17730\t17762\t32075727\t13\tsleep\tv\tsleep\u{1F}600", + "Z\t66666666-6666-4666-8666-666666666666\t17762\tr\t/tmp", + "C\t66666666-6666-4666-8666-666666666666\t0\t-bash: warning: setlocale: LC_ALL: cann%0Aot c" + + "hange locale (en_US.UTF-8): No such%0A file or directory%0Adev@fc79a48e2ad3:~$ cd /tmp && " + + "sleep 60%0A0%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A%0A" + + "%0A%0A%0A%0A%0A%0A", + "END\t1\t1\t1\t1", ].joined(separator: "\n") + "\n" private static let mini = ConnectionKey(user: "onevcat", hostname: "mini.local") @@ -88,6 +130,70 @@ struct RemoteProbeParserTests { identifyAgentInJob(try #require(report.sessions[0].foregroundJob(connection: Self.mini, bootID: "b"))) == nil) } + @Test func aTmuxClientMapsToItsActivePaneOnMacOS() throws { + let report = try report(Self.macOSTmux) + let session = try #require(report.sessions.first) + // The session's own foreground is the tmux client, which is no agent. + #expect( + identifyAgentInJob(try #require(session.foregroundJob(connection: Self.mini, bootID: report.bootID))) == nil) + let pane = try #require(session.tmux) + #expect(pane.clientPID == 19854) + #expect(pane.paneID == "%0") + #expect(pane.tty == "/dev/ttys001") + #expect(!pane.isInMode) + #expect(!pane.isScreenTruncated) + let job = try #require(pane.foregroundJob(connection: Self.mini, bootID: report.bootID)) + let claude = try #require(identifyAgentInJob(job)) + #expect(claude.agent == .claude) + #expect(claude.process.ref.namespacePID == 20003) + #expect(pane.processes.first?.workingDirectory == "/private/tmp") + #expect(pane.screen?.contains("Claude Code v2.1.302") == true) + #expect(pane.screen?.contains("\n❯") == true) + } + + @Test func aTmuxClientMapsToItsActivePaneOnLinux() throws { + let report = try report(Self.linuxTmux) + let pane = try #require(report.sessions.first?.tmux) + #expect(pane.paneID == "%0") + #expect(pane.tty == "/dev/pts/1") + #expect(pane.processes.map(\.arguments) == [["sleep", "600"]]) + #expect(pane.processes.first?.workingDirectory == "/tmp") + #expect(pane.screen?.contains("cd /tmp && sleep 60\n0") == true) + } + + @Test func tmuxRecordsAreCheckedLikeTheRest() { + let head = [ + "PROWL-PROBE\t2", "H\tLinux\tb", "S\t-\t10\t/dev/pts/0\t11", "P\t-\t11\t10\t11\t5\t1\ttmux\tv\ttmux", + ] + let client = "X\t-\t10\t11\t%3\t/dev/pts/1\t1\t20" + let member = "Y\t-\t20\t19\t20\t6\t1\tcodex\tv\tcodex" + func parse(_ lines: [String]) -> RemoteProbeParser.Result { + RemoteProbeParser.parse((head + lines).joined(separator: "\n") + "\n") + } + guard case .complete(let report) = parse([client, member, "C\t-\t1\t> hi", "END\t1\t1\t1\t1"]) else { + Issue.record("expected complete") + return + } + #expect(report.sessions.first?.tmux?.isInMode == true) + #expect(report.sessions.first?.tmux?.isScreenTruncated == true) + #expect(report.sessions.first?.tmux?.screen == "> hi") + // No screen record: cut off. + #expect(!isComplete(parse([client, member, "END\t1\t1\t1\t1"]))) + // A client of an unknown session, a pane member before any client, two clients for one + // session, and a session record after a client are malformed. + #expect(!isComplete(parse(["X\t-\t99\t11\t%3\t/dev/pts/1\t0\t20", "C\t-\t0\t", "END\t1\t1\t1\t0"]))) + #expect(!isComplete(parse([member, "END\t1\t1\t0\t1"]))) + #expect(!isComplete(parse([client, "C\t-\t0\t", client, "C\t-\t0\t", "END\t1\t1\t2\t0"]))) + #expect(!isComplete(parse([client, "C\t-\t0\t", "S\t-\t30\t/dev/pts/2\t30", "END\t2\t1\t1\t0"]))) + // Counts include pane members. + #expect(!isComplete(parse([client, member, "C\t-\t0\t", "END\t1\t1\t1\t0"]))) + } + + private func isComplete(_ result: RemoteProbeParser.Result) -> Bool { + if case .complete = result { return true } + return false + } + @Test func missingOrMismatchedEndRecordIsPartial() { let lines = Self.linux.split(separator: "\n").map(String.init) let truncated = lines.dropLast().joined(separator: "\n") + "\n" @@ -97,7 +203,7 @@ struct RemoteProbeParserTests { } #expect(partial?.sessions.count == 2) - let mismatched = (lines.dropLast() + ["END\t2\t3"]).joined(separator: "\n") + let mismatched = (lines.dropLast() + ["END\t2\t3\t0\t0"]).joined(separator: "\n") guard case .partial = RemoteProbeParser.parse(mismatched) else { Issue.record("expected partial") return @@ -106,8 +212,8 @@ struct RemoteProbeParserTests { @Test func malformedRecordsMakeTheReportPartial() { let malformed = [ - "PROWL-PROBE\t1", "H\tLinux\tboot", "S\t-\t10\t/dev/pts/0\t10", - "P\t-\t10\t9\t10\tstart\tnot-a-number\tbash\tv\tbash", "END\t1\t0", + "PROWL-PROBE\t2", "H\tLinux\tboot", "S\t-\t10\t/dev/pts/0\t10", + "P\t-\t10\t9\t10\tstart\tnot-a-number\tbash\tv\tbash", "END\t1\t0\t0\t0", ].joined(separator: "\n") guard case .partial = RemoteProbeParser.parse(malformed) else { Issue.record("expected partial") @@ -122,9 +228,9 @@ struct RemoteProbeParserTests { @Test func aNonUTF8FieldGarblesOnlyItself() throws { var data = Data( - "PROWL-PROBE\t1\nH\tLinux\tb\nS\t-\t10\t/dev/pts/0\t10\nP\t-\t10\t9\t10\t5\t1\tbash\tv\tbash\u{1F}".utf8) + "PROWL-PROBE\t2\nH\tLinux\tb\nS\t-\t10\t/dev/pts/0\t10\nP\t-\t10\t9\t10\t5\t1\tbash\tv\tbash\u{1F}".utf8) data.append(contentsOf: [0xFF, 0xFE]) - data.append(Data("\nEND\t1\t1\n".utf8)) + data.append(Data("\nEND\t1\t1\t0\t0\n".utf8)) guard case .complete(let report) = RemoteProbeParser.parse(data) else { Issue.record("expected complete") return @@ -148,14 +254,14 @@ struct RemoteProbeParserTests { } @Test func directoryEncodingIsChecked() { - let base = ["PROWL-PROBE\t1", "H\tDarwin\tb", "S\t-\t10\t/dev/ttys000\t10", "P\t-\t10\t9\t10\ts\t1\tzsh\ts\tzsh"] - let lsof = (base + ["D\t-\t10\tl\t" + #"/Users/me/\xe4\xb8\xad"#, "END\t1\t1"]).joined(separator: "\n") + let base = ["PROWL-PROBE\t2", "H\tDarwin\tb", "S\t-\t10\t/dev/ttys000\t10", "P\t-\t10\t9\t10\ts\t1\tzsh\ts\tzsh"] + let lsof = (base + ["D\t-\t10\tl\t" + #"/Users/me/\xe4\xb8\xad"#, "END\t1\t1\t0\t0"]).joined(separator: "\n") guard case .complete(let report) = RemoteProbeParser.parse(lsof) else { Issue.record("expected complete") return } #expect(report.sessions.first?.processes.first?.workingDirectory == "/Users/me/中") - let unknown = (base + ["D\t-\t10\tq\t/x", "END\t1\t1"]).joined(separator: "\n") + let unknown = (base + ["D\t-\t10\tq\t/x", "END\t1\t1\t0\t0"]).joined(separator: "\n") guard case .partial = RemoteProbeParser.parse(unknown) else { Issue.record("expected partial") return @@ -165,7 +271,9 @@ struct RemoteProbeParserTests { @Test func foreignOutputIsUnrecognized() { #expect(RemoteProbeParser.parse("") == .unrecognized) #expect(RemoteProbeParser.parse("Welcome to the host!\nPROWL-PROBE\t1\n") == .unrecognized) - #expect(RemoteProbeParser.parse("PROWL-PROBE\t2\nH\tLinux\tb\nEND\t0\t0\n") == .unrecognized) + // A format this parser does not know. + #expect(RemoteProbeParser.parse("PROWL-PROBE\t1\nH\tLinux\tb\nEND\t0\t0\n") == .unrecognized) + #expect(RemoteProbeParser.parse("PROWL-PROBE\t3\nH\tLinux\tb\nEND\t0\t0\t0\t0\n") == .unrecognized) } @Test func unescapeReversesTheProbeEscapes() { diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index cd88f87d..376ca9e6 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -13,7 +13,8 @@ sections below record what shipped and what differs from the plan. | 2026-10-18 | S3b: stale projection and refusal of remote agents in conditions, dispatch and workflow admission (behind the flag). Review Loop clean after 3 rounds | #927 | | 2026-10-18 | S3c: degraded OSC-only binding for panes the host cannot map or probe (behind the flag). Review Loop: 4 rounds, then two follow-ups, clean | #928 | | 2026-10-18 | S4a: host label and connection health on Active Agents rows, `remote` object in `prowl agents --json`, docs (behind the flag). Review Loop clean after 2 rounds | #930 | -| 2026-10-18 | S4b: pane and tab host label, ssh-ended marker; remote detection on by default | #931 | +| 2026-10-18 | S4b: pane and tab host label, ssh-ended marker; remote detection on by default. Review Loop: 3 rounds plus a 2-round follow-up, clean | #931 | +| 2026-10-18 | S5a: probe format 2 maps a session's tmux client to its active pane, with the pane's foreground job and visible screen | S5a PR | ## S1 — Typed process references @@ -370,3 +371,44 @@ label, the ssh-ended marker, and turning the flag on. switch, the pane and tab label, and the ended marker. `active-agents.md` documents the remote row. `terminal.md` documents the tab glyph and pane corner. +## S5a — tmux in the probe (format 2) + +S5 is split. S5a teaches the probe and parser about tmux. S5b makes the tracker take +identity from the mapping and state from the screen. + +- **Records.** Format 2 adds four records: + - `X `: a tmux + client in a session's foreground and its active pane. It names its session by anchor + pid, so it follows all sessions. + - `Y` and `Z`: as `P` and `D`, for that pane's foreground job. + - `C `: the pane's visible screen (`capture-pane -p`), capped at + 64 KiB. + + `END` carries four counts (sessions, session members, clients, pane members). A pane + without its `C` record makes the snapshot partial. +- **Finding the server.** The pane's processes inherit the server's first client's + environment, so the probe never maps by environment. On Linux, tmux overwrites the + client's argv (`-L` is lost), so the probe also does not parse argv. Instead it asks + every socket in `${TMUX_TMPDIR:-/tmp}/tmux-$uid/` for `list-clients` and keeps the line + whose `client_pid` is the session's foreground member. The tmux binary is not on the side + channel's `PATH` (Homebrew), so the probe runs the client's own executable: `lsof` txt on + macOS, `/proc//exe` on Linux. +- **Pitfalls found on the hosts.** + - Under `LC_ALL=C`, tmux prints control characters in formats as `_`, so a TAB-separated + `-F` comes back joined. The fields are spaces-separated, because none of them can hold a + space. + - Linux names the client `tmux: client` (macOS: `tmux`). + - The heredoc that feeds the client loop keeps the counters in the main shell. A + `printf | while` pipeline would run them in a subshell. +- **Measured.** On macOS 26.5 with tmux 3.7c and Debian trixie with tmux 3.5a: + - The plain mapping shows Claude (`comm` `2.1.302`) or `sleep 600` in pane `%0`. + - Copy mode sets `in mode` to 1 and keeps the identity. + - A split with the other pane active maps to `%1`. + - A second `ssh` session attached to the same tmux session maps its own Prowl pane to + the same `%0`. + - `RemoteConnectionLiveTests` on both hosts (with a tmux session attached) returns a + complete report. +- **Limits.** A server socket outside `${TMUX_TMPDIR:-/tmp}/tmux-$uid/` (as the side + channel sees `TMUX_TMPDIR`) is not found. Such a client stays a plain foreground + `tmux`, with no agent. + -- 2.54.0 From 19657867cc027933ff2799eb9e6305bdcc64d8dc Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 05:28:54 +0900 Subject: [PATCH 35/51] Record the S5a PR in the 082 action log --- docs-ai/082-ssh-remote-agents/001-action.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 376ca9e6..a5273c97 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -14,7 +14,7 @@ sections below record what shipped and what differs from the plan. | 2026-10-18 | S3c: degraded OSC-only binding for panes the host cannot map or probe (behind the flag). Review Loop: 4 rounds, then two follow-ups, clean | #928 | | 2026-10-18 | S4a: host label and connection health on Active Agents rows, `remote` object in `prowl agents --json`, docs (behind the flag). Review Loop clean after 2 rounds | #930 | | 2026-10-18 | S4b: pane and tab host label, ssh-ended marker; remote detection on by default. Review Loop: 3 rounds plus a 2-round follow-up, clean | #931 | -| 2026-10-18 | S5a: probe format 2 maps a session's tmux client to its active pane, with the pane's foreground job and visible screen | S5a PR | +| 2026-10-18 | S5a: probe format 2 maps a session's tmux client to its active pane, with the pane's foreground job and visible screen | #932 | ## S1 — Typed process references -- 2.54.0 From d7c2f227dd74f5eecdb91e365876400a617b8ed1 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 05:39:40 +0900 Subject: [PATCH 36/51] Keep failed tmux captures and upgraded clients honest A capture that fails prints no screen record, so the snapshot stays partial instead of reporting an empty screen. On Linux the probe runs a client through /proc//exe, which still works after an upgrade deletes the binary. --- .../RemoteHost/RemoteProbeScript.swift | 41 +++++++++++-------- App/Tests/RemoteProbeParserTests.swift | 33 +++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 7 ++++ 3 files changed, 65 insertions(+), 16 deletions(-) diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift index 0bb49735..8da934aa 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift @@ -101,7 +101,9 @@ nonisolated enum RemoteProbeScript { comm=$(printf '%s\n' "$table" | awk -v m="$m" '$1 == m { sub(/^[^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ /, ""); print }') case "$comm" in tmux | "tmux: client") - clients="$clients$apid ${pane:--} $m $(readlink "/proc/$m/exe" 2>/dev/null) + # The proc link, not its target: after an upgrade the target reads `… (deleted)`, + # while the link still runs the client's own (protocol-compatible) binary. + clients="$clients$apid ${pane:--} $m /proc/$m/exe " ;; esac @@ -219,6 +221,27 @@ nonisolated enum RemoteProbeScript { } fi + # screen_record : the C record of a pane's visible screen. + # A failed capture prints nothing, so the snapshot stays incomplete; a blank screen + # still prints its newlines. + screen_record() { + # The x keeps a trailing newline and marks a capture that succeeded. + text=$("$1" -S "$2" capture-pane -p -t "$3" 2>/dev/null && printf x) + [ -n "$text" ] || return 0 + text=${text%x} + truncated=0 + if [ "$(printf '%s' "$text" | wc -c)" -gt "$cap" ]; then + truncated=1 + text=$(printf '%s' "$text" | head -c "$cap"; printf x) + text=${text%x} + fi + printf '%s' "$text" | PROWL_PANE="$4" awk -v t="$truncated" ' + BEGIN { RS = "\001"; ORS = "" } + { s = $0; gsub(/%/, "%25", s); gsub(/\t/, "%09", s); gsub(/\n/, "%0A", s); gsub(/\r/, "%0D", s) + printf "C\t%s\t%s\t%s\n", ENVIRON["PROWL_PANE"], t, s; done = 1 } + END { if (!done) printf "C\t%s\t%s\t\n", ENVIRON["PROWL_PANE"], t }' + } + # tmux: map each client in a session's foreground to its active pane through the # server it talks to (the inherited environment names the first client, so it is # never used), then report that pane's foreground job and visible screen. @@ -254,21 +277,7 @@ nonisolated enum RemoteProbeScript { render Y "$(printf '%s\n' "$job" | awk -v a="$pane" -v g="$fg" -v t="${ptty#/dev/}" '{ print a, g, t, $1 }')" fi ycount=$((ycount + $(printf '%s\n' "$job" | awk 'NF { n++ } END { print n + 0 }'))) - # The x keeps a trailing newline; one byte past the cap marks the text truncated. - screen=$("$exe" -S "$sock" capture-pane -p -t "$tpane" 2>/dev/null | head -c $((cap + 1)); printf x) - screen=${screen%x} - truncated=0 - if [ "$(printf '%s' "$screen" | wc -c)" -gt "$cap" ]; then - truncated=1 - screen=$(printf '%s' "$screen" | head -c "$cap"; printf x) - screen=${screen%x} - fi - printf '%s' "$screen" | PROWL_PANE="$pane" awk -v t="$truncated" ' - BEGIN { RS = "\001"; ORS = "" } - { s = $0; gsub(/%/, "%25", s); gsub(/\t/, "%09", s); gsub(/\n/, "%0A", s); gsub(/\r/, "%0D", s) - p = ENVIRON["PROWL_PANE"]; gsub(/%/, "%25", p); gsub(/\t/, "%09", p) - printf "C\t%s\t%s\t%s\n", p, t, s; done = 1 } - END { if (!done) printf "C\t%s\t%s\t\n", ENVIRON["PROWL_PANE"], t }' + screen_record "$exe" "$sock" "$tpane" "$pane" done < String { + let script = RemoteProbeScript.text + let start = try #require(script.range(of: "screen_record() {\n")) + let end = try #require(script.range(of: "\n}\n", range: start.upperBound..&2; exit 1") == "") + // Past the cap (8 bytes here), the text is cut and marked. + #expect(try screenRecord(tmux: "printf '0123456789'") == "C\tPANE\t1\t01234567\n") + } + private func isComplete(_ result: RemoteProbeParser.Result) -> Bool { if case .complete = result { return true } return false diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index a5273c97..0e5d9c0b 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -400,6 +400,13 @@ identity from the mapping and state from the screen. - Linux names the client `tmux: client` (macOS: `tmux`). - The heredoc that feeds the client loop keeps the counters in the main shell. A `printf | while` pipeline would run them in a subshell. + - A capture that fails (the pane closed after `list-clients`) prints no `C` record, so + the snapshot stays partial; a blank screen still has its newlines + (`screen_record`, review round 1). + - On Linux, the probe runs the client through `/proc//exe`. After an upgrade the + link's target reads `… (deleted)`, but the link still runs the client's own + protocol-compatible binary (review round 1; reproduced in the container by + deleting a copied binary). - **Measured.** On macOS 26.5 with tmux 3.7c and Debian trixie with tmux 3.5a: - The plain mapping shows Claude (`comm` `2.1.302`) or `sleep 600` in pane `%0`. - Copy mode sets `in mode` to 1 and keeps the identity. -- 2.54.0 From 84bbf6e0006ed5f6057264f49d719023f8c5d785 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 05:50:44 +0900 Subject: [PATCH 37/51] Drop a tmux capture that fails after writing output The screen record now depends on the capture's exit status, so a capture cut off by a lost server leaves the snapshot partial. --- .../Infrastructure/RemoteHost/RemoteProbeScript.swift | 9 ++++----- App/Tests/RemoteProbeParserTests.swift | 2 ++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift index 8da934aa..6d5339fc 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift @@ -222,12 +222,11 @@ nonisolated enum RemoteProbeScript { fi # screen_record : the C record of a pane's visible screen. - # A failed capture prints nothing, so the snapshot stays incomplete; a blank screen - # still prints its newlines. + # A failed capture prints nothing, even when it wrote part of a screen first, so the + # snapshot stays incomplete; a blank screen still prints its newlines. screen_record() { - # The x keeps a trailing newline and marks a capture that succeeded. - text=$("$1" -S "$2" capture-pane -p -t "$3" 2>/dev/null && printf x) - [ -n "$text" ] || return 0 + # The x keeps a trailing newline; the status is the capture's own. + text=$("$1" -S "$2" capture-pane -p -t "$3" 2>/dev/null && printf x) || return 0 text=${text%x} truncated=0 if [ "$(printf '%s' "$text" | wc -c)" -gt "$cap" ]; then diff --git a/App/Tests/RemoteProbeParserTests.swift b/App/Tests/RemoteProbeParserTests.swift index 1749d4b1..7efac9a7 100644 --- a/App/Tests/RemoteProbeParserTests.swift +++ b/App/Tests/RemoteProbeParserTests.swift @@ -218,6 +218,8 @@ struct RemoteProbeParserTests { #expect(try screenRecord(tmux: "printf '\\n\\n'") == "C\tPANE\t0\t%0A%0A\n") // A pane that closed after `list-clients`: no record, so the snapshot is incomplete. #expect(try screenRecord(tmux: "echo 'can\\'t find pane: %3' >&2; exit 1") == "") + // A capture cut off by a lost server: part of a screen, then a failure. Still no record. + #expect(try screenRecord(tmux: "printf 'partial screen\\n'; exit 1") == "") // Past the cap (8 bytes here), the text is cut and marked. #expect(try screenRecord(tmux: "printf '0123456789'") == "C\tPANE\t1\t01234567\n") } -- 2.54.0 From bef3b8a47d883e44e0614ed038efd7289134d539 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 19 Oct 2026 02:13:37 +0900 Subject: [PATCH 38/51] Report an unread tmux client and bound the probe's screens A failed tmux mapping was silent: the snapshot stayed complete, so a consumer would read the plain tmux foreground as the agent's exit. - Print a U record when no server lists the client, and carry it as RemoteProbeSession.hasUnreadTmuxPane. - Use a tmux on PATH or in the Homebrew prefixes when the client's own binary is gone, for example after brew upgrade. - Ask tmux for a flag, because pane_in_mode counts modes. - Keep the last whole lines of a screen past the cap. - Share a 160 KiB budget across the screens of one snapshot, Prowl panes first, so the probe stays under the 256 KiB output limit. --- .../RemoteHost/RemoteProbeParser.swift | 38 +++- .../RemoteHost/RemoteProbeReport.swift | 11 +- .../RemoteHost/RemoteProbeScript.swift | 95 ++++++--- App/Tests/RemoteProbeParserTests.swift | 185 +++++++++++++++++- docs-ai/082-ssh-remote-agents/001-action.md | 35 +++- 5 files changed, 314 insertions(+), 50 deletions(-) diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift index 34bf0d31..94041383 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeParser.swift @@ -173,7 +173,16 @@ nonisolated enum RemoteProbeParser { case "D": guard tmuxSession == nil, !sessions.isEmpty else { return false } return Self.applyDirectory(record, to: &sessions[sessions.count - 1].processes) + case "END": return applyEnd(record) + default: return applyTmuxRecord(record) + } + } + + /// `X`, `U`, and the `Y`, `Z` and `C` records of the pane the latest `X` opened. + private mutating func applyTmuxRecord(_ record: [String]) -> Bool { + switch record.first { case "X": return applyTmuxClient(record) + case "U": return applyUnreadTmuxClient(record) case "Y": guard let index = tmuxSession, let process = RemoteProbeParser.process(record) else { return false } sessions[index].tmux?.processes.append(process) @@ -185,7 +194,6 @@ nonisolated enum RemoteProbeParser { sessions[index].tmux = tmux return true case "C": return applyScreen(record) - case "END": return applyEnd(record) default: return false } } @@ -203,7 +211,8 @@ nonisolated enum RemoteProbeParser { private mutating func applyTmuxClient(_ record: [String]) -> Bool { guard record.count == 8, let anchor = Int32(record[2]), let client = Int32(record[3]), let tpgid = Int32(record[7]), record[6] == "0" || record[6] == "1", - let index = sessions.firstIndex(where: { $0.anchorPID == anchor }), sessions[index].tmux == nil + let index = sessions.firstIndex(where: { $0.anchorPID == anchor }), sessions[index].tmux == nil, + !sessions[index].hasUnreadTmuxPane else { return false } sessions[index].tmux = TmuxBuilder( clientPID: client, paneID: record[4], tty: record[5] == "-" ? nil : record[5], isInMode: record[6] == "1", @@ -212,11 +221,24 @@ nonisolated enum RemoteProbeParser { return true } + /// `U`, like `X`, names its session by anchor pid; no pane record follows it. + private mutating func applyUnreadTmuxClient(_ record: [String]) -> Bool { + guard record.count == 4, let anchor = Int32(record[2]), Int32(record[3]) != nil, + let index = sessions.firstIndex(where: { $0.anchorPID == anchor }), sessions[index].tmux == nil, + !sessions[index].hasUnreadTmuxPane + else { return false } + sessions[index].hasUnreadTmuxPane = true + tmuxSession = nil + return true + } + + /// `C` with `-` is a screen left out for the budget: present, without text. private mutating func applyScreen(_ record: [String]) -> Bool { - guard record.count == 4, record[2] == "0" || record[2] == "1", let index = tmuxSession, - sessions[index].tmux?.screen == nil + guard record.count == 4, ["0", "1", "-"].contains(record[2]), let index = tmuxSession, + let tmux = sessions[index].tmux, !tmux.hasScreenRecord else { return false } - sessions[index].tmux?.screen = record[3] + sessions[index].tmux?.hasScreenRecord = true + sessions[index].tmux?.screen = record[2] == "-" ? nil : record[3] sessions[index].tmux?.isScreenTruncated = record[2] == "1" return true } @@ -262,7 +284,7 @@ nonisolated enum RemoteProbeParser { ended.processes == sessions.reduce(0, { $0 + $1.processes.count }), ended.clients == panes.count, ended.paneProcesses == panes.reduce(0, { $0 + $1.processes.count }), // A pane whose screen record is missing was cut off. - panes.allSatisfy({ $0.screen != nil }) + panes.allSatisfy(\.hasScreenRecord) else { return .partial(report) } return .complete(report) } @@ -275,6 +297,7 @@ nonisolated enum RemoteProbeParser { let isInMode: Bool let foregroundProcessGroupID: Int32 var processes: [ProcessBuilder] = [] + var hasScreenRecord = false var screen: String? var isScreenTruncated = false @@ -293,11 +316,12 @@ nonisolated enum RemoteProbeParser { let foregroundProcessGroupID: Int32 var processes: [ProcessBuilder] = [] var tmux: TmuxBuilder? + var hasUnreadTmuxPane = false func build() -> RemoteProbeSession { RemoteProbeSession( paneID: paneID, anchorPID: anchorPID, tty: tty, foregroundProcessGroupID: foregroundProcessGroupID, - processes: processes.map { $0.build() }, tmux: tmux?.build()) + processes: processes.map { $0.build() }, tmux: tmux?.build(), hasUnreadTmuxPane: hasUnreadTmuxPane) } } } diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift index c454a1c9..9b69aa90 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeReport.swift @@ -26,6 +26,9 @@ nonisolated struct RemoteProbeSession: Equatable, Sendable { let processes: [RemoteProbeProcess] /// The tmux pane this session shows, when a tmux client is in its foreground. var tmux: RemoteProbeTmuxPane? + /// A tmux client is in this session's foreground, but the probe could not reach its server. + /// The session's own foreground job then says nothing about the agent in the pane. + var hasUnreadTmuxPane = false } /// The active pane of a tmux client in a session's foreground (docs-ai 082, L0 item 6). The @@ -33,6 +36,9 @@ nonisolated struct RemoteProbeSession: Equatable, Sendable { /// environment, which names the server's first client. nonisolated struct RemoteProbeTmuxPane: Equatable, Sendable { static let screenLimit = 64 * 1024 + /// The escaped screen text of one snapshot together. It stays well under + /// `RemoteCommandLimits.outputBytes`, which stops the whole probe. + static let screenBudget = 160 * 1024 let clientPID: Int32 /// tmux's pane id, such as `%3`. @@ -42,9 +48,10 @@ nonisolated struct RemoteProbeTmuxPane: Equatable, Sendable { let isInMode: Bool let foregroundProcessGroupID: Int32 let processes: [RemoteProbeProcess] - /// The pane's visible screen, as `capture-pane` prints it. + /// The pane's visible screen, as `capture-pane` prints it; nil when the snapshot's + /// `screenBudget` was used up before this pane. let screen: String? - /// The screen passed `screenLimit` and was cut. + /// The screen passed `screenLimit`; only its last whole lines are kept. let isScreenTruncated: Bool } diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift index 6d5339fc..e725b5a4 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteProbeScript.swift @@ -25,13 +25,19 @@ nonisolated enum RemoteProbeScript { # foreground, and its active pane # Y, Z as P and D, for that pane's # foreground job - # C that pane's visible screen + # C that pane's visible screen; 1 = only + # its last lines, - = left out for the + # screen budget + # U a tmux client in that session's + # foreground whose server the probe + # could not reach # END

# A snapshot without END is incomplete. LC_ALL=C export LC_ALL uid=$(id -u) - cap=65536 + cap=\#(RemoteProbeTmuxPane.screenLimit) + budget=\#(RemoteProbeTmuxPane.screenBudget) echo "PROWL-PROBE 2" clients= @@ -223,7 +229,9 @@ nonisolated enum RemoteProbeScript { # screen_record : the C record of a pane's visible screen. # A failed capture prints nothing, even when it wrote part of a screen first, so the - # snapshot stays incomplete; a blank screen still prints its newlines. + # snapshot stays incomplete; a blank screen still prints its newlines. Past `cap`, the + # record keeps the last whole lines: an agent draws its composer and status at the + # bottom, and a cut line could split a character. Past the budget, it has no text. screen_record() { # The x keeps a trailing newline; the status is the capture's own. text=$("$1" -S "$2" capture-pane -p -t "$3" 2>/dev/null && printf x) || return 0 @@ -231,41 +239,66 @@ nonisolated enum RemoteProbeScript { truncated=0 if [ "$(printf '%s' "$text" | wc -c)" -gt "$cap" ]; then truncated=1 - text=$(printf '%s' "$text" | head -c "$cap"; printf x) + text=$(printf '%s' "$text" | tail -c "$cap" | sed '1d'; printf x) text=${text%x} fi - printf '%s' "$text" | PROWL_PANE="$4" awk -v t="$truncated" ' + record=$(printf '%s' "$text" | PROWL_PANE="$4" awk -v t="$truncated" ' BEGIN { RS = "\001"; ORS = "" } { s = $0; gsub(/%/, "%25", s); gsub(/\t/, "%09", s); gsub(/\n/, "%0A", s); gsub(/\r/, "%0D", s) - printf "C\t%s\t%s\t%s\n", ENVIRON["PROWL_PANE"], t, s; done = 1 } - END { if (!done) printf "C\t%s\t%s\t\n", ENVIRON["PROWL_PANE"], t }' + printf "C\t%s\t%s\t%s", ENVIRON["PROWL_PANE"], t, s; done = 1 } + END { if (!done) printf "C\t%s\t%s\t", ENVIRON["PROWL_PANE"], t }') + size=$(printf '%s' "$record" | wc -c) + if [ "$size" -gt "$budget" ]; then + printf 'C\t%s\t-\t\n' "$4" + return 0 + fi + budget=$((budget - size)) + printf '%s\n' "$record" } - # tmux: map each client in a session's foreground to its active pane through the - # server it talks to (the inherited environment names the first client, so it is - # never used), then report that pane's foreground job and visible screen. - xcount=0 - ycount=0 - tdir="${TMUX_TMPDIR:-/tmp}/tmux-$uid" - while read -r apid pane cpid exe; do - [ -n "$exe" ] && [ -x "$exe" ] || continue - found= - for sock in "$tdir"/*; do - [ -S "$sock" ] || continue - # Spaces, not TAB: under LC_ALL=C tmux prints control characters as `_`. None of - # these fields can hold a space. - found=$("$exe" -S "$sock" list-clients -F '#{client_pid} #{pane_id} #{pane_tty} #{pane_in_mode} #{pane_pid}' \ - 2>/dev/null | awk -v p="$cpid" '$1 == p { print; exit }') - [ -n "$found" ] && break + # tmux_fallback: a tmux for a client whose own binary is gone (an upgrade removed it). A + # tmux of another version fails against the server like an unreachable one. + tmux_fallback() { + for t in "$(command -v tmux 2>/dev/null)" /opt/homebrew/bin/tmux /usr/local/bin/tmux; do + if [ -n "$t" ] && [ -x "$t" ]; then + printf '%s' "$t" + return 0 + fi done - [ -n "$found" ] || continue + } + + # tmux_client : map a tmux client in a session's + # foreground to its active pane through the server it talks to (the inherited environment + # names the first client, so it is never used), then report that pane's foreground job + # and visible screen. Without a server that lists the client, a U record says so. + tmux_client() { + apid=$1 + pane=$2 + cpid=$3 + exe=$4 + [ -n "$exe" ] && [ -x "$exe" ] || exe=$(tmux_fallback) + found= + if [ -n "$exe" ]; then + for sock in "$tdir"/*; do + [ -S "$sock" ] || continue + # Spaces, not TAB: under LC_ALL=C tmux prints control characters as `_`. None of + # these fields can hold a space. `pane_in_mode` counts modes, so ask for a flag. + found=$("$exe" -S "$sock" list-clients \ + -F '#{client_pid} #{pane_id} #{pane_tty} #{?pane_in_mode,1,0} #{pane_pid}' \ + 2>/dev/null | awk -v p="$cpid" '$1 == p { print; exit }') + [ -n "$found" ] && break + done + fi + if [ -z "$found" ]; then + printf 'U\t%s\t%s\t%s\n' "$pane" "$apid" "$cpid" + return 0 + fi # shellcheck disable=SC2086 set -- $found tpane=$2 ptty=$3 mode=$4 - ppid=$5 - fg=$(pane_tpgid "$ppid") + fg=$(pane_tpgid "$5") [ -n "$fg" ] || fg=-1 printf 'X\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$pane" "$apid" "$cpid" "$tpane" "$ptty" "${mode:-0}" "$fg" xcount=$((xcount + 1)) @@ -277,8 +310,16 @@ nonisolated enum RemoteProbeScript { fi ycount=$((ycount + $(printf '%s\n' "$job" | awk 'NF { n++ } END { print n + 0 }'))) screen_record "$exe" "$sock" "$tpane" "$pane" + } + + xcount=0 + ycount=0 + tdir="${TMUX_TMPDIR:-/tmp}/tmux-$uid" + # Clients of Prowl panes first, so a session Prowl cannot name never takes their budget. + while read -r apid pane cpid exe; do + [ -n "$cpid" ] && tmux_client "$apid" "$pane" "$cpid" "$exe" done < RemoteProbeReport { @@ -187,23 +210,89 @@ struct RemoteProbeParserTests { #expect(!isComplete(parse([client, "C\t-\t0\t", "S\t-\t30\t/dev/pts/2\t30", "END\t2\t1\t1\t0"]))) // Counts include pane members. #expect(!isComplete(parse([client, member, "C\t-\t0\t", "END\t1\t1\t1\t0"]))) + // A screen left out for the budget is still a screen record. + guard case .complete(let omitted) = parse([client, member, "C\t-\t-\t", "END\t1\t1\t1\t1"]) else { + Issue.record("expected complete") + return + } + #expect(omitted.sessions.first?.tmux?.screen == nil) + #expect(omitted.sessions.first?.tmux?.isScreenTruncated == false) } - /// Runs the probe's `screen_record` function against a stand-in `tmux` that runs `body`. - private func screenRecord(tmux body: String) throws -> String { + @Test func aRecordedUnreadClientLeavesTheOtherSessionRead() throws { + let report = try report(Self.macOSUnreadTmux) + #expect(report.sessions.map(\.hasUnreadTmuxPane) == [true, false]) + #expect(report.sessions[0].tmux == nil) + let pane = try #require(report.sessions[1].tmux) + #expect(pane.isInMode) + #expect(pane.processes.map(\.commandLine) == ["-zsh"]) + #expect(pane.screen?.hasPrefix("➜ ~\n") == true) + } + + @Test func anUnreadTmuxPaneIsMarkedOnItsSessionOnly() { + let head = [ + "PROWL-PROBE\t2", "H\tLinux\tb", + "S\t-\t10\t/dev/pts/0\t11", "P\t-\t11\t10\t11\t5\t1\ttmux: client\tv\ttmux", + "S\t-\t30\t/dev/pts/2\t31", "P\t-\t31\t30\t31\t5\t1\tcodex\tv\tcodex", + ] + let unread = "U\t-\t10\t11" + func parse(_ lines: [String]) -> RemoteProbeParser.Result { + RemoteProbeParser.parse((head + lines).joined(separator: "\n") + "\n") + } + guard case .complete(let report) = parse([unread, "END\t2\t2\t0\t0"]) else { + Issue.record("expected complete") + return + } + #expect(report.sessions.map(\.hasUnreadTmuxPane) == [true, false]) + #expect(report.sessions.first?.tmux == nil) + // A client of an unknown session, a second record for one session, pane records after it, + // and a read pane next to an unread one are malformed. + #expect(!isComplete(parse(["U\t-\t99\t11", "END\t2\t2\t0\t0"]))) + #expect(!isComplete(parse([unread, unread, "END\t2\t2\t0\t0"]))) + #expect(!isComplete(parse([unread, "Y\t-\t20\t19\t20\t6\t1\tcodex\tv\tcodex", "END\t2\t2\t0\t1"]))) + #expect(!isComplete(parse([unread, "C\t-\t0\t", "END\t2\t2\t0\t0"]))) + let client = "X\t-\t10\t11\t%3\t/dev/pts/1\t0\t20" + #expect(!isComplete(parse([client, "C\t-\t0\t", unread, "END\t2\t2\t1\t0"]))) + #expect(!isComplete(parse([unread, client, "C\t-\t0\t", "END\t2\t2\t1\t0"]))) + } + + /// Runs `calls` after the probe's top-level shell functions, with a stand-in `tmux` that + /// runs `body`. `$tmux` names the stand-in and `$tdir` the socket directory, which holds + /// one socket when `withServer` is true. The stand-in is on `PATH` only when `prelude` + /// puts it there. + private func runProbe( + tmux body: String, withServer: Bool = false, prelude: String = "", calls: String + ) throws -> String { let script = RemoteProbeScript.text - let start = try #require(script.range(of: "screen_record() {\n")) - let end = try #require(script.range(of: "\n}\n", range: start.upperBound.. String { + try runProbe(tmux: body, calls: #"screen_record "$tmux" sock %3 PANE"#) + } + @Test func aFailedCaptureLeavesNoScreenRecord() throws { #expect(try screenRecord(tmux: "printf 'a\\tb%%\\n'") == "C\tPANE\t0\ta%09b%25%0A\n") // A blank screen is still a screen. @@ -220,8 +313,62 @@ struct RemoteProbeParserTests { #expect(try screenRecord(tmux: "echo 'can\\'t find pane: %3' >&2; exit 1") == "") // A capture cut off by a lost server: part of a screen, then a failure. Still no record. #expect(try screenRecord(tmux: "printf 'partial screen\\n'; exit 1") == "") - // Past the cap (8 bytes here), the text is cut and marked. - #expect(try screenRecord(tmux: "printf '0123456789'") == "C\tPANE\t1\t01234567\n") + } + + @Test func aScreenPastTheCapKeepsItsLastWholeLines() throws { + // The cap is 8 bytes here. An agent draws its composer and status at the bottom, and a + // cut line could split a UTF-8 character. + #expect(try screenRecord(tmux: "printf '0123456789\\nab\\n'") == "C\tPANE\t1\tab%0A\n") + #expect(try screenRecord(tmux: "printf '0123\\n456789\\n'") == "C\tPANE\t1\t456789%0A\n") + #expect(try screenRecord(tmux: "printf '1234567\\n'") == "C\tPANE\t0\t1234567%0A\n") + } + + @Test func screensPastTheBudgetAreLeftOut() throws { + // Each record is 17 bytes; the budget is 40. + let output = try runProbe( + tmux: "printf 'abc\\n'", + calls: #"for p in A B C; do screen_record "$tmux" sock %3 PANE-$p; done"#) + #expect(output == "C\tPANE-A\t0\tabc%0A\nC\tPANE-B\t0\tabc%0A\nC\tPANE-C\t-\t\n") + // The process records need the rest of the output limit, past which the probe stops. + #expect(RemoteProbeTmuxPane.screenBudget < RemoteCommandLimits().outputBytes * 2 / 3) + } + + /// A stand-in `tmux` whose server has one client, pid 11 on pane `%3`, in copy mode. + private static let tmuxServer = #""" + case "$3" in + list-clients) printf '%s\n' "$5" >> "${0%/*}/format"; echo '11 %3 /dev/pts/1 1 19' ;; + capture-pane) echo '> hi' ;; + esac + """# + + @Test func aTmuxClientReadsItsPaneThroughItsServer() throws { + let output = try runProbe( + tmux: Self.tmuxServer, withServer: true, + calls: #"tmux_client 10 PANE 11 "$tmux"; cat "${tmux%/*}/format""#) + // `pane_in_mode` counts modes (copy mode under a chooser is 2), so the probe asks for a flag. + #expect( + output == """ + X\tPANE\t10\t11\t%3\t/dev/pts/1\t1\t20 + C\tPANE\t0\t> hi%0A + #{client_pid} #{pane_id} #{pane_tty} #{?pane_in_mode,1,0} #{pane_pid} + + """) + } + + @Test func aClientWhoseBinaryIsGoneUsesATmuxOnPath() throws { + // An upgrade removed the client's own binary. + let output = try runProbe( + tmux: Self.tmuxServer, withServer: true, prelude: #"PATH="${tmux%/*}:$PATH""#, + calls: #"tmux_client 10 PANE 11 /gone/tmux"#) + #expect(output.hasPrefix("X\tPANE\t10\t11\t%3\t")) + } + + @Test func aClientWithoutAReachableServerIsUnread() throws { + // No socket in the directory, then a server that does not list the client. + #expect(try runProbe(tmux: Self.tmuxServer, calls: #"tmux_client 10 PANE 12 "$tmux""#) == "U\tPANE\t10\t12\n") + #expect( + try runProbe(tmux: Self.tmuxServer, withServer: true, calls: #"tmux_client 10 PANE 12 "$tmux""#) + == "U\tPANE\t10\t12\n") } private func isComplete(_ result: RemoteProbeParser.Result) -> Bool { @@ -338,3 +485,21 @@ struct RemoteProbeParserTests { #expect(!report.bootID.isEmpty) } } + +/// A listening-free Unix socket at `path`, so the probe's `[ -S ]` test passes; the caller +/// closes it. +private func bindUnixSocket(at path: String) throws -> Int32 { + let fileDescriptor = socket(AF_UNIX, SOCK_STREAM, 0) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let bytes = Array(path.utf8) + try #require(bytes.count < MemoryLayout.size(ofValue: address.sun_path)) + withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: bytes) } + let bound = withUnsafePointer(to: &address) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(fileDescriptor, $0, socklen_t(MemoryLayout.size)) + } + } + try #require(bound == 0) + return fileDescriptor +} diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 0e5d9c0b..39fa83dc 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -382,7 +382,12 @@ identity from the mapping and state from the screen. pid, so it follows all sessions. - `Y` and `Z`: as `P` and `D`, for that pane's foreground job. - `C `: the pane's visible screen (`capture-pane -p`), capped at - 64 KiB. + 64 KiB. Past the cap it keeps the last whole lines (`1`): an agent draws its composer + and status at the bottom, and a cut line could split a character. All screens of one + snapshot share a 160 KiB budget (`RemoteProbeTmuxPane.screenBudget`); a screen past it + has no text (`-`). Clients of Prowl panes go first. + - `U `: a tmux client in a session's foreground whose + server the probe could not reach. `RemoteProbeSession.hasUnreadTmuxPane` carries it. `END` carries four counts (sessions, session members, clients, pane members). A pane without its `C` record makes the snapshot partial. @@ -415,7 +420,29 @@ identity from the mapping and state from the screen. the same `%0`. - `RemoteConnectionLiveTests` on both hosts (with a tmux session attached) returns a complete report. -- **Limits.** A server socket outside `${TMUX_TMPDIR:-/tmp}/tmux-$uid/` (as the side - channel sees `TMUX_TMPDIR`) is not found. Such a client stays a plain foreground - `tmux`, with no agent. +- **Design review.** A later review of the design found that every + failed mapping was silent: the snapshot stayed complete, so the consumer would read the + plain `tmux` foreground as "the agent left". The fixes: + - A failed mapping prints `U`. The consumer treats that session as a gap, and only that + session. + - A client whose own binary is gone (macOS: `brew upgrade` removed the Cellar path that + `lsof` reports) uses a tmux on `PATH`, then `/opt/homebrew/bin/tmux`, then + `/usr/local/bin/tmux`. Reproduced on mini by deleting a copied binary under a live + client: the old probe dropped the client, the new one maps it. + - `pane_in_mode` counts modes (copy mode under a chooser is 2 on both hosts), so the + format asks for `#{?pane_in_mode,1,0}`. + - The screen budget and the kept bottom lines (above). Four 64 KiB screens used to fill + the 256 KiB output limit, which stops the whole probe. + + Rejected: one `list-clients` per socket per tick (one or two clients is the common + case); `display-message -c` or `list-panes -a` (neither ties a client to its own pane); + sharing one capture between two clients of a pane (needs a reference record or a + temporary file; the budget already bounds the cost). +- **Limits.** + - A server socket outside `${TMUX_TMPDIR:-/tmp}/tmux-$uid/` (as the side channel sees + `TMUX_TMPDIR`), such as `tmux -S `, is not found. Its client gets a `U` record. + - Only one level of tmux: a tmux inside a tmux pane is the pane's foreground job. + - A program in a `display-popup` is not in the pane's foreground job. + - `list-clients` reports the window's active pane. A client attached with + `-f active-pane` can show another pane (not tested). -- 2.54.0 From f6471de98cda6c174fba7085f7aad3f8f873393c Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 06:09:51 +0900 Subject: [PATCH 39/51] Detect remote agents inside user-owned tmux When a remote session shows a tmux client, the agent comes from that client's active pane and its state from the pane's capture, not the local screen. A changing local screen asks for a fresh capture, at most every 0.25 s, with one probe in flight. --- .../BusinessLogic/RemotePaneTracker.swift | 32 ++++- ...WorktreeTerminalState+AgentDetection.swift | 18 ++- .../Models/WorktreeTerminalState.swift | 3 + .../Ghostty/GhosttySurfaceBridge.swift | 5 +- App/Tests/RemoteAgentDetectionTests.swift | 118 ++++++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 30 ++++- docs/components/agent-detection.md | 11 ++ 7 files changed, 213 insertions(+), 4 deletions(-) diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index 121e8188..d285d031 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -110,6 +110,9 @@ final class RemotePaneTracker { /// When a snapshot last confirmed the bound agent, or for an unprobed binding, when its /// producer last reported. var confirmedAt: Date? + /// The visible screen of the tmux pane the session shows, while correlated (L0 item 6): + /// inside tmux it, not the local screen, is the agent's screen. + var tmuxScreen: String? func context(now: Date, stateKnown: Bool = true) -> RemoteAgentContext { RemoteAgentContext( @@ -124,6 +127,9 @@ final class RemotePaneTracker { static let activeCadence: TimeInterval = 1 /// Probe cadence while the pane shows a remote shell. static let idleCadence: TimeInterval = 3 + /// The least spacing of probes a changing local screen asks for: under sustained output + /// one probe runs at a time (the owner coalesces the rest) and the capture keeps up. + static let screenChangeSpacing: TimeInterval = 0.25 let surfaceID: UUID /// The local `ssh` process this tracker belongs to. @@ -142,6 +148,9 @@ final class RemotePaneTracker { private var retired = false private var lastRequestAt: TimeInterval? private(set) var latest: RemoteProbeSnapshot? + /// The tmux pane this pane's session shows, from the latest snapshot that mapped the + /// session; nil when that session shows no tmux client (docs-ai 082, S5). + private(set) var tmuxPane: RemoteProbeTmuxPane? private var consumedSequence: (epoch: UInt64, sequence: UInt64)? /// The remote session's anchor, learned from a snapshot that carried this pane's id. On @@ -267,7 +276,23 @@ final class RemotePaneTracker { } return Observation( correlation: correlation, connection: lease?.connection, identified: bound, verdict: verdict, - programStatusFence: boundRecordFence, confirmedAt: confirmedAt) + programStatusFence: boundRecordFence, confirmedAt: confirmedAt, tmuxScreen: tmuxScreen) + } + + /// The capture of the tmux pane this pane's session shows, while correlated. + var tmuxScreen: String? { + correlation == .correlated ? tmuxPane?.screen : nil + } + + /// The local screen of a tmux pane changed: the tmux client drew something, so the + /// capture is likely out of date. Asks for a probe now, at most every + /// `screenChangeSpacing`. + func requestRefreshForScreenChange() { + guard let lease, !retired else { return } + let now = uptime() + if let lastRequestAt, now - lastRequestAt < Self.screenChangeSpacing { return } + lastRequestAt = now + Task { await lease.refresh() } } /// The arrival fence of a probed agent: its successor fence, raised to the start of the @@ -322,6 +347,8 @@ final class RemotePaneTracker { // Only this pane's own session is evidence about its agent: an unmapped or // ambiguous scan proves nothing (C3). guard mapped.count == 1 else { return bound == nil ? nil : .unchanged } + // A partial snapshot that lost the tmux records is a gap, not a client that left. + if snapshot.isComplete || mapped[0].tmux != nil { tmuxPane = mapped[0].tmux } let job = correlatedJob(report: report, epoch: snapshot.connectionEpoch) if let identified = job.flatMap(identifyAgentInJob), identified.process.ref != bound?.process.ref { bound = identified @@ -370,6 +397,9 @@ final class RemotePaneTracker { private func correlatedJob(report: RemoteProbeReport, epoch: UInt64) -> ForegroundJob? { let matching = sessions(in: report, epoch: epoch) guard matching.count == 1, let session = matching.first, let connection = lease?.connection else { return nil } + // Inside tmux the session's own foreground is the tmux client; the agent is in the + // client's active pane (L0 item 6). + if let tmux = session.tmux { return tmux.foregroundJob(connection: connection, bootID: report.bootID) } return session.foregroundJob(connection: connection, bootID: report.bootID) } diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 160fd60c..75a5ca5e 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -106,6 +106,7 @@ extension WorktreeTerminalState { agentDetectionPresenceBySurface.removeValue(forKey: surfaceID) lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceID) lastAgentScreenScanBySurface.removeValue(forKey: surfaceID) + tmuxLocalScreenHashBySurface.removeValue(forKey: surfaceID) if surfaceAgentStates[surfaceID]?.detectedAgent == nil { surfaceAgentStates.removeValue(forKey: surfaceID) } @@ -267,7 +268,19 @@ extension WorktreeTerminalState { lastScan?.detection ?? AgentScreenDetection(state: .unknown, reason: .noRuleMatched) ) } - let activeText = view.bridge.readActiveText() ?? "" + let localText = view.bridge.readActiveText() ?? "" + if let tmuxScreen = remotePaneTrackers[surfaceID]?.tmuxScreen { + // Inside tmux the local screen is the tmux client's (status line, other panes, copy + // mode); the agent's own screen is the capture. A local change still means the + // capture is likely stale, so it asks for a probe now. + let localHash = localText.hashValue + if tmuxLocalScreenHashBySurface.updateValue(localHash, forKey: surfaceID) != localHash { + remotePaneTrackers[surfaceID]?.requestRefreshForScreenChange() + } + return (tmuxScreen, cachedScreenDetection(forSurfaceID: surfaceID, agent: agent, text: tmuxScreen)) + } + tmuxLocalScreenHashBySurface.removeValue(forKey: surfaceID) + let activeText = localText // Reuse the previous scan while the screen and detected agent are unchanged. // A live-but-idle agent is polled every 300 ms and `detectState` re-splits, // lowercases, and scans the whole screen each time; skipping that for @@ -665,6 +678,7 @@ extension WorktreeTerminalState { agentDetectionCoordinators.removeValue(forKey: surfaceID)?.invalidate() surfaceAgentStates[surfaceID] = PaneAgentState(lastChangedAt: Date()) lastAgentScreenScanBySurface.removeValue(forKey: surfaceID) + tmuxLocalScreenHashBySurface.removeValue(forKey: surfaceID) let hadPublishedEntry = lastEmittedAgentEntriesBySurface.removeValue(forKey: surfaceID) != nil // The launch identity lives exactly as long as the launched agent // (docs-ai 053/006): once the pane is a bare shell again, a manually @@ -870,6 +884,7 @@ extension WorktreeTerminalState { agentDetectionPresenceBySurface.removeValue(forKey: surfaceId) lastAgentDetectionDiagnosticsBySurface.removeValue(forKey: surfaceId) lastAgentScreenScanBySurface.removeValue(forKey: surfaceId) + tmuxLocalScreenHashBySurface.removeValue(forKey: surfaceId) let hadPublishedEntry = lastEmittedAgentEntriesBySurface.removeValue(forKey: surfaceId) != nil lastAgentEntryEmitAtBySurface.removeValue(forKey: surfaceId) pendingAgentEntryBySurface.removeValue(forKey: surfaceId) @@ -906,6 +921,7 @@ extension WorktreeTerminalState { agentDetectionPresenceBySurface.removeAll() lastAgentDetectionDiagnosticsBySurface.removeAll() lastAgentScreenScanBySurface.removeAll() + tmuxLocalScreenHashBySurface.removeAll() lastEmittedAgentEntriesBySurface.removeAll() lastAgentEntryEmitAtBySurface.removeAll() pendingAgentEntryBySurface.removeAll() diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift index 4b4c48a3..6b536924 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift @@ -273,6 +273,9 @@ final class WorktreeTerminalState { /// drives the UI. @ObservationIgnored var lastAgentScreenScanBySurface: [UUID: AgentScreenScan] = [:] + /// The local screen of a pane inside a remote tmux, as a hash: a change asks for a fresh + /// capture (docs-ai 082, S5). + @ObservationIgnored var tmuxLocalScreenHashBySurface: [UUID: Int] = [:] /// Last `ActiveAgentEntry` emitted per surface. `detectAgentState` re-emits /// whenever any `PaneAgentState` field changes, including internal /// bookkeeping (raw-state oscillation, session miss streaks, presence diff --git a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceBridge.swift index c955a83f..e5bcdd03 100644 --- a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceBridge.swift +++ b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceBridge.swift @@ -105,8 +105,11 @@ final class GhosttySurfaceBridge { surfaceView?.readViewportContentsForCLI() } + /// Test seam: the screen text a test surface (which has no terminal) reports. + var activeTextForTesting: String? + func readActiveText() -> String? { - surfaceView?.readActiveContentsForCLI() + activeTextForTesting ?? surfaceView?.readActiveContentsForCLI() } func childPID() -> pid_t? { diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 4b418c9f..07128bba 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -156,6 +156,34 @@ struct RemoteAgentDetectionTests { report: RemoteProbeReport(operatingSystem: .linux, bootID: bootID, sessions: sessions)) } + /// A snapshot whose session shows a tmux client (pid 150); its active pane runs `pane`. + private func tmuxSnapshot( + _ sequence: UInt64, pane: UUID?, foreground: RemoteProbeProcess, screen: String, inMode: Bool = false, + paneID: String = "%0" + ) -> RemoteProbeSnapshot { + let client = remoteProcess(150, name: "tmux") + var session = RemoteProbeSession( + paneID: pane, anchorPID: 100, tty: "/dev/pts/0", foregroundProcessGroupID: client.processGroupID, + processes: [client]) + session.tmux = RemoteProbeTmuxPane( + clientPID: 150, paneID: paneID, tty: "/dev/pts/3", isInMode: inMode, + foregroundProcessGroupID: foreground.processGroupID, processes: [foreground], screen: screen, + isScreenTruncated: false) + let now = ProcessInfo.processInfo.systemUptime + return RemoteProbeSnapshot( + connection: ConnectionKey(user: "onevcat", hostname: "mini.local"), connectionEpoch: 0, sequence: sequence, + startedAt: now, finishedAt: now, outcome: .complete, + report: RemoteProbeReport(operatingSystem: .linux, bootID: "boot", sessions: [session])) + } + + private static let blockedClaude = """ + ✻ Tempering… (12s · esc to interrupt) + Do you want to proceed? + ❯ 1. Yes + 2. No + Esc to cancel · Tab to amend + """ + private func remoteRef(_ pid: Int32, start: String = "t1") -> ProcessRef { .remote( RemoteProcessRef( @@ -1161,6 +1189,96 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude) } + @Test func anAgentInTmuxIsIdentifiedByTheMappingAndReadFromTheCapture() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + // The local screen is the tmux client's: it shows nothing an agent rule matches. + fixture.surface.bridge.activeTextForTesting = "[spike] 0:claude*" + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + await deliver( + fixture, + tmuxSnapshot( + 1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"), screen: Self.blockedClaude)) + await detect(fixture, screen: .unknown) + let pane = fixture.state.surfaceAgentStates[fixture.surfaceID] + #expect(pane?.agentProcess == remoteRef(200)) + #expect(pane?.detectedAgent == .claude) + #expect(pane?.state == .blocked) + #expect(fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID]?.text == Self.blockedClaude) + } + + @Test func copyModeKeepsAndAPaneSwitchReleasesTheTmuxAgent() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + let claude = remoteProcess(200, name: "claude") + await deliver(fixture, tmuxSnapshot(1, pane: fixture.surfaceID, foreground: claude, screen: Self.blockedClaude)) + await detect(fixture, screen: .unknown) + // Copy mode: the same pane and agent, so nothing changes. + await deliver( + fixture, tmuxSnapshot(2, pane: fixture.surfaceID, foreground: claude, screen: Self.blockedClaude, inMode: true)) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + // The client switched to a pane with a shell: two complete snapshots release the agent. + for sequence in UInt64(3)...4 { + await deliver( + fixture, + tmuxSnapshot( + sequence, pane: fixture.surfaceID, foreground: remoteProcess(300, name: "zsh"), screen: "$ ", paneID: "%1")) + await detect(fixture, screen: .unknown) + } + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + } + + @Test func aChangedLocalScreenInTmuxAsksForACapture() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + await deliver( + fixture, + tmuxSnapshot( + 1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"), screen: Self.blockedClaude)) + fixture.surface.bridge.activeTextForTesting = "frame 1" + await detect(fixture, screen: .unknown) + #expect(fixture.state.tmuxLocalScreenHashBySurface[fixture.surfaceID] == "frame 1".hashValue) + // Out of tmux, the local screen is the agent's screen again. + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await detect(fixture, screen: .unknown) + #expect(fixture.state.tmuxLocalScreenHashBySurface[fixture.surfaceID] == nil) + } + + @Test func screenChangeRefreshesKeepTheirSpacing() async { + var clock: TimeInterval = 100 + let source = FakeRemoteSource() + let tracker = RemotePaneTracker( + surfaceID: UUID(), ssh: LocalProcessGeneration(pid: 1, startedAt: Date()), + invocation: .supported(SSHConnectionRecipe(destination: "mini")), source: source.source, + uptime: { clock }, onSnapshot: {}) + tracker.start() + await settle { source.recipes.count == 1 } + let opened = source.refreshes + clock = 101 + tracker.requestRefreshForScreenChange() + await settle { source.refreshes == opened + 1 } + // Sustained output: a change every 0.1 s asks again only after the spacing. + clock = 101.1 + tracker.requestRefreshForScreenChange() + clock = 101.2 + tracker.requestRefreshForScreenChange() + clock = 101.3 + tracker.requestRefreshForScreenChange() + await settle { source.refreshes == opened + 2 } + for _ in 0..<50 { await Task.yield() } + #expect(source.refreshes == opened + 2) + tracker.retire() + } + @Test func aProbeGapKeepsTheRemoteBinding() async { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 39fa83dc..35031735 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -14,7 +14,8 @@ sections below record what shipped and what differs from the plan. | 2026-10-18 | S3c: degraded OSC-only binding for panes the host cannot map or probe (behind the flag). Review Loop: 4 rounds, then two follow-ups, clean | #928 | | 2026-10-18 | S4a: host label and connection health on Active Agents rows, `remote` object in `prowl agents --json`, docs (behind the flag). Review Loop clean after 2 rounds | #930 | | 2026-10-18 | S4b: pane and tab host label, ssh-ended marker; remote detection on by default. Review Loop: 3 rounds plus a 2-round follow-up, clean | #931 | -| 2026-10-18 | S5a: probe format 2 maps a session's tmux client to its active pane, with the pane's foreground job and visible screen | #932 | +| 2026-10-18 | S5a: probe format 2 maps a session's tmux client to its active pane, with the pane's foreground job and visible screen. Review Loop clean after 3 rounds | #932 | +| 2026-10-18 | S5b: inside tmux, identity from the mapping and state from the capture | S5b PR | ## S1 — Typed process references @@ -446,3 +447,30 @@ identity from the mapping and state from the screen. - `list-clients` reports the window's active pane. A client attached with `-f active-pane` can show another pane (not tested). +## S5b — tmux identity and capture state + +- **Identity.** `RemotePaneTracker.correlatedJob` takes the job from the session's tmux + pane when the session shows a tmux client. When that pane's job is empty, there is no + agent; the client itself is never classified. +- **Screen.** The tracker keeps `tmuxPane` from the latest snapshot that mapped the + session. A partial snapshot that lost the tmux records is a gap and keeps it. + `Observation.tmuxScreen` carries the capture while the pane is `correlated`. + `pollScreen` reads that text instead of the local screen, with the same per-surface + scan memo. Staleness follows `confirmedAt`, which comes from the same snapshot. +- **Wakes.** Output does not wake the detection loop, so the tick compares a hash of the + local screen (`tmuxLocalScreenHashBySurface`). When it changes, the tick calls + `requestRefreshForScreenChange()`, which requests at most one probe every + `screenChangeSpacing` (0.25 s). The owner coalesces the rest, so at most one probe is in + flight. +- **Test seam.** `GhosttySurfaceBridge.activeTextForTesting`. +- **Measured** (macOS 26.5, tmux 3.7c, Claude 2.1.302): + - Claude in `tmux -L s5e2e` appeared after 0.35 s (`claude.idleComposer` from the + capture); a prompt reached working at 0.44 s (`claude.spinner`) and done at 3.04 s. + - Copy mode during work stayed working. + - Sampled every 0.1 s for 4 s: at most 1 concurrent probe, with a probe running in + 7 of 40 samples. + - A split with the other pane active released the agent after 2.24 s; `select-pane` + back showed it after 3.79 s. With no agent bound, the idle cadence applies. + - A second Prowl pane attached as another client showed the same agent, `correlated`. + - Debian trixie (tmux 3.5a): Claude in tmux was identified after 2.45 s. + diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 7a8d3a8a..6cc9c3a7 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -434,6 +434,17 @@ false` and relaunch Prowl, or launch it with `PROWL_REMOTE_AGENT_DETECTION=0`. In that case a verified producer's OSC 7501 report still lists the agent, with `heuristic` confidence. Only the `ssh` exit releases such an agent. When its producer is quiet for 10 s, it becomes stale. +- **Inside tmux.** When the remote session's foreground is a tmux client, Prowl maps that + client to its active pane through the tmux server (never through inherited environment + variables). It identifies the agent from that pane's foreground job, and reads the + agent's state from the pane's visible screen (`tmux capture-pane`), not from the local + screen. The local screen also shows the tmux status line, other panes, and copy mode. + tmux drops OSC 7501, so screen rules decide the state there. Copy mode does not change + the state. Switching to another pane releases the agent, and switching back shows it + again within a few seconds. A second client attached to the same pane shows the same + agent in its own Prowl pane. A changing local screen asks for a fresh capture, with at + most one probe in flight. Servers whose socket is outside + `${TMUX_TMPDIR:-/tmp}/tmux-/` are not found. - **Release.** A probed agent is released after two complete fresh snapshots without it (about 2–3 s after it exits), or at once when the `ssh` exits. - **Limits.** A remote agent cannot be a `prowl agents dispatch` target that needs a -- 2.54.0 From 504fef4d9b6da9c6f6f04bd1e081b50b18d0fb6f Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 06:10:11 +0900 Subject: [PATCH 40/51] Record the S5b PR in the 082 action log --- docs-ai/082-ssh-remote-agents/001-action.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 35031735..81b0171b 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -15,7 +15,7 @@ sections below record what shipped and what differs from the plan. | 2026-10-18 | S4a: host label and connection health on Active Agents rows, `remote` object in `prowl agents --json`, docs (behind the flag). Review Loop clean after 2 rounds | #930 | | 2026-10-18 | S4b: pane and tab host label, ssh-ended marker; remote detection on by default. Review Loop: 3 rounds plus a 2-round follow-up, clean | #931 | | 2026-10-18 | S5a: probe format 2 maps a session's tmux client to its active pane, with the pane's foreground job and visible screen. Review Loop clean after 3 rounds | #932 | -| 2026-10-18 | S5b: inside tmux, identity from the mapping and state from the capture | S5b PR | +| 2026-10-18 | S5b: inside tmux, identity from the mapping and state from the capture | #933 | ## S1 — Typed process references -- 2.54.0 From 7b12677510ce7e48dc867802d05b05e8bb76a9b2 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 06:20:36 +0900 Subject: [PATCH 41/51] Read a tmux agent only from its own pane's capture A capture counts only when its pane runs the bound agent. A failed or partial probe, or a capture of another pane during the release hold, keeps the last one; an old capture leaves the state unknown. Inside tmux the client's local screen never decides. --- .../BusinessLogic/RemotePaneTracker.swift | 45 +++++++---- ...WorktreeTerminalState+AgentDetection.swift | 7 +- App/Tests/RemoteAgentDetectionTests.swift | 76 +++++++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 22 ++++-- 4 files changed, 130 insertions(+), 20 deletions(-) diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index d285d031..a9abd992 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -110,9 +110,6 @@ final class RemotePaneTracker { /// When a snapshot last confirmed the bound agent, or for an unprobed binding, when its /// producer last reported. var confirmedAt: Date? - /// The visible screen of the tmux pane the session shows, while correlated (L0 item 6): - /// inside tmux it, not the local screen, is the agent's screen. - var tmuxScreen: String? func context(now: Date, stateKnown: Bool = true) -> RemoteAgentContext { RemoteAgentContext( @@ -148,9 +145,10 @@ final class RemotePaneTracker { private var retired = false private var lastRequestAt: TimeInterval? private(set) var latest: RemoteProbeSnapshot? - /// The tmux pane this pane's session shows, from the latest snapshot that mapped the - /// session; nil when that session shows no tmux client (docs-ai 082, S5). - private(set) var tmuxPane: RemoteProbeTmuxPane? + /// The pane's session shows a tmux client (docs-ai 082, S5); a gap keeps it. + private(set) var isInTmux = false + /// The latest capture whose pane runs the bound agent, and when it was acquired. + private var tmuxCapture: (screen: String, acquiredAt: Date)? private var consumedSequence: (epoch: UInt64, sequence: UInt64)? /// The remote session's anchor, learned from a snapshot that carried this pane's id. On @@ -276,12 +274,28 @@ final class RemotePaneTracker { } return Observation( correlation: correlation, connection: lease?.connection, identified: bound, verdict: verdict, - programStatusFence: boundRecordFence, confirmedAt: confirmedAt, tmuxScreen: tmuxScreen) + programStatusFence: boundRecordFence, confirmedAt: confirmedAt) } - /// The capture of the tmux pane this pane's session shows, while correlated. - var tmuxScreen: String? { - correlation == .correlated ? tmuxPane?.screen : nil + /// The agent's screen when the pane's session shows a tmux client (L0 item 6). + enum TmuxScreen: Equatable { + /// The latest capture of the bound agent's own pane. + case capture(String) + /// No capture of that pane is fresh: the state is unknown, and the local screen (the + /// client's view) never stands in for it. + case unavailable + } + + /// nil outside tmux, where the local screen is the agent's screen. Inside, the last + /// capture that showed the bound agent's pane counts for `RemoteAgentContext.staleAfter` + /// after its acquisition; a failed or partial probe, or a capture of another pane (a + /// switch the presence rule has not released yet), does not replace it. + func tmuxScreen(now: Date) -> TmuxScreen? { + guard isInTmux else { return nil } + guard let tmuxCapture, now.timeIntervalSince(tmuxCapture.acquiredAt) <= RemoteAgentContext.staleAfter else { + return .unavailable + } + return .capture(tmuxCapture.screen) } /// The local screen of a tmux pane changed: the tmux client drew something, so the @@ -348,23 +362,28 @@ final class RemotePaneTracker { // ambiguous scan proves nothing (C3). guard mapped.count == 1 else { return bound == nil ? nil : .unchanged } // A partial snapshot that lost the tmux records is a gap, not a client that left. - if snapshot.isComplete || mapped[0].tmux != nil { tmuxPane = mapped[0].tmux } + if snapshot.isComplete || mapped[0].tmux != nil { isInTmux = mapped[0].tmux != nil } let job = correlatedJob(report: report, epoch: snapshot.connectionEpoch) if let identified = job.flatMap(identifyAgentInJob), identified.process.ref != bound?.process.ref { bound = identified + tmuxCapture = nil presence = RemoteProcessPresence() fenceHistory.bind(identified.process.ref, confirmedAt: now) } guard let current = bound else { return nil } let contains = job?.processes.contains { $0.ref == current.process.ref } ?? false + // Freshness counts from the snapshot's acquisition, not from when a tick read it. + let acquiredAt = now.addingTimeInterval(-max(0, uptime() - snapshot.startedAt)) + // A capture describes the bound agent only when its pane runs that agent. + if contains, let screen = mapped[0].tmux?.screen { tmuxCapture = (screen, acquiredAt) } let verdict = presence.observe(snapshot, contains: contains, now: uptime()) switch verdict { case .present: - // Freshness counts from the snapshot's acquisition, not from when a tick read it. - confirmedAt = now.addingTimeInterval(-max(0, uptime() - snapshot.startedAt)) + confirmedAt = acquiredAt case .released: bound = nil confirmedAt = nil + tmuxCapture = nil presence = RemoteProcessPresence() case .missing, .unchanged: break diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 75a5ca5e..049c2d9a 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -269,7 +269,7 @@ extension WorktreeTerminalState { ) } let localText = view.bridge.readActiveText() ?? "" - if let tmuxScreen = remotePaneTrackers[surfaceID]?.tmuxScreen { + if let tmuxScreen = remotePaneTrackers[surfaceID]?.tmuxScreen(now: Date()) { // Inside tmux the local screen is the tmux client's (status line, other panes, copy // mode); the agent's own screen is the capture. A local change still means the // capture is likely stale, so it asks for a probe now. @@ -277,7 +277,10 @@ extension WorktreeTerminalState { if tmuxLocalScreenHashBySurface.updateValue(localHash, forKey: surfaceID) != localHash { remotePaneTrackers[surfaceID]?.requestRefreshForScreenChange() } - return (tmuxScreen, cachedScreenDetection(forSurfaceID: surfaceID, agent: agent, text: tmuxScreen)) + guard case .capture(let text) = tmuxScreen else { + return ("", AgentScreenDetection(state: .unknown, reason: .noRuleMatched)) + } + return (text, cachedScreenDetection(forSurfaceID: surfaceID, agent: agent, text: text)) } tmuxLocalScreenHashBySurface.removeValue(forKey: surfaceID) let activeText = localText diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index 07128bba..e50dad8d 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -176,6 +176,8 @@ struct RemoteAgentDetectionTests { report: RemoteProbeReport(operatingSystem: .linux, bootID: "boot", sessions: [session])) } + private static let workingClaude = "✻ Tempering… (12s · esc to interrupt)\n" + private static let blockedClaude = """ ✻ Tempering… (12s · esc to interrupt) Do you want to proceed? @@ -1234,6 +1236,80 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) } + /// Binds a working Claude inside tmux while the local screen (the client's view) shows a + /// blocked prompt, which must never decide. + private func bindWorkingTmuxClaude(_ fixture: Fixture, probe: Probe) async { + fixture.surface.bridge.activeTextForTesting = Self.blockedClaude + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + await deliver( + fixture, + tmuxSnapshot( + 1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"), screen: Self.workingClaude)) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .working) + } + + @Test func aMissingCaptureNeverFallsBackToTheClientScreen() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindWorkingTmuxClaude(fixture, probe: probe) + // A failed probe, then a partial one that kept the mapping and process but lost the screen. + await deliver(fixture, snapshot(2, pane: nil, foreground: nil, outcome: .failed(.unreachable))) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .working) + var partial = tmuxSnapshot( + 3, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"), screen: Self.workingClaude) + partial = RemoteProbeSnapshot( + connection: partial.connection, connectionEpoch: 0, sequence: 3, startedAt: partial.startedAt, + finishedAt: partial.finishedAt, outcome: .partial, + report: RemoteProbeReport( + operatingSystem: .linux, bootID: "boot", + sessions: partial.report?.sessions.map { session in + var session = session + session.tmux = session.tmux.map { + RemoteProbeTmuxPane( + clientPID: $0.clientPID, paneID: $0.paneID, tty: $0.tty, isInMode: $0.isInMode, + foregroundProcessGroupID: $0.foregroundProcessGroupID, processes: $0.processes, screen: nil, + isScreenTruncated: false) + } + return session + } ?? [])) + await deliver(fixture, partial) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .working) + #expect(fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID]?.text == Self.workingClaude) + } + + @Test func anOldCaptureLeavesTheStateUnknown() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindWorkingTmuxClaude(fixture, probe: probe) + let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) + #expect(tracker.tmuxScreen(now: Date()) == .capture(Self.workingClaude)) + #expect(tracker.tmuxScreen(now: Date().addingTimeInterval(11)) == .unavailable) + } + + @Test func anotherPanesCaptureNeverDescribesTheRetainedAgent() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindWorkingTmuxClaude(fixture, probe: probe) + // The client switched to a shell pane; the presence rule still holds Claude for one + // more snapshot, and the shell's screen is not Claude's. + await deliver( + fixture, + tmuxSnapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(300, name: "zsh"), screen: "$ ", paneID: "%1")) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .working) + await deliver( + fixture, + tmuxSnapshot(3, pane: fixture.surfaceID, foreground: remoteProcess(300, name: "zsh"), screen: "$ ", paneID: "%1")) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + } + @Test func aChangedLocalScreenInTmuxAsksForACapture() async { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 81b0171b..6ef253aa 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -452,11 +452,23 @@ identity from the mapping and state from the screen. - **Identity.** `RemotePaneTracker.correlatedJob` takes the job from the session's tmux pane when the session shows a tmux client. When that pane's job is empty, there is no agent; the client itself is never classified. -- **Screen.** The tracker keeps `tmuxPane` from the latest snapshot that mapped the - session. A partial snapshot that lost the tmux records is a gap and keeps it. - `Observation.tmuxScreen` carries the capture while the pane is `correlated`. - `pollScreen` reads that text instead of the local screen, with the same per-surface - scan memo. Staleness follows `confirmedAt`, which comes from the same snapshot. +- **Screen.** The tracker separates two facts. + - `isInTmux`: the session shows a tmux client. A partial snapshot without the tmux + records is a gap and keeps it. + - `tmuxCapture`: the latest capture whose pane runs the bound agent, with its + acquisition time. It is cleared on rebind and on release. + + `tmuxScreen(now:)` returns three values: + - nil outside tmux; + - `.capture(text)` while that capture is no older than `RemoteAgentContext.staleAfter`; + - `.unavailable` otherwise. + + `pollScreen` scans the capture with the same per-surface memo. For `.unavailable` it + gives an unknown detection, so the remote context is state-unknown. It never reads the + local screen, which is the client's view, inside tmux. A failed or partial probe, or a + capture of another pane during the two-snapshot release hold, keeps the last capture + of the bound agent's pane, so the retained state never describes another pane (review + round 1). - **Wakes.** Output does not wake the detection loop, so the tick compares a hash of the local screen (`tmuxLocalScreenHashBySurface`). When it changes, the tick calls `requestRefreshForScreenChange()`, which requests at most one probe every -- 2.54.0 From 6699b281cf53a01d73de7c460caf399a2aebafaa Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 06:37:18 +0900 Subject: [PATCH 42/51] Mark an expired tmux capture as unknown state and survive detach An expired capture now makes the remote context state-unknown and replaces the cached scan, since the state machine keeps the last stable state for display. The bound agent keeps its own pane's capture until it is seen elsewhere or released, so a detach to the remote shell never describes it. --- .../BusinessLogic/RemotePaneTracker.swift | 23 ++++-- ...WorktreeTerminalState+AgentDetection.swift | 25 ++++-- .../Models/WorktreeTerminalState.swift | 1 + App/Tests/RemoteAgentDetectionTests.swift | 79 +++++++++++++------ docs-ai/082-ssh-remote-agents/001-action.md | 21 +++-- 5 files changed, 107 insertions(+), 42 deletions(-) diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index a9abd992..ad2384b9 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -147,8 +147,12 @@ final class RemotePaneTracker { private(set) var latest: RemoteProbeSnapshot? /// The pane's session shows a tmux client (docs-ai 082, S5); a gap keeps it. private(set) var isInTmux = false - /// The latest capture whose pane runs the bound agent, and when it was acquired. - private var tmuxCapture: (screen: String, acquiredAt: Date)? + /// The latest capture whose pane runs the bound agent, and when (`uptime`) it was acquired. + private var tmuxCapture: (screen: String, acquiredAt: TimeInterval)? + /// The bound agent was last seen in a tmux pane: its screen stays its pane's capture + /// until it is seen elsewhere or released, even after the client detaches back to the + /// remote shell. + private var boundFromTmux = false private var consumedSequence: (epoch: UInt64, sequence: UInt64)? /// The remote session's anchor, learned from a snapshot that carried this pane's id. On @@ -290,9 +294,9 @@ final class RemotePaneTracker { /// capture that showed the bound agent's pane counts for `RemoteAgentContext.staleAfter` /// after its acquisition; a failed or partial probe, or a capture of another pane (a /// switch the presence rule has not released yet), does not replace it. - func tmuxScreen(now: Date) -> TmuxScreen? { - guard isInTmux else { return nil } - guard let tmuxCapture, now.timeIntervalSince(tmuxCapture.acquiredAt) <= RemoteAgentContext.staleAfter else { + func tmuxScreen() -> TmuxScreen? { + guard isInTmux || (bound != nil && boundFromTmux) else { return nil } + guard let tmuxCapture, uptime() - tmuxCapture.acquiredAt <= RemoteAgentContext.staleAfter else { return .unavailable } return .capture(tmuxCapture.screen) @@ -374,8 +378,12 @@ final class RemotePaneTracker { let contains = job?.processes.contains { $0.ref == current.process.ref } ?? false // Freshness counts from the snapshot's acquisition, not from when a tick read it. let acquiredAt = now.addingTimeInterval(-max(0, uptime() - snapshot.startedAt)) - // A capture describes the bound agent only when its pane runs that agent. - if contains, let screen = mapped[0].tmux?.screen { tmuxCapture = (screen, acquiredAt) } + // A capture describes the bound agent only when its pane runs that agent; where the + // agent was last seen decides which screen is its own. + if contains { + boundFromTmux = mapped[0].tmux != nil + if let screen = mapped[0].tmux?.screen { tmuxCapture = (screen, snapshot.startedAt) } + } let verdict = presence.observe(snapshot, contains: contains, now: uptime()) switch verdict { case .present: @@ -384,6 +392,7 @@ final class RemotePaneTracker { bound = nil confirmedAt = nil tmuxCapture = nil + boundFromTmux = false presence = RemoteProcessPresence() case .missing, .unchanged: break diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 049c2d9a..370b595d 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -239,8 +239,7 @@ extension WorktreeTerminalState { ) next.sessionMissStreak = sessionMissStreak next.decision = decision - next.remote = - next.agentProcess?.origin == .local ? nil : remote?.context(now: now, stateKnown: stabilized != .unknown) + next.remote = remoteContext(remote, origin: next.agentProcess?.origin, stabilized, surfaceID, now: now) // Limit logging to meaningful transitions - agent identity or // stabilized state changes. Raw oscillation and `seen` flips are // routine and would otherwise dominate the log stream. @@ -258,6 +257,18 @@ extension WorktreeTerminalState { /// The screen half of one poll. A verified OSC 7501 root record decides the state, /// so the delegated tick does not read the screen at all: the last scan stands in /// as the raw state and as the resolver's text (docs-ai 079). + /// The published remote context, nil for a local agent. Evidence decides the state only + /// when the stabilized state is known and, inside tmux, a capture is fresh: the state + /// machine keeps the last stable state for display when it is given an unknown. + private func remoteContext( + _ remote: RemotePaneTracker.Observation?, origin: ProcessOrigin?, _ stabilized: AgentRawState, + _ surfaceID: UUID, now: Date + ) -> RemoteAgentContext? { + guard origin != .local else { return nil } + let known = stabilized != .unknown && remotePaneTrackers[surfaceID]?.tmuxScreen() != .unavailable + return remote?.context(now: now, stateKnown: known) + } + private func pollScreen( for view: GhosttySurfaceView, surfaceID: UUID, agent: DetectedAgent, delegated: Bool ) -> (activeText: String, detection: AgentScreenDetection) { @@ -269,7 +280,7 @@ extension WorktreeTerminalState { ) } let localText = view.bridge.readActiveText() ?? "" - if let tmuxScreen = remotePaneTrackers[surfaceID]?.tmuxScreen(now: Date()) { + if let tmuxScreen = remotePaneTrackers[surfaceID]?.tmuxScreen() { // Inside tmux the local screen is the tmux client's (status line, other panes, copy // mode); the agent's own screen is the capture. A local change still means the // capture is likely stale, so it asks for a probe now. @@ -278,7 +289,10 @@ extension WorktreeTerminalState { remotePaneTrackers[surfaceID]?.requestRefreshForScreenChange() } guard case .capture(let text) = tmuxScreen else { - return ("", AgentScreenDetection(state: .unknown, reason: .noRuleMatched)) + // No fresh capture: nothing the cache holds may answer a condition either. + let unknown = AgentScreenDetection(state: .unknown, reason: .noRuleMatched) + lastAgentScreenScanBySurface[surfaceID] = AgentScreenScan(agent: agent, text: "", detection: unknown) + return ("", unknown) } return (text, cachedScreenDetection(forSurfaceID: surfaceID, agent: agent, text: text)) } @@ -496,7 +510,8 @@ extension WorktreeTerminalState { invocation: SSHInvocation.parse( ssh.arguments, workingDirectory: ProcessDetection.currentDirectory(pid: ssh.generation.pid)), fenceHistory: remoteFenceHistoryBySurface[surfaceID]?[ssh.generation], - source: remoteSnapshotSourceForTesting ?? .live + source: remoteSnapshotSourceForTesting ?? .live, + uptime: remoteUptimeForTesting ?? { ProcessInfo.processInfo.systemUptime } ) { [weak self] in self?.wakeAgentDetectionForRemoteSnapshot(surfaceID: surfaceID) } diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift index 6b536924..95ea16f6 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift @@ -250,6 +250,7 @@ final class WorktreeTerminalState { /// restores it with the records it fences. @ObservationIgnored var remoteFenceHistoryBySurface: [UUID: [LocalProcessGeneration: RemoteFenceHistory]] = [:] @ObservationIgnored var remoteSnapshotSourceForTesting: RemoteSnapshotSource? + @ObservationIgnored var remoteUptimeForTesting: (() -> TimeInterval)? @ObservationIgnored var remoteAgentDetectionEnabledForTesting: Bool? @ObservationIgnored var accessibilityPosterForTesting: (@MainActor (Any, NSAccessibility.Notification) -> Void)? var remoteAgentDetectionEnabled: Bool { diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index e50dad8d..a9c751aa 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -158,8 +158,8 @@ struct RemoteAgentDetectionTests { /// A snapshot whose session shows a tmux client (pid 150); its active pane runs `pane`. private func tmuxSnapshot( - _ sequence: UInt64, pane: UUID?, foreground: RemoteProbeProcess, screen: String, inMode: Bool = false, - paneID: String = "%0" + _ sequence: UInt64, pane: UUID?, foreground: RemoteProbeProcess, screen: String?, inMode: Bool = false, + paneID: String = "%0", outcome: RemoteProbeSnapshot.Outcome = .complete, age: TimeInterval = 0 ) -> RemoteProbeSnapshot { let client = remoteProcess(150, name: "tmux") var session = RemoteProbeSession( @@ -169,10 +169,10 @@ struct RemoteAgentDetectionTests { clientPID: 150, paneID: paneID, tty: "/dev/pts/3", isInMode: inMode, foregroundProcessGroupID: foreground.processGroupID, processes: [foreground], screen: screen, isScreenTruncated: false) - let now = ProcessInfo.processInfo.systemUptime + let now = ProcessInfo.processInfo.systemUptime - age return RemoteProbeSnapshot( connection: ConnectionKey(user: "onevcat", hostname: "mini.local"), connectionEpoch: 0, sequence: sequence, - startedAt: now, finishedAt: now, outcome: .complete, + startedAt: now, finishedAt: now, outcome: outcome, report: RemoteProbeReport(operatingSystem: .linux, bootID: "boot", sessions: [session])) } @@ -1259,23 +1259,8 @@ struct RemoteAgentDetectionTests { await deliver(fixture, snapshot(2, pane: nil, foreground: nil, outcome: .failed(.unreachable))) await detect(fixture, screen: .unknown) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .working) - var partial = tmuxSnapshot( - 3, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"), screen: Self.workingClaude) - partial = RemoteProbeSnapshot( - connection: partial.connection, connectionEpoch: 0, sequence: 3, startedAt: partial.startedAt, - finishedAt: partial.finishedAt, outcome: .partial, - report: RemoteProbeReport( - operatingSystem: .linux, bootID: "boot", - sessions: partial.report?.sessions.map { session in - var session = session - session.tmux = session.tmux.map { - RemoteProbeTmuxPane( - clientPID: $0.clientPID, paneID: $0.paneID, tty: $0.tty, isInMode: $0.isInMode, - foregroundProcessGroupID: $0.foregroundProcessGroupID, processes: $0.processes, screen: nil, - isScreenTruncated: false) - } - return session - } ?? [])) + let partial = tmuxSnapshot( + 3, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"), screen: nil, outcome: .partial) await deliver(fixture, partial) await detect(fixture, screen: .unknown) #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .working) @@ -1283,12 +1268,58 @@ struct RemoteAgentDetectionTests { } @Test func anOldCaptureLeavesTheStateUnknown() async throws { + let probe = Probe() + let fixture = makeFixture(probe: probe) + var clock: TimeInterval = 1_000 + fixture.state.remoteUptimeForTesting = { clock } + var emitted: [ActiveAgentEntry] = [] + fixture.state.onAgentEntryChanged = { emitted.append($0) } + fixture.surface.bridge.activeTextForTesting = "$ " + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + let claude = remoteProcess(200, name: "claude") + func snapshot(_ sequence: UInt64, screen: String?) -> RemoteProbeSnapshot { + let base = tmuxSnapshot( + sequence, pane: fixture.surfaceID, foreground: claude, screen: screen, + outcome: screen == nil ? .partial : .complete) + return RemoteProbeSnapshot( + connection: base.connection, connectionEpoch: 0, sequence: sequence, startedAt: clock, finishedAt: clock, + outcome: base.outcome, report: base.report) + } + await deliver(fixture, snapshot(1, screen: Self.blockedClaude)) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .blocked) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.remote?.isStateKnown == true) + // Twelve seconds of probes that confirm the process but lose the screen. + clock = 1_012 + await deliver(fixture, snapshot(2, screen: nil)) + await detect(fixture, screen: .unknown) + let pane = try #require(fixture.state.surfaceAgentStates[fixture.surfaceID]) + #expect(pane.remote?.isStale == false) + #expect(pane.remote?.isStateKnown == false) + #expect(emitted.last?.remote?.isStateKnown == false) + #expect(fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID]?.detection.state == .unknown) + // A fresh capture of the agent's pane recovers it. + await deliver(fixture, snapshot(3, screen: Self.workingClaude)) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.remote?.isStateKnown == true) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .working) + } + + @Test func aDetachedClientKeepsTheRetainedAgentOnItsOwnCapture() async { let probe = Probe() let fixture = makeFixture(probe: probe) await bindWorkingTmuxClaude(fixture, probe: probe) - let tracker = try #require(fixture.state.remotePaneTrackers[fixture.surfaceID]) - #expect(tracker.tmuxScreen(now: Date()) == .capture(Self.workingClaude)) - #expect(tracker.tmuxScreen(now: Date().addingTimeInterval(11)) == .unavailable) + // The user detached: the session shows the remote shell, and the local screen with it. + fixture.surface.bridge.activeTextForTesting = "$ " + await deliver(fixture, snapshot(2, pane: fixture.surfaceID, foreground: remoteProcess(101, name: "zsh"))) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .working) + await deliver(fixture, snapshot(3, pane: fixture.surfaceID, foreground: remoteProcess(101, name: "zsh"))) + await detect(fixture, screen: .unknown) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) } @Test func anotherPanesCaptureNeverDescribesTheRetainedAgent() async { diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 6ef253aa..459f6a59 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -463,12 +463,21 @@ identity from the mapping and state from the screen. - `.capture(text)` while that capture is no older than `RemoteAgentContext.staleAfter`; - `.unavailable` otherwise. - `pollScreen` scans the capture with the same per-surface memo. For `.unavailable` it - gives an unknown detection, so the remote context is state-unknown. It never reads the - local screen, which is the client's view, inside tmux. A failed or partial probe, or a - capture of another pane during the two-snapshot release hold, keeps the last capture - of the bound agent's pane, so the retained state never describes another pane (review - round 1). + `pollScreen` scans the capture with the same per-surface memo, and never reads the local + screen (the client's view) inside tmux. + - A failed or partial probe, or a capture of another pane during the two-snapshot + release hold, keeps the last capture of the bound agent's pane. So the retained + state never describes another pane (review round 1). + - For `.unavailable`, it stores an unknown scan. The context's `isStateKnown` also + reads the capture's availability, because the state machine keeps the last stable + state for display when it is given an unknown. Conditions then see `unknown`, not the + old state (review round 2). + - The capture's age counts in the tracker's `uptime`, which tests inject through + `remoteUptimeForTesting`. +- **Where the agent was last seen.** `boundFromTmux` follows the latest snapshot that + contains the bound agent. After a detach, the session shows the remote shell, while the + retained agent keeps its own capture until the presence rule releases it. The shell + screen never describes it (review round 2). - **Wakes.** Output does not wake the detection loop, so the tick compares a hash of the local screen (`tmuxLocalScreenHashBySurface`). When it changes, the tick calls `requestRefreshForScreenChange()`, which requests at most one probe every -- 2.54.0 From 9eb53ffca805c61288c5d16f8ae9e239b816d923 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 06:40:50 +0900 Subject: [PATCH 43/51] Record the final S5b E2E in the 082 action log --- docs-ai/082-ssh-remote-agents/001-action.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 459f6a59..c4b6378f 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -15,7 +15,7 @@ sections below record what shipped and what differs from the plan. | 2026-10-18 | S4a: host label and connection health on Active Agents rows, `remote` object in `prowl agents --json`, docs (behind the flag). Review Loop clean after 2 rounds | #930 | | 2026-10-18 | S4b: pane and tab host label, ssh-ended marker; remote detection on by default. Review Loop: 3 rounds plus a 2-round follow-up, clean | #931 | | 2026-10-18 | S5a: probe format 2 maps a session's tmux client to its active pane, with the pane's foreground job and visible screen. Review Loop clean after 3 rounds | #932 | -| 2026-10-18 | S5b: inside tmux, identity from the mapping and state from the capture | #933 | +| 2026-10-18 | S5b: inside tmux, identity from the mapping and state from the capture. Review Loop clean after 3 rounds. L0 complete | #933 | ## S1 — Typed process references @@ -494,4 +494,11 @@ identity from the mapping and state from the screen. back showed it after 3.79 s. With no agent bound, the idle cadence applies. - A second Prowl pane attached as another client showed the same agent, `correlated`. - Debian trixie (tmux 3.5a): Claude in tmux was identified after 2.45 s. +- **Final E2E on the reviewed HEAD** (mini, tmux 3.7c): + - Claude appeared after 0.68 s and settled on `claude.idleComposer`. + - A prompt read as `claude.spinner`; copy mode during the work stayed working; done + came from the capture. + - A detach during work kept it working (never idle from the remote shell) until the + release at 0.56 s. Reattaching showed it working again after 0.58 s. + - `state_known` stayed true throughout. -- 2.54.0 From 51d109b4e7eb6e93ee2014dd402df22823b1c287 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 11:06:25 +0900 Subject: [PATCH 44/51] Call the ControlMaster connection the shared connection in comments --- .../Infrastructure/RemoteHost/RemoteConnectionOwner.swift | 4 ++-- App/Tests/RemoteConnectionLiveTests.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift b/App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift index 7d5f70c4..8d23e303 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteConnectionOwner.swift @@ -102,8 +102,8 @@ actor RemoteConnectionOwner { } } - /// Stops the probe in flight, finishes every subscription, and closes the control - /// master, so no `ssh` process outlives the owner for longer than its own exit. + /// Stops the probe in flight, finishes every subscription, and closes the shared + /// connection, so no `ssh` process outlives the owner for longer than its own exit. func shutdown() async { refreshPending = false running?.cancel() diff --git a/App/Tests/RemoteConnectionLiveTests.swift b/App/Tests/RemoteConnectionLiveTests.swift index 81e71886..1bb9eec2 100644 --- a/App/Tests/RemoteConnectionLiveTests.swift +++ b/App/Tests/RemoteConnectionLiveTests.swift @@ -28,7 +28,7 @@ struct RemoteConnectionLiveTests { let first = try #require(await owner.latest) #expect(first.outcome == .complete, "\(first.outcome)") #expect(first.report.map { !$0.bootID.isEmpty } == true) - // The second run reuses the master. + // The second run reuses the shared connection. await owner.requestRefresh() await owner.waitUntilIdle() let second = try #require(await owner.latest) -- 2.54.0 From ba6367efe554ea90e47f5b8e45afc4455fec0d87 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 19 Oct 2026 02:24:46 +0900 Subject: [PATCH 45/51] Treat an unreachable tmux server as a gap and say so A session whose tmux client the probe could not map no longer proves that the agent left. It is a gap for that pane only: the tracker keeps the bound agent, counts no miss, and binds nothing new. The new correlation tmuxUnreachable tells the user why: the pane badge shows a warning that names tmux -S, and --json reports remote.correlation tmux_unreachable. A degraded OSC-only binding is not allowed there, because tmux drops OSC 7501. The docs describe the screen budget, the kept bottom lines, and the tmux limits. --- .../CLIService/AgentsCommandHandler.swift | 1 + .../AgentDetection/RemotePaneIndicator.swift | 2 +- .../Models/RemoteAgentPresentation.swift | 9 +++ .../BusinessLogic/RemotePaneTracker.swift | 17 +++++- App/Tests/RemoteAgentDetectionTests.swift | 60 +++++++++++++++++++ App/Tests/RemoteAgentPresentationTests.swift | 5 ++ .../Resources/cli-output-schema.json | 3 +- .../ProwlCLIShared/AgentsCommandPayload.swift | 3 +- docs-ai/082-ssh-remote-agents/001-action.md | 10 ++++ docs/components/agent-detection.md | 12 +++- docs/components/cli.md | 3 +- 11 files changed, 116 insertions(+), 9 deletions(-) diff --git a/App/Sources/CLIService/AgentsCommandHandler.swift b/App/Sources/CLIService/AgentsCommandHandler.swift index 813377d0..8440b247 100644 --- a/App/Sources/CLIService/AgentsCommandHandler.swift +++ b/App/Sources/CLIService/AgentsCommandHandler.swift @@ -181,6 +181,7 @@ final class AgentsCommandHandler: CommandHandler { case .unmapped: ("unmapped", nil) case .probeUnavailable(let failure): ("probe_unavailable", failure.map(failureReason) ?? "connection_failed") case .unsupported(let reason): ("unsupported", reason) + case .tmuxUnreachable: ("tmux_unreachable", nil) } } diff --git a/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift b/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift index 6e20f019..6519caa8 100644 --- a/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift +++ b/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift @@ -28,7 +28,7 @@ nonisolated enum RemotePaneIndicator: Hashable, Sendable { static func issue(_ correlation: RemotePaneCorrelation) -> RemotePaneCorrelation? { switch correlation { case .pending, .correlated: nil - case .ambiguous, .unmapped, .probeUnavailable, .unsupported: correlation + case .ambiguous, .unmapped, .probeUnavailable, .unsupported, .tmuxUnreachable: correlation } } } diff --git a/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift b/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift index 11624bfb..1002c63a 100644 --- a/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift +++ b/App/Sources/Features/ActiveAgents/Models/RemoteAgentPresentation.swift @@ -70,12 +70,21 @@ nonisolated enum RemoteAgentPresentation { return String(localized: "Prowl cannot read \(host): \(failureReason(failure)).") case .unsupported(let reason): return String(localized: "Prowl does not read hosts for this ssh command: \(reason).") + case .tmuxUnreachable: + return String( + localized: """ + Prowl cannot reach the tmux server that this pane shows on \(host), so it does not detect \ + the agents in it. Prowl looks for tmux servers only in the default socket directory: a \ + server started with tmux -S is not found. + """) } } /// Why Prowl does not read a pane's agents, with what still works. static func paneIssueExplanation(_ issue: RemotePaneCorrelation, host: String) -> String { let reason = correlationExplanation(issue, host: host) + // tmux drops OSC 7501, so no report reaches the pane either. + if issue == .tmuxUnreachable { return reason } let fallback = String( localized: "Agents in this pane are detected only when they report their status themselves (OSC 7501).") return "\(reason) \(fallback)" diff --git a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift index ad2384b9..b0fc92ab 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/RemotePaneTracker.swift @@ -86,6 +86,9 @@ nonisolated enum RemotePaneCorrelation: Hashable, Sendable { case probeUnavailable(RemoteProbeFailure?) /// The local `ssh` command line is outside the supported grammar. case unsupported(String) + /// The pane's session shows a tmux client whose server the probe cannot reach, for + /// example one started with `tmux -S` outside the default socket directory. + case tmuxUnreachable } /// Tracks one pane's foreground `ssh` and the remote agent it shows (docs-ai 082, L0). @@ -326,7 +329,8 @@ final class RemotePaneTracker { guard bound == nil else { return false } switch correlation { case .unmapped, .probeUnavailable, .unsupported: return true - case .pending, .correlated, .ambiguous: return false + // tmux drops OSC 7501, and the local screen is the client's view. + case .pending, .correlated, .ambiguous, .tmuxUnreachable: return false } } @@ -365,6 +369,12 @@ final class RemotePaneTracker { // Only this pane's own session is evidence about its agent: an unmapped or // ambiguous scan proves nothing (C3). guard mapped.count == 1 else { return bound == nil ? nil : .unchanged } + // The session shows a tmux client whose server the probe could not reach: its own + // foreground is that client, so the snapshot says nothing about the agent in the pane. + if mapped[0].hasUnreadTmuxPane { + isInTmux = true + return bound == nil ? nil : .unchanged + } // A partial snapshot that lost the tmux records is a gap, not a client that left. if snapshot.isComplete || mapped[0].tmux != nil { isInTmux = mapped[0].tmux != nil } let job = correlatedJob(report: report, epoch: snapshot.connectionEpoch) @@ -408,9 +418,10 @@ final class RemotePaneTracker { if case .failed(let failure) = latest.outcome { return .probeUnavailable(failure) } return .probeUnavailable(nil) } - switch sessions(in: report, epoch: latest.connectionEpoch).count { + let mapped = sessions(in: report, epoch: latest.connectionEpoch) + switch mapped.count { case 0: return latest.isComplete ? .unmapped : .pending - case 1: return .correlated + case 1: return mapped[0].hasUnreadTmuxPane ? .tmuxUnreachable : .correlated default: return .ambiguous } } diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index a9c751aa..ed8dec1d 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -176,6 +176,26 @@ struct RemoteAgentDetectionTests { report: RemoteProbeReport(operatingSystem: .linux, bootID: "boot", sessions: [session])) } + /// A complete snapshot whose session shows a tmux client (pid 150) whose server the probe + /// could not reach. + private func unreadTmuxSnapshot(_ sequence: UInt64, pane: UUID?) -> RemoteProbeSnapshot { + let client = remoteProcess(150, name: "tmux") + var session = RemoteProbeSession( + paneID: pane, anchorPID: 100, tty: "/dev/pts/0", foregroundProcessGroupID: client.processGroupID, + processes: [client]) + session.hasUnreadTmuxPane = true + let now = ProcessInfo.processInfo.systemUptime + return RemoteProbeSnapshot( + connection: ConnectionKey(user: "onevcat", hostname: "mini.local"), connectionEpoch: 0, sequence: sequence, + startedAt: now, finishedAt: now, outcome: .complete, + report: RemoteProbeReport(operatingSystem: .linux, bootID: "boot", sessions: [session])) + } + + private func paneIssue(_ fixture: Fixture) -> RemotePaneCorrelation? { + guard case .connected(_, _, let issue) = fixture.surface.bridge.state.remoteIndicator else { return nil } + return issue + } + private static let workingClaude = "✻ Tempering… (12s · esc to interrupt)\n" private static let blockedClaude = """ @@ -1267,6 +1287,46 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.lastAgentScreenScanBySurface[fixture.surfaceID]?.text == Self.workingClaude) } + @Test func anUnreachableTmuxServerIsAGapAndNotAnExit() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + await bindWorkingTmuxClaude(fixture, probe: probe) + // The server's socket went away (or its binary no longer speaks to it): the session's + // foreground is only the client, which says nothing about the agent in the pane. + for sequence in UInt64(2)...4 { + await deliver(fixture, unreadTmuxSnapshot(sequence, pane: fixture.surfaceID)) + await detect(fixture, screen: .unknown) + } + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.agentProcess == remoteRef(200)) + #expect(fixture.state.remotePaneTrackers[fixture.surfaceID]?.correlation == .tmuxUnreachable) + // The pane says why its agents are not read. + #expect(paneIssue(fixture) == .tmuxUnreachable) + // The local screen (the client's view) never stands in. + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .working) + // The server is back. + await deliver( + fixture, + tmuxSnapshot( + 5, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"), screen: Self.blockedClaude)) + await detect(fixture, screen: .unknown) + #expect(fixture.state.remotePaneTrackers[fixture.surfaceID]?.correlation == .correlated) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.state == .blocked) + } + + @Test func aTmuxClientOfAnUnreachableServerNeverBindsAnAgent() async { + let probe = Probe() + let fixture = makeFixture(probe: probe) + fixture.surface.bridge.activeTextForTesting = Self.workingClaude + probe.job = sshJob + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + await deliver(fixture, unreadTmuxSnapshot(1, pane: fixture.surfaceID)) + await detect(fixture, screen: .working) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == nil) + #expect(paneIssue(fixture) == .tmuxUnreachable) + #expect(fixture.state.remotePaneTrackers[fixture.surfaceID]?.allowsUnprobedBinding == false) + } + @Test func anOldCaptureLeavesTheStateUnknown() async throws { let probe = Probe() let fixture = makeFixture(probe: probe) diff --git a/App/Tests/RemoteAgentPresentationTests.swift b/App/Tests/RemoteAgentPresentationTests.swift index bb55358e..ef64b8b9 100644 --- a/App/Tests/RemoteAgentPresentationTests.swift +++ b/App/Tests/RemoteAgentPresentationTests.swift @@ -59,6 +59,11 @@ struct RemoteAgentPresentationTests { #expect( RemoteAgentPresentation.correlationExplanation(exit, host: "h") == "Prowl cannot read h: Connection closed by 10.0.0.2 port 22.") + // tmux drops OSC 7501, so the pane names no fallback that would not work. + let tmux = RemoteAgentPresentation.paneIssueExplanation(.tmuxUnreachable, host: "h") + #expect(tmux.contains("tmux -S")) + #expect(!tmux.contains("OSC 7501")) + #expect(AgentsCommandHandler.correlation(.tmuxUnreachable) == ("tmux_unreachable", nil)) } @Test func theRowTooltipAddsTheExplanationForRemoteAgents() { diff --git a/CLI/Sources/ProwlCLIContracts/Resources/cli-output-schema.json b/CLI/Sources/ProwlCLIContracts/Resources/cli-output-schema.json index 86d4679a..3eb14ad8 100644 --- a/CLI/Sources/ProwlCLIContracts/Resources/cli-output-schema.json +++ b/CLI/Sources/ProwlCLIContracts/Resources/cli-output-schema.json @@ -1141,7 +1141,8 @@ "ambiguous", "unmapped", "probe_unavailable", - "unsupported" + "unsupported", + "tmux_unreachable" ] }, "correlation_reason": { diff --git a/Shared/Sources/ProwlCLIShared/AgentsCommandPayload.swift b/Shared/Sources/ProwlCLIShared/AgentsCommandPayload.swift index 6ddde973..e64852e5 100644 --- a/Shared/Sources/ProwlCLIShared/AgentsCommandPayload.swift +++ b/Shared/Sources/ProwlCLIShared/AgentsCommandPayload.swift @@ -88,7 +88,8 @@ public struct AgentsCommandRemote: Codable, Equatable { public let user: String? public let hostname: String? public let port: Int? - /// `pending`, `correlated`, `ambiguous`, `unmapped`, `probe_unavailable`, or `unsupported`. + /// `pending`, `correlated`, `ambiguous`, `unmapped`, `probe_unavailable`, `unsupported`, or + /// `tmux_unreachable`. public let correlation: String /// Why the host cannot be read, or why the `ssh` command is not supported. public let correlationReason: String? diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index c4b6378f..82a3445a 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -483,6 +483,16 @@ identity from the mapping and state from the screen. `requestRefreshForScreenChange()`, which requests at most one probe every `screenChangeSpacing` (0.25 s). The owner coalesces the rest, so at most one probe is in flight. +- **Unreachable tmux server** (after the S5a design review). A session with + `hasUnreadTmuxPane` is a gap for this pane alone: `consume` keeps `isInTmux`, does not + count a miss, and binds nothing. Its correlation is `tmuxUnreachable`: + - the pane badge shows the warning, with a reason that names `tmux -S` and no OSC 7501 + fallback (tmux drops it); + - `--json` reports `remote.correlation` `tmux_unreachable`; + - a degraded OSC-only binding is not allowed there. + + A bound agent stays listed and its state becomes unknown when its capture is older than + `RemoteAgentContext.staleAfter`. - **Test seam.** `GhosttySurfaceBridge.activeTextForTesting`. - **Measured** (macOS 26.5, tmux 3.7c, Claude 2.1.302): - Claude in `tmux -L s5e2e` appeared after 0.35 s (`claude.idleComposer` from the diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 6cc9c3a7..5d8e416d 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -443,8 +443,16 @@ false` and relaunch Prowl, or launch it with `PROWL_REMOTE_AGENT_DETECTION=0`. the state. Switching to another pane releases the agent, and switching back shows it again within a few seconds. A second client attached to the same pane shows the same agent in its own Prowl pane. A changing local screen asks for a fresh capture, with at - most one probe in flight. Servers whose socket is outside - `${TMUX_TMPDIR:-/tmp}/tmux-/` are not found. + most one probe in flight. A screen larger than 64 KiB keeps its last lines. When many + panes are captured at once, the screens of Prowl panes come first, and a screen past + the probe's budget leaves that agent's state unknown for that probe. +- **tmux limits.** Prowl looks for tmux servers only in `${TMUX_TMPDIR:-/tmp}/tmux-/`. + When it cannot reach the server of a client (for example a server started with + `tmux -S `, or a removed socket), the pane badge shows a warning and + `remote.correlation` is `tmux_unreachable`. An agent that was already listed stays + listed, and its state becomes unknown after 10 s; it is not released. Prowl follows + one level of tmux only: a tmux inside a tmux pane, or a program in a `display-popup`, + is not read. - **Release.** A probed agent is released after two complete fresh snapshots without it (about 2–3 s after it exits), or at once when the `ssh` exits. - **Limits.** A remote agent cannot be a `prowl agents dispatch` target that needs a diff --git a/docs/components/cli.md b/docs/components/cli.md index 0190f061..c09d7332 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -239,7 +239,8 @@ Each agent contains: - `host` (`user@hostname`), `user`, `hostname`, `port`: absent while the connection is not resolved. - `correlation`: `pending`, `correlated`, `ambiguous`, `unmapped`, - `probe_unavailable`, or `unsupported`. + `probe_unavailable`, `unsupported`, or `tmux_unreachable` (the pane shows a tmux + client whose server Prowl cannot reach). - `correlation_reason`: why. For `probe_unavailable` it is a code: `authentication`, `host_key`, `unresolvable_host`, `unreachable`, `launch`, `exit`, `unrecognized_output`, or `connection_failed`. For `unsupported` it names the `ssh` -- 2.54.0 From b968e3e088b7db15713b9b0c0703fec5ea655a47 Mon Sep 17 00:00:00 2001 From: onevcat Date: Mon, 19 Oct 2026 20:40:25 +0900 Subject: [PATCH 46/51] Shrink the pane badge to its glyph after five seconds The full badge covered the right part of a tmux status line all the time. It now names the host for five seconds after it appears or changes, then shows only its glyph; the pointer on the glyph shows the host again. A cancelled timer no longer expires the next indicator. The pane's accessibility help joins the pane index and the remote reason with a line break, so VoiceOver does not read them as one sentence. --- .../AgentDetection/RemotePaneIndicator.swift | 3 + .../Terminal/Views/RemotePaneBadge.swift | 67 ++++++++++++++----- .../GhosttySurfaceView+Accessibility.swift | 4 +- App/Tests/RemoteAgentDetectionTests.swift | 3 +- docs-ai/082-ssh-remote-agents/001-action.md | 10 +++ docs/components/agent-detection.md | 3 +- docs/components/terminal.md | 8 ++- 7 files changed, 75 insertions(+), 23 deletions(-) diff --git a/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift b/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift index 6519caa8..f04fe607 100644 --- a/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift +++ b/App/Sources/Domain/AgentDetection/RemotePaneIndicator.swift @@ -4,6 +4,9 @@ import Foundation nonisolated enum RemotePaneIndicator: Hashable, Sendable { /// How long the pane says that its `ssh` ended. static let endedMarkerDuration: TimeInterval = 5 + /// How long the pane names the host after the label appears or changes, before it + /// shows only its glyph. + static let hostLabelDuration: TimeInterval = 5 /// The pane's foreground `ssh` (this local generation) reaches this host. `issue` is the /// correlation that keeps Prowl from reading the pane's agents, whether or not an agent diff --git a/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift b/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift index 7ca98bdc..8c4c90fd 100644 --- a/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift +++ b/App/Sources/Features/Terminal/Views/RemotePaneBadge.swift @@ -1,29 +1,40 @@ import SwiftUI /// The host a pane's foreground `ssh` reaches, in the pane's corner (docs-ai 082, L0 -/// item 10). When that `ssh` exits, it says so for `RemotePaneIndicator.endedMarkerDuration` -/// after the exit, then goes away; a later mount of the view does not show it again. +/// item 10). It names the host for `RemotePaneIndicator.hostLabelDuration` after it appears +/// or changes, then shrinks to its glyph so that it does not keep the terminal's bottom line +/// (often a tmux status line) covered; the pointer on the glyph shows the host again. When +/// that `ssh` exits, it says so for `RemotePaneIndicator.endedMarkerDuration` after the +/// exit, then goes away; a later mount of the view does not show it again. struct RemotePaneBadge: View { let indicator: RemotePaneIndicator? @State private var isEndedMarkerExpired = false + @State private var isHostLabelExpired = false + @State private var isHovering = false var body: some View { Group { switch indicator { case .connected(let host, _, nil): - label(Text(verbatim: host), systemImage: "network") + label(Text(verbatim: host), systemImage: "network", isExpanded: isHostShown) + .onHover { isHovering = $0 } + .onDisappear { isHovering = false } .accessibilityLabel(Text("Connected to \(host)")) - .allowsHitTesting(false) case .connected(let host, _, let issue?): - // The reason is the tooltip, so this capsule (and only it) takes the pointer. + // The reason is the tooltip. let explanation = RemoteAgentPresentation.paneIssueExplanation(issue, host: host) - label(Text(verbatim: host), systemImage: "exclamationmark.triangle.fill", multicolor: true) - .help(explanation) - .accessibilityLabel(Text("Connected to \(host). Agents are not detected.")) - .accessibilityHint(Text(verbatim: explanation)) + label( + Text(verbatim: host), systemImage: "exclamationmark.triangle.fill", multicolor: true, + isExpanded: isHostShown + ) + .onHover { isHovering = $0 } + .onDisappear { isHovering = false } + .help(explanation) + .accessibilityLabel(Text("Connected to \(host). Agents are not detected.")) + .accessibilityHint(Text(verbatim: explanation)) case .ended(let host, _): if !isEndedMarkerExpired, indicator?.showsEndedMarker(now: Date()) == true { - label(Text("Disconnected from \(host)"), systemImage: "network.slash") + label(Text("Disconnected from \(host)"), systemImage: "network.slash", isExpanded: true) .transition(.opacity) .allowsHitTesting(false) } @@ -32,17 +43,32 @@ struct RemotePaneBadge: View { } } .animation(.easeOut(duration: 0.3), value: isEndedMarkerExpired) + .animation(.easeOut(duration: 0.2), value: isHostShown) .task(id: indicator) { isEndedMarkerExpired = false - guard case .ended(_, let endedAt) = indicator else { return } - let remaining = endedAt.addingTimeInterval(RemotePaneIndicator.endedMarkerDuration).timeIntervalSinceNow - if remaining > 0 { try? await Task.sleep(for: .seconds(remaining)) } - isEndedMarkerExpired = true + isHostLabelExpired = false + let remaining: TimeInterval + switch indicator { + case .connected: + remaining = RemotePaneIndicator.hostLabelDuration + case .ended(_, let endedAt): + remaining = endedAt.addingTimeInterval(RemotePaneIndicator.endedMarkerDuration).timeIntervalSinceNow + case nil: + return + } + // A cancelled sleep means a new indicator, whose own task resets the flags. + if remaining > 0 { + do { try await Task.sleep(for: .seconds(remaining)) } catch { return } + } + if case .ended = indicator { isEndedMarkerExpired = true } else { isHostLabelExpired = true } } } - private func label(_ text: Text, systemImage: String, multicolor: Bool = false) -> some View { - Label { + private var isHostShown: Bool { !isHostLabelExpired || isHovering } + + @ViewBuilder + private func label(_ text: Text, systemImage: String, multicolor: Bool = false, isExpanded: Bool) -> some View { + let label = Label { text.lineLimit(1).truncationMode(.middle) } icon: { Image(systemName: systemImage) @@ -50,12 +76,19 @@ struct RemotePaneBadge: View { .symbolRenderingMode(multicolor ? .multicolor : .monochrome) .accessibilityHidden(true) } - .labelStyle(.titleAndIcon) + Group { + if isExpanded { + label.labelStyle(.titleAndIcon) + } else { + label.labelStyle(.iconOnly) + } + } .font(.caption2) .foregroundStyle(.secondary) .padding(.horizontal, 6) .padding(.vertical, 2) .background(.regularMaterial, in: .capsule) + .contentShape(.capsule) .padding(6) } } diff --git a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift index cb948143..a38f007f 100644 --- a/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift +++ b/App/Sources/Infrastructure/Ghostty/GhosttySurfaceView+Accessibility.swift @@ -47,7 +47,9 @@ extension GhosttySurfaceView { override func accessibilityHelp() -> String? { let remote = RemoteAgentPresentation.paneAccessibility(bridge.state.remoteIndicator, now: Date()).help let parts = [accessibilityPaneIndexHelp, remote].compactMap(\.self) - return parts.isEmpty ? nil : parts.joined(separator: " ") + // "Pane n of m" has no end punctuation in any language, so a space would run it into + // the next sentence; VoiceOver pauses at a line break. + return parts.isEmpty ? nil : parts.joined(separator: "\n") } override func accessibilitySelectedTextRange() -> NSRange { diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index ed8dec1d..fc13de7f 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -419,7 +419,8 @@ struct RemoteAgentDetectionTests { #expect(surface.accessibilityHelp() == "Pane 1 of 2") surface.bridge.state.remoteIndicator = .connected(host: "dev@box", ssh: sshGeneration, issue: .unmapped) #expect(surface.accessibilityLabel() == "Claude Code, connected to dev@box") - #expect(surface.accessibilityHelp()?.hasPrefix("Pane 1 of 2 dev@box has no session for this pane.") == true) + // A line break keeps the two sentences apart when VoiceOver reads the help. + #expect(surface.accessibilityHelp()?.hasPrefix("Pane 1 of 2\ndev@box has no session for this pane.") == true) surface.bridge.state.remoteIndicator = .ended(host: "dev@box", endedAt: Date()) #expect(surface.accessibilityLabel() == "Claude Code, disconnected from dev@box") // An old exit is not announced. diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 82a3445a..0075ce20 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -493,6 +493,16 @@ identity from the mapping and state from the screen. A bound agent stays listed and its state becomes unknown when its capture is older than `RemoteAgentContext.staleAfter`. +- **Compact badge** (live check, 2026-10-19). In tmux, the full badge covered the right + part of the status line all the time. `RemotePaneBadge` now names the host for + `RemotePaneIndicator.hostLabelDuration` (5 s) after the indicator appears or changes (a + new issue included), then shows only its glyph. The pointer on the glyph shows the host + again, so the glyph takes hits; the ended marker still does not. A view mount (such as a + tab switch) names the host again. A cancelled timer no longer marks the next indicator as + expired. +- **Accessibility help** (live check). The pane's help joined "Pane n of m" and the issue + reason with a space, so VoiceOver read them as one sentence. It joins them with a line + break now. - **Test seam.** `GhosttySurfaceBridge.activeTextForTesting`. - **Measured** (macOS 26.5, tmux 3.7c, Claude 2.1.302): - Claude in `tmux -L s5e2e` appeared after 0.35 s (`claude.idleComposer` from the diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index 5d8e416d..d984a6b9 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -413,7 +413,8 @@ false` and relaunch Prowl, or launch it with `PROWL_REMOTE_AGENT_DETECTION=0`. screen decides. - **Pane and tab.** A pane whose foreground `ssh` reaches a host shows `user@host` in its bottom-trailing corner, and its tab shows a network glyph before the title (the tooltip - names the hosts). When that `ssh` exits and the pane is back in its local shell, the + names the hosts). Five seconds after the label appears or changes, only its glyph stays; + the pointer on the glyph shows the host again. When that `ssh` exits and the pane is back in its local shell, the corner reads "Disconnected from user@host" for five seconds. A suspended `ssh` (still running) only removes the label. When Prowl cannot read the pane's agents (the host has no session for the pane, the side channel fails, or the `ssh` command is not supported), diff --git a/docs/components/terminal.md b/docs/components/terminal.md index a06afab6..7ee00a31 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -103,9 +103,11 @@ later recognised command in the same tab still takes it over. A tab with a pane whose foreground `ssh` reaches a remote host also shows a network glyph before its title, and the tooltip names the host. The pane itself shows `user@host` in its -bottom-trailing corner, and "Disconnected from user@host" for five seconds after that `ssh` -exits. A yellow warning glyph in that label means Prowl cannot read the pane's agents; its -tooltip says why. See [agent detection](agent-detection.md#remote-agents-over-ssh). +bottom-trailing corner for five seconds when the label appears or changes. Then only the +network glyph stays, so the label does not cover the terminal's bottom line (for example a +tmux status line); put the pointer on the glyph to see the host again. After that `ssh` +exits, the corner reads "Disconnected from user@host" for five seconds. A yellow warning +glyph in that label means Prowl cannot read the pane's agents; its tooltip says why. See [agent detection](agent-detection.md#remote-agents-over-ssh). ## Shell integration & status (OSC sequences) -- 2.54.0 From bb0ee4e802bb2581f8eed5c969b0c4afaa8cf0d4 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 21:07:56 +0900 Subject: [PATCH 47/51] Ask before the first SSH side-channel connection The stored answer is undecided, enabled or disabled, and starts undecided. While it is undecided, a pane's ssh makes no Prowl connection; the first ssh inside the grammar asks once in the toolbar status, with a panel that takes no keyboard focus. Settings > Agents > Display holds the switch, and the environment variable still overrides it. The toolbar status also shows the focused pane's side channel, with a panel that tells what the extra connection does. --- .../Clients/Terminal/TerminalClient.swift | 3 + .../AppFeature+RemoteAgentConsent.swift | 45 ++++ .../Reducer/AppFeature+TerminalEvents.swift | 4 + .../Features/App/Reducer/AppFeature.swift | 16 ++ .../Views/RemoteAgentToolbarStatus.swift | 238 ++++++++++++++++++ .../Views/ToolbarStatusView.swift | 26 +- .../Views/WorktreeDetailToolbarViews.swift | 8 +- .../Views/WorktreeDetailView.swift | 36 ++- .../Settings/Models/GlobalSettings.swift | 13 + .../Settings/Reducer/SettingsFeature.swift | 11 + .../Views/AgentDisplaySettingsView.swift | 15 ++ .../TerminalEventCoalescer.swift | 2 +- .../WorktreeTerminalManager.swift | 31 ++- ...WorktreeTerminalState+AgentDetection.swift | 39 ++- .../WorktreeTerminalState+ProgramStatus.swift | 1 + .../Models/WorktreeTerminalState.swift | 15 +- .../RemoteHost/RemoteAgentDetection.swift | 37 ++- App/Tests/RemoteAgentConsentTests.swift | 178 +++++++++++++ App/Tests/RemoteAgentDetectionTests.swift | 81 +++++- docs-ai/082-ssh-remote-agents/000-plan.md | 19 ++ docs/components/agent-detection.md | 20 +- docs/components/settings.md | 2 +- 22 files changed, 798 insertions(+), 42 deletions(-) create mode 100644 App/Sources/Features/App/Reducer/AppFeature+RemoteAgentConsent.swift create mode 100644 App/Sources/Features/Repositories/Views/RemoteAgentToolbarStatus.swift create mode 100644 App/Tests/RemoteAgentConsentTests.swift diff --git a/App/Sources/Clients/Terminal/TerminalClient.swift b/App/Sources/Clients/Terminal/TerminalClient.swift index 1b35558c..40d3d587 100644 --- a/App/Sources/Clients/Terminal/TerminalClient.swift +++ b/App/Sources/Clients/Terminal/TerminalClient.swift @@ -64,6 +64,7 @@ struct TerminalClient { case setNotificationsEnabled(Bool) case setCommandFinishedNotification(enabled: Bool, threshold: Int) case setCanvasMode(Bool) + case setRemoteAgentDetection(RemoteAgentDetectionConsent) case setSelectedWorktreeID(Worktree.ID?) case saveLayoutSnapshot /// `allowsMissingWorktrees` skips the saved worktrees that `worktrees` does not hold @@ -85,6 +86,8 @@ struct TerminalClient { case taskStatusChanged(worktreeID: Worktree.ID, status: WorktreeTaskStatus) case agentEntryChanged(ActiveAgentEntry) case agentEntryRemoved(ActiveAgentEntry.ID) + /// A pane's `ssh` waits for the user's answer before Prowl connects (docs-ai 082). + case remoteAgentConsentNeeded(worktreeID: Worktree.ID, surfaceID: UUID, destination: String) /// A profile launch created its surface. The reducer records the per-repo /// launch memory on this event — not at dispatch — so a failed launch /// never shifts the Recommended resolution (docs-ai 053/005). diff --git a/App/Sources/Features/App/Reducer/AppFeature+RemoteAgentConsent.swift b/App/Sources/Features/App/Reducer/AppFeature+RemoteAgentConsent.swift new file mode 100644 index 00000000..ea61ad72 --- /dev/null +++ b/App/Sources/Features/App/Reducer/AppFeature+RemoteAgentConsent.swift @@ -0,0 +1,45 @@ +import ComposableArchitecture +import Foundation + +/// The question a pane's first `ssh` raises (docs-ai 082, L0 item 12): may Prowl open its own +/// connection to that host to detect agents. +struct RemoteAgentConsentPrompt: Equatable { + let worktreeID: Worktree.ID + let surfaceID: UUID + /// The destination as the user typed it (`mini`, `dev@box`); Prowl has not resolved it. + let destination: String +} + +enum RemoteAgentConsentChoice: Equatable { + case turnOn + /// Asks again after a relaunch. + case notNow + case turnOff +} + +extension AppFeature { + func reduceRemoteAgentConsentNeeded( + worktreeID: Worktree.ID, surfaceID: UUID, destination: String, state: inout State + ) -> Effect { + // The terminal layer asks before the settings reach it at launch; a stored answer wins. + guard state.settings.remoteAgentDetection == .undecided, !state.isRemoteAgentConsentDeferred else { + return .none + } + state.remoteAgentConsentPrompt = RemoteAgentConsentPrompt( + worktreeID: worktreeID, surfaceID: surfaceID, destination: destination) + return .none + } + + func reduceRemoteAgentConsent(_ choice: RemoteAgentConsentChoice, state: inout State) -> Effect { + state.remoteAgentConsentPrompt = nil + switch choice { + case .turnOn: + return .send(.settings(.setRemoteAgentDetection(.enabled))) + case .notNow: + state.isRemoteAgentConsentDeferred = true + return .none + case .turnOff: + return .send(.settings(.setRemoteAgentDetection(.disabled))) + } + } +} diff --git a/App/Sources/Features/App/Reducer/AppFeature+TerminalEvents.swift b/App/Sources/Features/App/Reducer/AppFeature+TerminalEvents.swift index cb8bc8c2..967fc706 100644 --- a/App/Sources/Features/App/Reducer/AppFeature+TerminalEvents.swift +++ b/App/Sources/Features/App/Reducer/AppFeature+TerminalEvents.swift @@ -159,6 +159,10 @@ extension AppFeature { case .agentEntryRemoved(let id): return .send(.repositories(.activeAgents(.agentEntryRemoved(id)))) + case .remoteAgentConsentNeeded(let worktreeID, let surfaceID, let destination): + return reduceRemoteAgentConsentNeeded( + worktreeID: worktreeID, surfaceID: surfaceID, destination: destination, state: &state) + case .agentProfileLaunched(let worktreeID, let profileID): // Launch memory is recorded only once a surface actually exists, so a // failed provision or surface creation never shifts Recommended. diff --git a/App/Sources/Features/App/Reducer/AppFeature.swift b/App/Sources/Features/App/Reducer/AppFeature.swift index 7b734c9b..0b7aae94 100644 --- a/App/Sources/Features/App/Reducer/AppFeature.swift +++ b/App/Sources/Features/App/Reducer/AppFeature.swift @@ -37,6 +37,10 @@ struct AppFeature { /// Workflows visible to the action-target worktree, refreshed when the palette opens. var workflowPaletteItems: [WorkflowStartCatalogItem] = [] @Presents var alert: AlertState? + /// The side-channel consent panel (docs-ai 082, L0 item 12); nil while nothing asks. + var remoteAgentConsentPrompt: RemoteAgentConsentPrompt? + /// Not Now: no panel until the next launch. + var isRemoteAgentConsentDeferred = false init( repositories: RepositoriesFeature.State = .init(), @@ -80,6 +84,8 @@ struct AppFeature { case runCustomCommand(EffectiveCustomCommand.Identifier) case launchAgentProfile(AgentProfile.ID) case openAgentProfilesSettings + case openAgentDisplaySettings + case remoteAgentConsent(RemoteAgentConsentChoice) case openWorkflowSettings case openWorkflowDetails(WorkflowStartCatalogItem, worktreeID: Worktree.ID) case canvasFocusedWorktreeChanged(Worktree.ID?) @@ -488,6 +494,7 @@ struct AppFeature { let didChangeAgentIslandEnabled = state.repositories.activeAgents.isIslandEnabled != settings.agentIslandEnabled state.lastKnownSystemNotificationsEnabled = settings.systemNotificationsEnabled + if settings.remoteAgentDetection != .undecided { state.remoteAgentConsentPrompt = nil } state.settings.keybindingUserOverrides = settings.keybindingUserOverrides state.repositories.showActiveAgentTabTitles = settings.showActiveAgentTabTitles state.repositories.activeAgents.isIslandEnabled = settings.agentIslandEnabled @@ -557,6 +564,9 @@ struct AppFeature { .run { _ in await terminalClient.send(.setNotificationsEnabled(settings.inAppNotificationsEnabled)) }, + .run { _ in + await terminalClient.send(.setRemoteAgentDetection(settings.remoteAgentDetection)) + }, .run { _ in await terminalClient.send( .setCommandFinishedNotification( @@ -870,6 +880,12 @@ struct AppFeature { case .openAgentProfilesSettings: return openSettingsEffect(selecting: .profiles) + case .openAgentDisplaySettings: + return openSettingsEffect(selecting: .agentDisplay) + + case .remoteAgentConsent(let choice): + return reduceRemoteAgentConsent(choice, state: &state) + case .openWorkflowDetails(let item, let worktreeID): guard featureFlags.workflowUI else { return .none } let selection: SettingsSection diff --git a/App/Sources/Features/Repositories/Views/RemoteAgentToolbarStatus.swift b/App/Sources/Features/Repositories/Views/RemoteAgentToolbarStatus.swift new file mode 100644 index 00000000..068284d9 --- /dev/null +++ b/App/Sources/Features/Repositories/Views/RemoteAgentToolbarStatus.swift @@ -0,0 +1,238 @@ +import SwiftUI + +/// The side channel of the focused pane's `ssh`, for the toolbar status (docs-ai 082, L0 +/// item 12): Prowl has a connection to the host, or tried to open one. +struct RemoteChannelToolbarStatus: Equatable { + let host: String + /// Why Prowl does not read the pane's agents; nil while it does. + let issue: RemotePaneCorrelation? + + /// nil when the pane's `ssh` has no side channel: no `ssh`, an ended one, or one outside the + /// grammar (Prowl never connects for it; the pane badge says why). + init?(_ indicator: RemotePaneIndicator?) { + guard case .connected(let host, _, let issue) = indicator else { return nil } + if case .unsupported = issue { return nil } + self.host = host + self.issue = issue + } +} + +/// Asks whether Prowl may open its own connection. Its panel opens at once and stays until the +/// user answers or clicks elsewhere; the capsule stays until the user answers. Neither takes +/// keyboard focus: the user can be typing a password or a code in the pane. +struct RemoteAgentConsentStatusButton: View { + let prompt: RemoteAgentConsentPrompt + let onChoice: (RemoteAgentConsentChoice) -> Void + @State private var isPresented = false + + var body: some View { + Button { + isPresented.toggle() + } label: { + HStack(spacing: 6) { + Image(systemName: "network.badge.shield.half.filled") + .symbolRenderingMode(.multicolor) + .foregroundStyle(.orange) + .accessibilityHidden(true) + Text("Detect agents on \(prompt.destination)?") + .lineLimit(1) + .truncationMode(.middle) + } + } + .buttonStyle(.plain) + .font(.caption) + .contentShape(.rect) + .help("Choose whether Prowl may connect to the host of this SSH session to detect agents.") + .accessibilityLabel(Text("Detect agents on \(prompt.destination)? Prowl waits for your answer.")) + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + RemoteAgentConsentPanel(destination: prompt.destination) { choice in + isPresented = false + onChoice(choice) + } + } + .task(id: prompt) { + // Opened after the toolbar item is in the window, so the panel has an anchor. + await Task.yield() + isPresented = true + } + } +} + +private struct RemoteAgentConsentPanel: View { + let destination: String + let onChoice: (RemoteAgentConsentChoice) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Label { + Text("Detect agents in SSH sessions?") + .font(.headline) + } icon: { + Image(systemName: "network.badge.shield.half.filled") + .symbolRenderingMode(.multicolor) + .accessibilityHidden(true) + } + Text( + """ + Prowl can show the agents that run on \(destination), with their state. To find them, \ + Prowl opens its own SSH connection to the host, next to your session, and runs a \ + read-only check every few seconds. It installs nothing on the host, and your session \ + does not change. + """ + ) + .fixedSize(horizontal: false, vertical: true) + Text("The host can log this connection. Prowl does not connect until you choose.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + // No default button and no keyboard focus: a Return typed for the pane's password + // prompt must never answer here. + HStack { + Button("Turn Off") { onChoice(.turnOff) } + .focusable(false) + .help("Never detect agents in SSH sessions. You can turn it on in Settings › Agents › Display.") + Button("Not Now") { onChoice(.notNow) } + .focusable(false) + .help("Ask again after Prowl restarts.") + Spacer(minLength: 12) + Button("Turn On") { onChoice(.turnOn) } + .focusable(false) + .buttonStyle(.borderedProminent) + .help("Detect agents in SSH sessions. You can turn it off in Settings › Agents › Display.") + } + } + .font(.callout) + .padding(16) + .frame(width: 380) + } +} + +/// The focused pane's side channel: the host and its health, with a panel on hover that tells +/// what the extra connection does. +struct RemoteChannelStatusButton: View { + let status: RemoteChannelToolbarStatus + let onOpenSettings: () -> Void + @State private var isPresented = false + @State private var isPinnedOpen = false + @State private var isHoveringButton = false + @State private var isHoveringPopover = false + @State private var closeTask: Task? + + var body: some View { + Button { + isPinnedOpen.toggle() + updatePresentation() + } label: { + HStack(spacing: 6) { + Image(systemName: status.issue == nil ? "network" : "exclamationmark.triangle.fill") + .symbolRenderingMode(status.issue == nil ? .monochrome : .multicolor) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + Text(verbatim: status.host) + .lineLimit(1) + .truncationMode(.middle) + Text(status.issue == nil ? "Agent detection on" : "Agents not detected") + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .buttonStyle(.plain) + .font(.caption) + .contentShape(.rect) + .help("Prowl's own SSH connection for agent detection. Hover to see what it does.") + .accessibilityLabel(accessibilityLabel) + .onHover { hovering in + isHoveringButton = hovering + updatePresentation() + } + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + RemoteChannelPanel(status: status) { + isPinnedOpen = false + isPresented = false + onOpenSettings() + } + .onHover { hovering in + isHoveringPopover = hovering + updatePresentation() + } + .onDisappear { + isHoveringPopover = false + isPinnedOpen = false + } + } + .onDisappear { + closeTask?.cancel() + } + } + + private var accessibilityLabel: Text { + status.issue == nil + ? Text("Agent detection connected to \(status.host)") + : Text("Agent detection for \(status.host): agents are not detected") + } + + private func updatePresentation() { + if isPinnedOpen || isHoveringButton || isHoveringPopover { + closeTask?.cancel() + isPresented = true + return + } + closeTask?.cancel() + closeTask = Task { @MainActor in + try? await ContinuousClock().sleep(for: .milliseconds(150)) + if !Task.isCancelled { + isPresented = false + } + } + } +} + +private struct RemoteChannelPanel: View { + let status: RemoteChannelToolbarStatus + let onOpenSettings: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Label { + Text("Agent detection over SSH") + .font(.headline) + } icon: { + Image(systemName: "network") + .accessibilityHidden(true) + } + Text( + """ + Prowl keeps its own SSH connection to \(status.host), next to your session in this \ + pane. Over it, Prowl runs a read-only check every few seconds to find the agents in \ + this pane and their state. It installs nothing on the host, and your session does not \ + change. + """ + ) + .fixedSize(horizontal: false, vertical: true) + Label { + Text(verbatim: detail) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: status.issue == nil ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .symbolRenderingMode(.multicolor) + .accessibilityHidden(true) + } + .foregroundStyle(.secondary) + HStack { + Spacer() + Button("Agent Settings…", action: onOpenSettings) + .focusable(false) + .help("Open Settings › Agents › Display, where you can turn off agent detection in SSH sessions.") + } + } + .font(.callout) + .padding(16) + .frame(width: 380) + } + + private var detail: String { + guard let issue = status.issue else { + return String(localized: "Connected. Agents in this pane are detected.") + } + return RemoteAgentPresentation.paneIssueExplanation(issue, host: status.host) + } +} diff --git a/App/Sources/Features/Repositories/Views/ToolbarStatusView.swift b/App/Sources/Features/Repositories/Views/ToolbarStatusView.swift index 2ad17dee..1f04f9f9 100644 --- a/App/Sources/Features/Repositories/Views/ToolbarStatusView.swift +++ b/App/Sources/Features/Repositories/Views/ToolbarStatusView.swift @@ -9,8 +9,12 @@ struct ToolbarStatusView: View { let workflow: WorkflowStatusCenterPresentation let pullRequest: GithubPullRequest? let codeHost: CodeHost + var remoteAgentConsent: RemoteAgentConsentPrompt? + var remoteChannel: RemoteChannelToolbarStatus? let onWorkflowIntent: (WorkflowRunPanelIntent) -> Void let onRetryGithubRateLimit: () -> Void + var onRemoteAgentConsent: (RemoteAgentConsentChoice) -> Void = { _ in } + var onOpenAgentSettings: () -> Void = {} var body: some View { let selection = ToolbarStatusSelection( @@ -18,7 +22,9 @@ struct ToolbarStatusView: View { workflow: featureFlags.workflowUI ? workflow : WorkflowStatusCenterPresentation(state: .init(), selectedWorktreeID: nil, now: .distantPast), pullRequest: pullRequest, - githubRateLimit: githubRateLimit + githubRateLimit: githubRateLimit, + remoteAgentConsent: remoteAgentConsent, + remoteChannel: remoteChannel ) ZStack { // Keep the panel mounted so a selected run remains readable after the last active run ends. @@ -61,6 +67,12 @@ struct ToolbarStatusView: View { .transition(.opacity) case .workflow: EmptyView() + case .remoteAgentConsent(let prompt): + RemoteAgentConsentStatusButton(prompt: prompt, onChoice: onRemoteAgentConsent) + .transition(.opacity) + case .remoteChannel(let status): + RemoteChannelStatusButton(status: status, onOpenSettings: onOpenAgentSettings) + .transition(.opacity) case .githubRateLimited(let status): GithubRateLimitStatusButton(status: status, onRetry: onRetryGithubRateLimit) .transition(.opacity) @@ -80,8 +92,10 @@ struct ToolbarStatusView: View { @MainActor enum ToolbarStatusSelection: Equatable { case toast(RepositoriesFeature.StatusToast) + case remoteAgentConsent(RemoteAgentConsentPrompt) case workflow(WorkflowStatusCenterPresentation) case githubRateLimited(GithubRateLimitStatus) + case remoteChannel(RemoteChannelToolbarStatus) case pullRequest(PullRequestStatusModel) case motivational @@ -94,14 +108,22 @@ enum ToolbarStatusSelection: Equatable { toast: RepositoriesFeature.StatusToast?, workflow: WorkflowStatusCenterPresentation, pullRequest: GithubPullRequest?, - githubRateLimit: GithubRateLimitStatus? = nil + githubRateLimit: GithubRateLimitStatus? = nil, + remoteAgentConsent: RemoteAgentConsentPrompt? = nil, + remoteChannel: RemoteChannelToolbarStatus? = nil ) { + // The consent question outranks running work: until it is answered, the pane's `ssh` + // gets no detection (docs-ai 082, L0 item 12). if let toast { self = .toast(toast) + } else if let remoteAgentConsent { + self = .remoteAgentConsent(remoteAgentConsent) } else if !workflow.runs.isEmpty { self = .workflow(workflow) } else if let githubRateLimit { self = .githubRateLimited(githubRateLimit) + } else if let remoteChannel { + self = .remoteChannel(remoteChannel) } else if let pullRequest = PullRequestStatusModel(pullRequest: pullRequest) { self = .pullRequest(pullRequest) } else { diff --git a/App/Sources/Features/Repositories/Views/WorktreeDetailToolbarViews.swift b/App/Sources/Features/Repositories/Views/WorktreeDetailToolbarViews.swift index 93b06524..4ba537e5 100644 --- a/App/Sources/Features/Repositories/Views/WorktreeDetailToolbarViews.swift +++ b/App/Sources/Features/Repositories/Views/WorktreeDetailToolbarViews.swift @@ -284,7 +284,9 @@ private struct WorktreeToolbarPreview: View { isUpdateAvailable: true, isUpdateReadyToInstall: false, availableUpdateVersion: "2026.5.1", - showRunButtonInToolbar: true + showRunButtonInToolbar: true, + remoteAgentConsent: nil, + remoteChannel: nil ), openActionSelection: .finder, openActionIsAutomatic: true, @@ -321,7 +323,9 @@ private struct WorktreeToolbarPreview: View { onRunWorkflowWithOptions: { _ in }, onShowWorkflowDetails: { _ in }, onWorkflowIntent: { _ in }, - onRetryGithubRateLimit: {} + onRetryGithubRateLimit: {}, + onRemoteAgentConsent: { _ in }, + onOpenAgentSettings: {} ) } .environment(commandKeyObserver) diff --git a/App/Sources/Features/Repositories/Views/WorktreeDetailView.swift b/App/Sources/Features/Repositories/Views/WorktreeDetailView.swift index 3b9279ec..907e225e 100644 --- a/App/Sources/Features/Repositories/Views/WorktreeDetailView.swift +++ b/App/Sources/Features/Repositories/Views/WorktreeDetailView.swift @@ -19,6 +19,7 @@ struct WorktreeDetailView: View { let isUpdateReadyToInstall: Bool let availableUpdateVersion: String? let showRunButtonInToolbar: Bool + let remoteAgentConsent: RemoteAgentConsentPrompt? } @Bindable var store: StoreOf @@ -85,7 +86,8 @@ struct WorktreeDetailView: View { isUpdateAvailable: state.updates.isUpdateAvailable, isUpdateReadyToInstall: state.updates.isUpdateReadyToInstall, availableUpdateVersion: state.updates.availableVersion, - showRunButtonInToolbar: settingsFile.global.showRunButtonInToolbar + showRunButtonInToolbar: settingsFile.global.showRunButtonInToolbar, + remoteAgentConsent: state.remoteAgentConsentPrompt ) ) let content = detailContent( @@ -204,7 +206,9 @@ struct WorktreeDetailView: View { store.send(.openWorkflowDetails(item, worktreeID: worktreeID)) }, onWorkflowIntent: handleWorkflowIntent, - onRetryGithubRateLimit: retryGithubRateLimitedRequests + onRetryGithubRateLimit: retryGithubRateLimitedRequests, + onRemoteAgentConsent: { store.send(.remoteAgentConsent($0)) }, + onOpenAgentSettings: { store.send(.openAgentDisplaySettings) } ) } @@ -262,10 +266,20 @@ struct WorktreeDetailView: View { isUpdateAvailable: input.isUpdateAvailable, isUpdateReadyToInstall: input.isUpdateReadyToInstall, availableUpdateVersion: input.availableUpdateVersion, - showRunButtonInToolbar: input.showRunButtonInToolbar + showRunButtonInToolbar: input.showRunButtonInToolbar, + remoteAgentConsent: input.remoteAgentConsent, + remoteChannel: remoteChannelStatus(for: input.actionTargetWorktree) ) } + /// The focused pane's side channel (docs-ai 082, L0 item 12). + private func remoteChannelStatus(for worktree: Worktree?) -> RemoteChannelToolbarStatus? { + guard let worktree, let state = terminalManager.stateIfExists(for: worktree.id), + let surfaceID = state.currentFocusedSurfaceId() + else { return nil } + return RemoteChannelToolbarStatus(state.surfaces[surfaceID]?.bridge.state.remoteIndicator) + } + @ToolbarContentBuilder private func canvasToolbarContent( state: ToolbarSharedState @@ -311,8 +325,12 @@ struct WorktreeDetailView: View { workflow: state.workflowStatus, pullRequest: state.pullRequest, codeHost: state.codeHost, + remoteAgentConsent: state.remoteAgentConsent, + remoteChannel: state.remoteChannel, onWorkflowIntent: handleWorkflowIntent, - onRetryGithubRateLimit: retryGithubRateLimitedRequests + onRetryGithubRateLimit: retryGithubRateLimitedRequests, + onRemoteAgentConsent: { store.send(.remoteAgentConsent($0)) }, + onOpenAgentSettings: { store.send(.openAgentDisplaySettings) } ) .padding(.horizontal) } @@ -965,6 +983,8 @@ struct WorktreeDetailView: View { let isUpdateReadyToInstall: Bool let availableUpdateVersion: String? let showRunButtonInToolbar: Bool + let remoteAgentConsent: RemoteAgentConsentPrompt? + let remoteChannel: RemoteChannelToolbarStatus? } struct WorktreeToolbarState { @@ -1067,6 +1087,8 @@ struct WorktreeDetailView: View { let onShowWorkflowDetails: (WorkflowStartCatalogItem) -> Void let onWorkflowIntent: (WorkflowRunPanelIntent) -> Void let onRetryGithubRateLimit: () -> Void + let onRemoteAgentConsent: (RemoteAgentConsentChoice) -> Void + let onOpenAgentSettings: () -> Void @Environment(\.resolvedKeybindings) private var resolvedKeybindings var body: some ToolbarContent { @@ -1098,8 +1120,12 @@ struct WorktreeDetailView: View { workflow: toolbarState.shared.workflowStatus, pullRequest: toolbarState.shared.pullRequest, codeHost: toolbarState.shared.codeHost, + remoteAgentConsent: toolbarState.shared.remoteAgentConsent, + remoteChannel: toolbarState.shared.remoteChannel, onWorkflowIntent: onWorkflowIntent, - onRetryGithubRateLimit: onRetryGithubRateLimit + onRetryGithubRateLimit: onRetryGithubRateLimit, + onRemoteAgentConsent: onRemoteAgentConsent, + onOpenAgentSettings: onOpenAgentSettings ) .padding(.horizontal) } diff --git a/App/Sources/Features/Settings/Models/GlobalSettings.swift b/App/Sources/Features/Settings/Models/GlobalSettings.swift index d1555605..6eb9240d 100644 --- a/App/Sources/Features/Settings/Models/GlobalSettings.swift +++ b/App/Sources/Features/Settings/Models/GlobalSettings.swift @@ -50,6 +50,8 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { var detectRepositoryIconsAutomatically: Bool = true /// A Ghostty config file used instead of Ghostty's default files; `nil` shares Ghostty's config. var ghosttyConfigPath: String? + /// Whether Prowl may open its own SSH connection to detect remote agents (docs-ai 082). + var remoteAgentDetection: RemoteAgentDetectionConsent = .undecided static let `default` = GlobalSettings( appearanceMode: .dark, @@ -247,6 +249,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { try container.encode(externalDiffCustomCommand, forKey: .externalDiffCustomCommand) try container.encode(detectRepositoryIconsAutomatically, forKey: .detectRepositoryIconsAutomatically) try container.encodeIfPresent(ghosttyConfigPath, forKey: .ghosttyConfigPath) + try container.encode(remoteAgentDetection, forKey: .remoteAgentDetection) } private enum CodingKeys: String, CodingKey { @@ -300,6 +303,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { case externalDiffCustomCommand case detectRepositoryIconsAutomatically case ghosttyConfigPath + case remoteAgentDetection // Legacy keys for migration case automaticallyArchiveMergedWorktrees case notificationSoundEnabled @@ -400,6 +404,7 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { detectRepositoryIconsAutomatically = try container.decodeIfPresent(Bool.self, forKey: .detectRepositoryIconsAutomatically) ?? true + remoteAgentDetection = try Self.decodeRemoteAgentDetection(from: container) let toolbarAndDock = try Self.decodeToolbarAndDockSettings(from: container) showRunButtonInToolbar = toolbarAndDock.showRunButtonInToolbar showDefaultEditorInToolbar = toolbarAndDock.showDefaultEditorInToolbar @@ -407,6 +412,14 @@ nonisolated struct GlobalSettings: Codable, Equatable, Sendable { showNotificationDotOnDock = toolbarAndDock.showNotificationDotOnDock } + /// An unknown value (a newer build's) asks again instead of failing the whole file. + private static func decodeRemoteAgentDetection( + from container: KeyedDecodingContainer + ) throws -> RemoteAgentDetectionConsent { + try container.decodeIfPresent(String.self, forKey: .remoteAgentDetection) + .flatMap(RemoteAgentDetectionConsent.init(rawValue:)) ?? .undecided + } + private struct DecodedAgentIslandSettings { let enabled: Bool let onlyShowWithAgents: Bool diff --git a/App/Sources/Features/Settings/Reducer/SettingsFeature.swift b/App/Sources/Features/Settings/Reducer/SettingsFeature.swift index ac30f3aa..a37bb161 100644 --- a/App/Sources/Features/Settings/Reducer/SettingsFeature.swift +++ b/App/Sources/Features/Settings/Reducer/SettingsFeature.swift @@ -75,6 +75,7 @@ struct SettingsFeature { var showNotificationDotOnDock: Bool var externalDiffToolID: String var ghosttyConfigPath: String? + var remoteAgentDetection: RemoteAgentDetectionConsent var externalDiffCustomCommand: String var detectRepositoryIconsAutomatically: Bool var cliInstallStatus: CLIInstallStatus = .notInstalled @@ -157,6 +158,7 @@ struct SettingsFeature { externalDiffCustomCommand = settings.externalDiffCustomCommand detectRepositoryIconsAutomatically = settings.detectRepositoryIconsAutomatically ghosttyConfigPath = settings.ghosttyConfigPath + remoteAgentDetection = settings.remoteAgentDetection } var displayedArchivedAutoDeletePeriod: AutoDeletePeriod? { @@ -230,6 +232,7 @@ struct SettingsFeature { settings.externalDiffCustomCommand = externalDiffCustomCommand settings.detectRepositoryIconsAutomatically = detectRepositoryIconsAutomatically settings.ghosttyConfigPath = ghosttyConfigPath + settings.remoteAgentDetection = remoteAgentDetection return settings } } @@ -253,6 +256,8 @@ struct SettingsFeature { ) case applyArchivedAutoDeletePeriod(AutoDeletePeriod?, targets: [ArchivedAutoDeleteTarget]) case setAgentIslandEnabled(Bool) + /// The Settings switch and the consent panel both answer here (docs-ai 082, L0 item 12). + case setRemoteAgentDetection(RemoteAgentDetectionConsent) case setAgentIslandFloatingPosition(displayID: String, normalizedPosition: Double) case setAgentIslandSilentOpacity(Double) case setAgentIslandDisplayPreference(AgentIslandDisplayPreference) @@ -388,6 +393,7 @@ struct SettingsFeature { state.canvasDefaultLayout = normalizedSettings.canvasDefaultLayout state.detectRepositoryIconsAutomatically = normalizedSettings.detectRepositoryIconsAutomatically state.ghosttyConfigPath = normalizedSettings.ghosttyConfigPath + state.remoteAgentDetection = normalizedSettings.remoteAgentDetection state.syncGlobalDefaults(from: normalizedSettings) return .send(.delegate(.settingsChanged(normalizedSettings))) @@ -485,6 +491,11 @@ struct SettingsFeature { state.syncGlobalDefaults(from: state.globalSettings) return persist(state) + case .setRemoteAgentDetection(let consent): + guard state.remoteAgentDetection != consent else { return .none } + state.remoteAgentDetection = consent + return persist(state) + case .setAgentIslandFloatingPosition(let displayID, let normalizedPosition): state.agentIslandFloatingPositions.setNormalizedPosition( normalizedPosition, diff --git a/App/Sources/Features/Settings/Views/AgentDisplaySettingsView.swift b/App/Sources/Features/Settings/Views/AgentDisplaySettingsView.swift index 7a850f85..46feb9d5 100644 --- a/App/Sources/Features/Settings/Views/AgentDisplaySettingsView.swift +++ b/App/Sources/Features/Settings/Views/AgentDisplaySettingsView.swift @@ -26,6 +26,21 @@ struct AgentDisplaySettingsView: View { ) .help("Overlay detected agent status on the owning tab icon in Shelf View.") } + Section("SSH Sessions") { + Toggle( + isOn: Binding( + get: { store.remoteAgentDetection.showsEnabled }, + set: { store.send(.setRemoteAgentDetection($0 ? .enabled : .disabled)) }) + ) { + Text("Detect agents in SSH sessions") + Text( + """ + When a pane runs ssh, Prowl opens its own connection to the same host and runs a \ + read-only check to find agents. It installs nothing on the host. + """) + } + .help("Let Prowl open its own SSH connection to show the agents that run on the host.") + } AgentIslandSettingsSection( store: store, effectiveKeybindings: effectiveKeybindings, diff --git a/App/Sources/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift b/App/Sources/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift index 9e67f4b1..54039f29 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/TerminalEventCoalescer.swift @@ -37,7 +37,7 @@ struct TerminalEventCoalescer { case .customCommandSucceeded, .notificationReceived, .notificationIndicatorChanged, .tabCreated, .tabClosed, .tabRestored, .agentEntryRemoved, .commandPaletteToggleRequested, .setupScriptConsumed, .layoutRestored, .layoutRestoreFailed, - .agentProfileLaunched, .agentProfileLaunchWarning, .agentProfileLaunchFailed: + .agentProfileLaunched, .agentProfileLaunchWarning, .agentProfileLaunchFailed, .remoteAgentConsentNeeded: return nil } } diff --git a/App/Sources/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift b/App/Sources/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift index 4d977fa3..39d87961 100644 --- a/App/Sources/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift +++ b/App/Sources/Features/Terminal/BusinessLogic/WorktreeTerminalManager.swift @@ -36,6 +36,7 @@ final class WorktreeTerminalManager { private var notificationsEnabled = true private var commandFinishedNotificationEnabled = true private var commandFinishedNotificationThreshold = 10 + private var remoteAgentDetectionConsent: RemoteAgentDetectionConsent = .undecided private var preferredFontSize: Float32? private var lastNotificationIndicatorCount: Int? private var eventContinuation: AsyncStream.Continuation? @@ -122,6 +123,9 @@ final class WorktreeTerminalManager { if handleSearchCommand(command) { return } + if handleSettingsCommand(command) { + return + } handleManagementCommand(command) } @@ -488,16 +492,30 @@ final class WorktreeTerminalManager { return true } + /// Settings the app pushes to every terminal state. + private func handleSettingsCommand(_ command: TerminalClient.Command) -> Bool { + switch command { + case .setNotificationsEnabled(let enabled): + setNotificationsEnabled(enabled) + case .setCommandFinishedNotification(let enabled, let threshold): + setCommandFinishedNotification(enabled: enabled, threshold: threshold) + case .setRemoteAgentDetection(let consent): + remoteAgentDetectionConsent = consent + for state in states.values { + state.setRemoteAgentDetectionConsent(consent) + } + default: + return false + } + return true + } + private func handleManagementCommand(_ command: TerminalClient.Command) { switch command { case .prune(let ids): prune(keeping: ids) case .prunePreservingRepositories(let ids, let repositoryIDs): prune(keeping: ids, preservingRepositoryIDs: repositoryIDs) - case .setNotificationsEnabled(let enabled): - setNotificationsEnabled(enabled) - case .setCommandFinishedNotification(let enabled, let threshold): - setCommandFinishedNotification(enabled: enabled, threshold: threshold) case .setCanvasMode(let enabled): if enabled { terminalLogger.info( @@ -942,6 +960,11 @@ final class WorktreeTerminalManager { enabled: commandFinishedNotificationEnabled, threshold: commandFinishedNotificationThreshold ) + state.setRemoteAgentDetectionConsent(remoteAgentDetectionConsent) + state.onRemoteAgentConsentNeeded = { [weak self] surfaceID, destination in + self?.emit( + .remoteAgentConsentNeeded(worktreeID: worktree.id, surfaceID: surfaceID, destination: destination)) + } state.undoCloseTimeout = closeUndoStack.timeout state.onCloseRecorded = { [weak self] record in guard let self else { return } diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 370b595d..f5059b44 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -350,10 +350,20 @@ extension WorktreeTerminalState { private func observeRemoteAgent( job: ForegroundJob?, localIdentified: IdentifiedAgentProcess?, surfaceID: UUID, now: Date ) -> RemotePaneTracker.Observation? { - guard remoteAgentDetectionEnabled else { + switch remoteAgentDetectionMode { + case .enabled: + break + case .disabled: retireRemotePaneTracker(surfaceID: surfaceID) setRemoteIndicator(nil, surfaceID: surfaceID) return nil + case .awaitingConsent: + retireRemotePaneTracker(surfaceID: surfaceID) + setRemoteIndicator(nil, surfaceID: surfaceID) + if let job, localIdentified == nil, let ssh = Self.foregroundSSH(job) { + askRemoteAgentConsent(surfaceID: surfaceID, ssh: ssh) + } + return nil } let tracker: RemotePaneTracker if let job { @@ -547,6 +557,33 @@ extension WorktreeTerminalState { return nil } + /// Asks once per `ssh` whether Prowl may open its own connection (docs-ai 082, L0 item 12). + /// It only parses the command line: no `ssh -G`, no connection. An `ssh` outside the + /// grammar would get no side channel, so it does not ask. + private func askRemoteAgentConsent( + surfaceID: UUID, ssh: (generation: LocalProcessGeneration, arguments: [String]) + ) { + guard remoteConsentAskedSSHBySurface[surfaceID] != ssh.generation else { return } + remoteConsentAskedSSHBySurface[surfaceID] = ssh.generation + guard case .supported(let recipe) = SSHInvocation.parse(ssh.arguments) else { return } + onRemoteAgentConsentNeeded?(surfaceID, recipe.destination) + } + + /// Applies the stored answer. Turning detection on wakes the panes that asked, so their + /// open `ssh` sessions are detected without a reconnect. + func setRemoteAgentDetectionConsent(_ consent: RemoteAgentDetectionConsent) { + guard remoteAgentDetectionConsent != consent else { return } + remoteAgentDetectionConsent = consent + guard remoteAgentDetectionMode == .enabled else { return } + let asked = remoteConsentAskedSSHBySurface.keys + remoteConsentAskedSSHBySurface.removeAll() + for surfaceID in asked { + guard let view = surfaces[surfaceID], let tabId = tabId(containing: surfaceID) else { continue } + startAgentDetectionTaskIfNeeded(for: view, tabId: tabId) + interruptAgentDetectionSleep(forSurfaceID: surfaceID) + } + } + func retireRemotePaneTracker(surfaceID: UUID) { remotePaneTrackers.removeValue(forKey: surfaceID)?.retire() } diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift index d9474bba..989b3756 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+ProgramStatus.swift @@ -115,6 +115,7 @@ extension WorktreeTerminalState { programStatusUnmappedAppsLoggedBySurface.removeValue(forKey: surfaceID) programStatusOriginBySurface.removeValue(forKey: surfaceID) remoteFenceHistoryBySurface.removeValue(forKey: surfaceID) + remoteConsentAskedSSHBySurface.removeValue(forKey: surfaceID) } } diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift index 95ea16f6..39bd59b0 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState.swift @@ -251,10 +251,21 @@ final class WorktreeTerminalState { @ObservationIgnored var remoteFenceHistoryBySurface: [UUID: [LocalProcessGeneration: RemoteFenceHistory]] = [:] @ObservationIgnored var remoteSnapshotSourceForTesting: RemoteSnapshotSource? @ObservationIgnored var remoteUptimeForTesting: (() -> TimeInterval)? + /// Overrides the mode in tests: true is `.enabled`, false `.disabled`. @ObservationIgnored var remoteAgentDetectionEnabledForTesting: Bool? + @ObservationIgnored var remoteDetectionEnvironmentForTesting: [String: String]? + /// The stored answer about the side channel; the manager pushes it from the settings. + @ObservationIgnored var remoteAgentDetectionConsent: RemoteAgentDetectionConsent = .undecided + /// The `ssh` generation each pane asked about, so one `ssh` asks once. + @ObservationIgnored var remoteConsentAskedSSHBySurface: [UUID: LocalProcessGeneration] = [:] + /// A pane's `ssh` needs the user's answer before Prowl connects: (surface, destination). + @ObservationIgnored var onRemoteAgentConsentNeeded: ((UUID, String) -> Void)? @ObservationIgnored var accessibilityPosterForTesting: (@MainActor (Any, NSAccessibility.Notification) -> Void)? - var remoteAgentDetectionEnabled: Bool { - remoteAgentDetectionEnabledForTesting ?? RemoteAgentDetection.isEnabled + var remoteAgentDetectionMode: RemoteAgentDetection.Mode { + if let enabled = remoteAgentDetectionEnabledForTesting { return enabled ? .enabled : .disabled } + return RemoteAgentDetection.mode( + environment: remoteDetectionEnvironmentForTesting ?? ProcessInfo.processInfo.environment, + consent: remoteAgentDetectionConsent) } /// Test seams: the process probe, the OSC support table, and the diagnostics sink. @ObservationIgnored var agentProcessProbeForTesting: AgentProcessProbeOverride? diff --git a/App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift b/App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift index 00477db1..f1eb772e 100644 --- a/App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift +++ b/App/Sources/Infrastructure/RemoteHost/RemoteAgentDetection.swift @@ -1,18 +1,37 @@ import Foundation -/// The switch for SSH-transparent detection (docs-ai 082, L0). On by default since the L0 UX -/// shipped; `PROWL_REMOTE_AGENT_DETECTION=0` or a `false` `remoteAgentDetectionEnabled` -/// default turns it off. +/// The user's answer about SSH-transparent detection (docs-ai 082, L0 item 12). The side +/// channel reaches hosts the user named and the host can log it, so it starts only after +/// the user agrees. +nonisolated enum RemoteAgentDetectionConsent: String, Codable, Equatable, Sendable { + /// No answer yet: no side channel; the first `ssh` asks. + case undecided + case enabled + case disabled + + /// What the Settings switch shows: on unless the user turned it off. + var showsEnabled: Bool { self != .disabled } +} + +/// The switch for SSH-transparent detection (docs-ai 082, L0). The stored consent decides, +/// unless `PROWL_REMOTE_AGENT_DETECTION` is set: `0` turns it off, any other value on, and +/// neither asks. nonisolated enum RemoteAgentDetection { - static let defaultsKey = "remoteAgentDetectionEnabled" static let environmentKey = "PROWL_REMOTE_AGENT_DETECTION" - static var isEnabled: Bool { - isEnabled(environment: ProcessInfo.processInfo.environment, defaults: .standard) + enum Mode: Equatable, Sendable { + case enabled + case disabled + /// Detection waits for the user's answer: Prowl opens no connection and asks. + case awaitingConsent } - static func isEnabled(environment: [String: String], defaults: UserDefaults) -> Bool { - if let value = environment[environmentKey] { return value != "0" } - return defaults.object(forKey: defaultsKey) as? Bool ?? true + static func mode(environment: [String: String], consent: RemoteAgentDetectionConsent) -> Mode { + if let value = environment[environmentKey] { return value == "0" ? .disabled : .enabled } + switch consent { + case .undecided: return .awaitingConsent + case .enabled: return .enabled + case .disabled: return .disabled + } } } diff --git a/App/Tests/RemoteAgentConsentTests.swift b/App/Tests/RemoteAgentConsentTests.swift new file mode 100644 index 00000000..e6b9b4ac --- /dev/null +++ b/App/Tests/RemoteAgentConsentTests.swift @@ -0,0 +1,178 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Sharing +import Testing + +@testable import Prowl + +/// Consent before the first side-channel connection (docs-ai 082, L0 item 12): the stored +/// answer, the panel the first `ssh` raises, and the toolbar status order. +@MainActor +struct RemoteAgentConsentTests { + private let prompt = RemoteAgentConsentPrompt(worktreeID: "/tmp/repo", surfaceID: UUID(), destination: "mini") + + @Test func theEnvironmentOverridesTheStoredAnswer() { + #expect(RemoteAgentDetection.mode(environment: [:], consent: .undecided) == .awaitingConsent) + #expect(RemoteAgentDetection.mode(environment: [:], consent: .enabled) == .enabled) + #expect(RemoteAgentDetection.mode(environment: [:], consent: .disabled) == .disabled) + let turnedOff = ["PROWL_REMOTE_AGENT_DETECTION": "0"] + let turnedOn = ["PROWL_REMOTE_AGENT_DETECTION": "1"] + #expect(RemoteAgentDetection.mode(environment: turnedOff, consent: .enabled) == .disabled) + #expect(RemoteAgentDetection.mode(environment: turnedOn, consent: .disabled) == .enabled) + // Neither value asks: an override never waits for an answer. + #expect(RemoteAgentDetection.mode(environment: turnedOn, consent: .undecided) == .enabled) + #expect(RemoteAgentDetection.mode(environment: turnedOff, consent: .undecided) == .disabled) + } + + @Test func theSwitchShowsOnUnlessTurnedOff() { + #expect(RemoteAgentDetectionConsent.undecided.showsEnabled) + #expect(RemoteAgentDetectionConsent.enabled.showsEnabled) + #expect(!RemoteAgentDetectionConsent.disabled.showsEnabled) + } + + @Test func theAnswerPersistsAndAnUnknownValueAsksAgain() throws { + #expect(GlobalSettings.default.remoteAgentDetection == .undecided) + var settings = GlobalSettings.default + settings.remoteAgentDetection = .disabled + let data = try JSONEncoder().encode(settings) + #expect(try JSONDecoder().decode(GlobalSettings.self, from: data).remoteAgentDetection == .disabled) + + var dictionary = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + dictionary["remoteAgentDetection"] = "askLater" + let future = try JSONSerialization.data(withJSONObject: dictionary) + #expect(try JSONDecoder().decode(GlobalSettings.self, from: future).remoteAgentDetection == .undecided) + dictionary.removeValue(forKey: "remoteAgentDetection") + let older = try JSONSerialization.data(withJSONObject: dictionary) + #expect(try JSONDecoder().decode(GlobalSettings.self, from: older).remoteAgentDetection == .undecided) + } + + @Test(.dependencies) func theFirstSSHShowsThePanelAndTurnOnStoresTheAnswer() async { + let commands = LockIsolated<[TerminalClient.Command]>([]) + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } withDependencies: { + $0.terminalClient.send = { command in commands.withValue { $0.append(command) } } + } + store.exhaustivity = .off + + await store.send( + .terminalEvent( + .remoteAgentConsentNeeded( + worktreeID: prompt.worktreeID, surfaceID: prompt.surfaceID, destination: prompt.destination)) + ) { + $0.remoteAgentConsentPrompt = prompt + } + await store.send(.remoteAgentConsent(.turnOn)) { + $0.remoteAgentConsentPrompt = nil + } + await store.receive(\.settings.setRemoteAgentDetection) { + $0.settings.remoteAgentDetection = .enabled + } + await store.receive(\.settings.delegate.settingsChanged) + await store.finish() + #expect(commands.value.contains(.setRemoteAgentDetection(.enabled))) + @Shared(.settingsFile) var settingsFile + #expect(settingsFile.global.remoteAgentDetection == .enabled) + } + + @Test(.dependencies) func notNowWaitsForTheNextLaunch() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + store.exhaustivity = .off + let event = TerminalClient.Event.remoteAgentConsentNeeded( + worktreeID: prompt.worktreeID, surfaceID: prompt.surfaceID, destination: prompt.destination) + + await store.send(.terminalEvent(event)) { + $0.remoteAgentConsentPrompt = prompt + } + await store.send(.remoteAgentConsent(.notNow)) { + $0.remoteAgentConsentPrompt = nil + $0.isRemoteAgentConsentDeferred = true + } + // Another `ssh` in this launch does not ask again, and nothing is stored. + await store.send(.terminalEvent(event)) + #expect(store.state.remoteAgentConsentPrompt == nil) + #expect(store.state.settings.remoteAgentDetection == .undecided) + } + + @Test(.dependencies) func turnOffStoresTheAnswer() async { + let store = TestStore(initialState: AppFeature.State()) { + AppFeature() + } + store.exhaustivity = .off + + await store.send( + .terminalEvent( + .remoteAgentConsentNeeded( + worktreeID: prompt.worktreeID, surfaceID: prompt.surfaceID, destination: prompt.destination))) + await store.send(.remoteAgentConsent(.turnOff)) { + $0.remoteAgentConsentPrompt = nil + } + await store.receive(\.settings.setRemoteAgentDetection) { + $0.settings.remoteAgentDetection = .disabled + } + } + + @Test(.dependencies) func aStoredAnswerNeverShowsThePanel() async { + var state = AppFeature.State() + state.settings.remoteAgentDetection = .disabled + let store = TestStore(initialState: state) { + AppFeature() + } + + await store.send( + .terminalEvent( + .remoteAgentConsentNeeded( + worktreeID: prompt.worktreeID, surfaceID: prompt.surfaceID, destination: prompt.destination))) + } + + @Test(.dependencies) func answeringInSettingsClosesThePanel() async { + var state = AppFeature.State() + state.remoteAgentConsentPrompt = prompt + let store = TestStore(initialState: state) { + AppFeature() + } + store.exhaustivity = .off + var settings = GlobalSettings.default + settings.remoteAgentDetection = .enabled + + await store.send(.settings(.delegate(.settingsChanged(settings)))) { + $0.remoteAgentConsentPrompt = nil + } + } + + @Test func theConsentPanelOutranksWorkAndTheChannelSitsAbovePullRequests() { + let idle = WorkflowStatusCenterPresentation( + state: WorkflowRunsFeature.State(), selectedWorktreeID: nil, now: Date(timeIntervalSince1970: 0)) + let channel = RemoteChannelToolbarStatus( + .connected(host: "dev@mini", ssh: LocalProcessGeneration(pid: 1, startedAt: .distantPast))) + #expect(channel != nil) + + #expect( + ToolbarStatusSelection( + toast: .success("Saved"), workflow: idle, pullRequest: nil, remoteAgentConsent: prompt) + == .toast(.success("Saved"))) + #expect( + ToolbarStatusSelection( + toast: nil, workflow: idle, pullRequest: nil, remoteAgentConsent: prompt, remoteChannel: channel) + == .remoteAgentConsent(prompt)) + #expect( + ToolbarStatusSelection(toast: nil, workflow: idle, pullRequest: nil, remoteChannel: channel) + == .remoteChannel(channel!)) + #expect(ToolbarStatusSelection(toast: nil, workflow: idle, pullRequest: nil) == .motivational) + } + + @Test func onlyAPaneWithASideChannelShowsInTheToolbar() throws { + let ssh = LocalProcessGeneration(pid: 1, startedAt: .distantPast) + let failed = try #require( + RemoteChannelToolbarStatus(.connected(host: "mini", ssh: ssh, issue: .probeUnavailable(.authentication)))) + #expect(failed.host == "mini") + #expect(failed.issue == .probeUnavailable(.authentication)) + // Prowl never connects for an `ssh` outside the grammar, and an ended one has no channel. + #expect(RemoteChannelToolbarStatus(.connected(host: "mini", ssh: ssh, issue: .unsupported("-N"))) == nil) + #expect(RemoteChannelToolbarStatus(.ended(host: "mini", endedAt: .distantPast)) == nil) + #expect(RemoteChannelToolbarStatus(nil) == nil) + } +} diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index fc13de7f..f3485642 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -479,6 +479,75 @@ struct RemoteAgentDetectionTests { + "Agents in this pane are detected only when they report their status themselves (OSC 7501).") } + /// A fixture whose mode comes from the stored consent, with no environment override. + private func makeConsentFixture(probe: Probe) -> Fixture { + let fixture = makeFixture(probe: probe) + fixture.state.remoteAgentDetectionEnabledForTesting = nil + fixture.state.remoteDetectionEnvironmentForTesting = [:] + return fixture + } + + @Test func anUndecidedAnswerAsksOnceAndOpensNoConnection() async { + let probe = Probe() + let fixture = makeConsentFixture(probe: probe) + var asked: [String] = [] + fixture.state.onRemoteAgentConsentNeeded = { surfaceID, destination in + #expect(surfaceID == fixture.surfaceID) + asked.append(destination) + } + probe.job = sshJob + await detect(fixture) + await detect(fixture) + for _ in 0..<50 { await Task.yield() } + + #expect(asked == ["mini"]) + #expect(fixture.source.attempts == 0) + #expect(fixture.state.remotePaneTrackers[fixture.surfaceID] == nil) + #expect(fixture.surface.bridge.state.remoteIndicator == nil) + } + + @Test func anSSHOutsideTheGrammarDoesNotAsk() async { + let probe = Probe() + let fixture = makeConsentFixture(probe: probe) + var asked: [String] = [] + fixture.state.onRemoteAgentConsentNeeded = { _, destination in asked.append(destination) } + probe.job = ForegroundJob( + processGroupID: getpid(), + processes: [ + ForegroundProcess( + pid: getpid(), parentProcessID: 1, name: "ssh", argv0: "ssh", cmdline: "ssh -N mini", + arguments: ["ssh", "-N", "mini"]) + ]) + await detect(fixture) + #expect(asked.isEmpty) + } + + @Test func turningDetectionOnReadsTheOpenSSHWithoutAReconnect() async { + let probe = Probe() + let fixture = makeConsentFixture(probe: probe) + probe.job = sshJob + await detect(fixture) + #expect(fixture.source.attempts == 0) + + fixture.state.setRemoteAgentDetectionConsent(.enabled) + await detect(fixture) + await settle { fixture.source.recipes.count == 1 } + #expect(fixture.source.recipes.first?.destination == "mini") + #expect(fixture.state.remoteConsentAskedSSHBySurface.isEmpty) + } + + @Test func theEnvironmentNeverAsks() async { + let probe = Probe() + let fixture = makeConsentFixture(probe: probe) + fixture.state.remoteDetectionEnvironmentForTesting = ["PROWL_REMOTE_AGENT_DETECTION": "0"] + var asked = false + fixture.state.onRemoteAgentConsentNeeded = { _, _ in asked = true } + probe.job = sshJob + await detect(fixture) + #expect(!asked) + #expect(fixture.source.attempts == 0) + } + @Test func turningDetectionOffClearsTheRemoteLabel() async { let probe = Probe() let fixture = makeFixture(probe: probe) @@ -1591,18 +1660,6 @@ struct RemoteAgentDetectionTests { } } -struct RemoteAgentDetectionSwitchTests { - @Test func detectionIsOnUnlessTurnedOff() throws { - let defaults = try #require(UserDefaults(suiteName: "RemoteAgentDetectionSwitchTests-\(UUID().uuidString)")) - #expect(RemoteAgentDetection.isEnabled(environment: [:], defaults: defaults)) - #expect(!RemoteAgentDetection.isEnabled(environment: ["PROWL_REMOTE_AGENT_DETECTION": "0"], defaults: defaults)) - defaults.set(false, forKey: RemoteAgentDetection.defaultsKey) - #expect(!RemoteAgentDetection.isEnabled(environment: [:], defaults: defaults)) - // The environment wins over the default, both ways. - #expect(RemoteAgentDetection.isEnabled(environment: ["PROWL_REMOTE_AGENT_DETECTION": "1"], defaults: defaults)) - } -} - struct RemoteAgentContextTests { @Test func stalenessFollowsTheLastConfirmation() { let now = Date(timeIntervalSince1970: 1_000) diff --git a/docs-ai/082-ssh-remote-agents/000-plan.md b/docs-ai/082-ssh-remote-agents/000-plan.md index 2a190679..3289ce47 100644 --- a/docs-ai/082-ssh-remote-agents/000-plan.md +++ b/docs-ai/082-ssh-remote-agents/000-plan.md @@ -212,6 +212,21 @@ ticks; macOS: `kern.boottime` and `lstart`), elapsed time, cwd, the tmux mapping 11. **Admission.** A pane whose agent has a remote origin, including a manual `ssh` from a local repository, cannot be a workflow participant or a dispatch target that needs a receipt. The refusal is a clear error at the admission point, not only in the UI. +12. **Consent and side-channel status.** The side channel reaches hosts the user named, and + the host can log it, so it starts only after the user agrees. + - Settings › Agents has the switch. Its stored value is `undecided` (the default), `on` + or `off`. The switch shows on unless the value is `off`. + - While the value is `undecided`, Prowl opens no connection and runs no `ssh -G`. The + first foreground `ssh` inside the grammar shows a consent panel: a toolbar status item + that opens its panel at once. The panel never takes keyboard focus, because the user + can be typing a password or a code. Its choices are Turn On, Not Now (ask again after a + relaunch) and Turn Off. A change of the switch in Settings also answers it. + - `PROWL_REMOTE_AGENT_DETECTION` overrides the stored value and never shows the panel. + - While the focused pane's `ssh` uses the side channel (a resolved connection, or a + failed attempt), the toolbar status shows the host and its health. Hover opens a panel + that tells what the extra connection does, the pane's correlation, and where to turn + it off. + - Toolbar status order: toast, consent, workflow, rate limit, side channel, pull request. ### L1 — Remote folders @@ -346,6 +361,7 @@ case passes. | 2 | S2 | C1 host identity, C4 connection owner, C3 snapshot contract, C5 command model, C6 probe and its parser (unit tested with recorded output) | None (unused) | | 3 | S3 | L0 manual correlation: `LC_PROWL_PANE`, argv grammar, effective-config overrides, correlation states, generation and fence, release, stale projection, degraded OSC binding, admission refusal | Behind a feature flag | | 4 | S4 | L0 UX: host label, connection health, ssh-ended marker, CLI fields, docs; turns the S3 flag on | Manual remote agents appear | +| 4c | S4c | L0 consent: stored `undecided`/`on`/`off`, consent panel at the first `ssh`, no connection before consent, Settings switch, side-channel toolbar status | No connection until the user agrees | | 5 | S5 | L0 tmux: user-owned tmux mapping and capture-based state | tmux panes detected | | 6 | L1a | `RepositoryLocation`, `localURL: URL?`, call-site triage | None | | 7 | L1b | `RepositoryHost` local implementation (fast paths kept); `GitClient` routed through it | None | @@ -371,6 +387,8 @@ case passes. | L0-9 | Cost against a recorded baseline: local CPU grows by at most 2 % of one core per 10 remote panes (measured at 1/10/50), plus remote CPU, bytes/s and subprocess count recorded; one in-flight probe per connection; probe p95 < 0.5 s on a host with ≥ 500 processes | | L0-10 | No Prowl-installed files, configuration or probe artifacts on the host; no rc edits | | L0-11 | Host label, connection health and the ssh-ended marker are visible; `prowl agents --json` carries host, correlation state and freshness | +| L0-12 | With the value `undecided`, a pane's `ssh` starts no Prowl `ssh` process; the consent panel shows once per launch and keeps the terminal's keyboard focus; Turn On starts detection in open `ssh` panes without a reconnect | +| L0-13 | The focused pane's side channel shows in the toolbar status with host and health; its hover panel explains the extra connection | | L1-1 | Add, persist, restore a remote folder; a display-alias edit does not redirect it; a changed `HostName` pauses operations until the user rebinds or keeps the old target; hostile but valid path characters work; an unreachable host stays listed | | L1-2 | Old local settings and snapshots migrate unchanged; identical local and remote paths do not collide | | L1-3 | Worktree list/create/remove/rename on a bash 3.2 (macOS) host and a bash 5 (Linux) host | @@ -429,3 +447,4 @@ case passes. - Updated 2026-10-16: spike record for L0–L2 — see [002-spike-findings.md](002-spike-findings.md) - Updated 2026-10-17: revised in place after review round 1 — shared contracts C1–C6 (host identity, typed process reference, snapshot freshness, connection owner, structured commands, probe wire format), secondary port key, attachment-epoch fence, `clear` as withdrawal, capture-based tmux state, per-pane sessions with separate create/attach/end, structured remote Profile launch, local fast paths behind the host boundary, admission enforcement, UX items, PR order - Updated 2026-10-17: revised in place after review round 2 — ownership-based evidence retirement and `ConnectionKey` in S1, replacement-interval OSC fence, effective-config overrides and authority rebinding, qualified secondary key, stale projection and remote-unprobed origin, scheduling rule and numeric budgets, detach at close with reconnect Undo, removal disclosure, exposure gates +- Updated 2026-10-18: L0 item 12 (consent before the first side-channel connection, Settings switch, side-channel toolbar status), slice S4c, criteria L0-12 and L0-13 diff --git a/docs/components/agent-detection.md b/docs/components/agent-detection.md index d984a6b9..bab74b55 100644 --- a/docs/components/agent-detection.md +++ b/docs/components/agent-detection.md @@ -400,9 +400,23 @@ frames keep the last trusted state instead of forcing Idle. ## Remote agents over SSH When a pane's foreground command is a local `ssh`, Prowl shows the agent that runs on the -host (docs-ai 082, L0). Prowl installs nothing on the host and edits no rc file. To turn -the feature off, run `defaults write com.onevcat.prowl remoteAgentDetectionEnabled -bool -false` and relaunch Prowl, or launch it with `PROWL_REMOTE_AGENT_DETECTION=0`. +host (docs-ai 082, L0). Prowl installs nothing on the host and edits no rc file. + +- **Consent.** Prowl does not connect to a host before you agree. The first `ssh` that + Prowl can read shows "Detect agents on ?" in the toolbar status, with its panel + open. The panel does not take keyboard focus, so you can finish a password or code + prompt first. **Turn On** starts detection in the open `ssh` panes at once, **Not Now** + asks again after Prowl restarts, and **Turn Off** stops asking. Until you choose, Prowl + opens no connection. Settings › Agents › Display › **Detect agents in SSH sessions** + holds the answer; the switch shows on until you turn it off. The environment variable + `PROWL_REMOTE_AGENT_DETECTION` overrides the setting and never asks: `0` turns the + feature off, any other value turns it on. +- **Toolbar status.** While the focused pane's `ssh` uses Prowl's own connection, the + toolbar status shows the host and "Agent detection on", or "Agents not detected" with a + warning glyph. Hover (or click) for a panel that tells what the extra connection does, + why agents are not detected, and a button to the setting. The consent question ranks + above workflow runs there; this status ranks below workflow runs and rate limits, and + above the pull request. - **How.** Prowl opens its own SSH connection to the same target (`ssh -G` resolves user, host name, port, `ProxyJump` and `-F`). It runs a read-only probe over that connection: diff --git a/docs/components/settings.md b/docs/components/settings.md index 6eaaf13d..bd07221b 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -35,7 +35,7 @@ and opens that section's root. | **GitHub** | Enable GitHub integration (uses the `gh` CLI). → [github-pull-requests](github-pull-requests.md) | | **Commands** | Global Custom Commands. Enabled commands appear in the window toolbar; each repo can independently hide a Global command. → [custom-actions](custom-actions.md) | | **Advanced** | Analytics, crash reports, restore terminal layout on launch (experimental) + clear saved layout. | -| **Agents → Display** | Active Agents panel auto-show, terminal titles, Shelf status markers, and Agent Island enablement, Monitor, global shortcut, silent opacity, and floating-position reset. → [active-agents](active-agents.md), [agent-island](agent-island.md) | +| **Agents → Display** | Active Agents panel auto-show, terminal titles, Shelf status markers, and Agent Island enablement, Monitor, global shortcut, silent opacity, and floating-position reset, and **Detect agents in SSH sessions** (Prowl's own SSH connection; asked at the first `ssh`). → [active-agents](active-agents.md), [agent-island](agent-island.md), [agent-detection](agent-detection.md#remote-agents-over-ssh) | | **Agents → Profiles** | Named launch presets for supported agent runtimes (model, effort, execution mode, tab/split placement, extra arguments, opt-in dedicated home for a separate account) with a live launch preview. List order is the recommendation fallback. → [agent-profiles](agent-profiles.md) | | **Agents → CLI & Skills** | Install/status for the bundled `prowl` CLI, the local socket path it uses to reach the app and whether this app is **listening** on it, and the **Agent Skills** section that links the bundled skills into your agents' skill folders. → [cli](cli.md) | | **Agents → Workflows** | Compact Built-in and personal workflow lists. Select a row for Enabled, explicit Run target, role profile preferences, Run Setup, validation, and source-file actions; **New Workflow…**, **Create with Agent…**, the **Run History** summary with **Clear History…**, and the CLI dependency banner remain on the index. → [workflows](workflows.md) | -- 2.54.0 From 834aec6e25bacc8ac2d356bb8fc3e67fdcab3fe6 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 21:13:54 +0900 Subject: [PATCH 48/51] Record S4c in the 082 action log --- docs-ai/082-ssh-remote-agents/001-action.md | 46 +++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 0075ce20..e2b474b0 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -522,3 +522,49 @@ identity from the mapping and state from the screen. release at 0.56 s. Reattaching showed it working again after 0.58 s. - `state_known` stayed true throughout. + +## S4c — Consent before the first connection, side-channel status + +- **Stored answer.** `GlobalSettings.remoteAgentDetection` is a `RemoteAgentDetectionConsent`: + `undecided` (the default), `enabled` or `disabled`. An unknown stored value decodes as + `undecided`, so a newer build's value never fails the settings file. This replaces the + `remoteAgentDetectionEnabled` default of S4b. `RemoteAgentDetection.mode(environment:consent:)` + gives `enabled`, `disabled` or `awaitingConsent`; `PROWL_REMOTE_AGENT_DETECTION` still wins + (`0` off, any other value on) and never asks. +- **No connection before consent.** The app pushes the answer through + `TerminalClient.Command.setRemoteAgentDetection` to every `WorktreeTerminalState`. While + the mode is `awaitingConsent`, `observeRemoteAgent` creates no tracker, so there is no + `ssh -G` and no side channel. The first foreground `ssh` of a pane generation asks once + (`remoteConsentAskedSSHBySurface`). It only parses the command line; an `ssh` outside the + grammar does not ask. The ask is the event `remoteAgentConsentNeeded(worktreeID, + surfaceID, destination)`. When the answer becomes `enabled`, the panes that asked are + woken, so their open `ssh` sessions are read without a reconnect. +- **Panel.** `AppFeature.State.remoteAgentConsentPrompt` holds the question; a stored answer + or Not Now (`isRemoteAgentConsentDeferred`, until the next launch) drops new asks. The + toolbar status shows "Detect agents on ?" and opens its popover at once + (`RemoteAgentConsentStatusButton`). The choices are Turn Off, Not Now and Turn On. They + are not focusable and none is the default button, so a Return typed for a password prompt + never answers. A change of the Settings switch (Agents › Display › SSH Sessions) also + answers and closes the panel. +- **Side-channel status.** `RemoteChannelToolbarStatus` reads the focused pane's + `RemotePaneIndicator`: `connected` without `unsupported` (Prowl never connects for an + `ssh` outside the grammar). `RemoteChannelStatusButton` shows the host with "Agent + detection on", or a warning glyph with "Agents not detected". Hover or click opens a + panel that tells what the extra connection does, the issue explanation from + `RemoteAgentPresentation.paneIssueExplanation`, and "Agent Settings…". +- **Order.** `ToolbarStatusSelection`: toast, consent, workflow, rate limit, side channel, + pull request, then the clock. +- **Focus (measured).** A test window with a text view showed that neither + `NSPopover(.applicationDefined)` nor SwiftUI `.popover` changes the key window or the first + responder. +- **E2E** (isolated Debug instance, mini, Claude 2.1.302): + - Undecided: `ssh` to mini opened the panel; no `cm-` process existed. AX reported the + main window as focused and the pane's text area as the focused element. + - Claude started on mini before the answer was not listed. Turn On started the side + channel after 0.34 s, stored `enabled`, and listed Claude (`correlated`, `probed`) + without a reconnect. The toolbar showed the host with "Agent detection on", and its + panel opened on click. + - "Agent Settings…" opened Settings › Agents › Display. Turning the switch off stored + `disabled`, ended the side channel after 0.34 s, and cleared the agent and the status. + - After a relaunch with `undecided`, Not Now closed the panel; a second `ssh` in that + launch did not ask, and no connection opened. -- 2.54.0 From a2c22e0cad750d9a27aaac33dc9e1096884fc8a0 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 21:26:20 +0900 Subject: [PATCH 49/51] Restart detection on every pane when the consent mode changes A pane whose loop went cold while detection was off had nothing to wake it, so turning detection on in Settings left its open ssh unread. --- .../WorktreeTerminalState+AgentDetection.swift | 16 +++++++++------- App/Tests/RemoteAgentDetectionTests.swift | 18 ++++++++++++++++++ docs-ai/082-ssh-remote-agents/001-action.md | 6 ++++-- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index f5059b44..9484f944 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -569,16 +569,18 @@ extension WorktreeTerminalState { onRemoteAgentConsentNeeded?(surfaceID, recipe.destination) } - /// Applies the stored answer. Turning detection on wakes the panes that asked, so their - /// open `ssh` sessions are detected without a reconnect. + /// Applies the stored answer. A change of mode runs one tick on every pane: a pane whose + /// loop went cold while detection was off or waiting has nothing else to wake it, and its + /// open `ssh` must be read (or let go) without a reconnect or a key press. A pane that + /// gets a tracker keeps ticking (`remoteAdjustedInterval`); the others go cold again. func setRemoteAgentDetectionConsent(_ consent: RemoteAgentDetectionConsent) { guard remoteAgentDetectionConsent != consent else { return } + let previousMode = remoteAgentDetectionMode remoteAgentDetectionConsent = consent - guard remoteAgentDetectionMode == .enabled else { return } - let asked = remoteConsentAskedSSHBySurface.keys - remoteConsentAskedSSHBySurface.removeAll() - for surfaceID in asked { - guard let view = surfaces[surfaceID], let tabId = tabId(containing: surfaceID) else { continue } + guard remoteAgentDetectionMode != previousMode else { return } + if remoteAgentDetectionMode == .enabled { remoteConsentAskedSSHBySurface.removeAll() } + for (surfaceID, view) in surfaces { + guard let tabId = tabId(containing: surfaceID) else { continue } startAgentDetectionTaskIfNeeded(for: view, tabId: tabId) interruptAgentDetectionSleep(forSurfaceID: surfaceID) } diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index f3485642..cfbfc946 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -536,6 +536,24 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.remoteConsentAskedSSHBySurface.isEmpty) } + /// Review R1: a pane whose loop went cold while detection was off has nothing to wake it; + /// turning detection on must start its loop, whether or not it ever asked. + @Test func turningDetectionOnRestartsAColdSSHPane() async { + let probe = Probe() + let fixture = makeConsentFixture(probe: probe) + fixture.state.setRemoteAgentDetectionConsent(.disabled) + probe.job = sshJob + await detect(fixture) + #expect(fixture.source.attempts == 0) + // The loop finished cold: no task is left for this surface. + fixture.state.agentDetectionTasks[fixture.surfaceID] = nil + + fixture.state.setRemoteAgentDetectionConsent(.enabled) + await settle { fixture.source.attempts == 1 } + fixture.state.agentDetectionTasks[fixture.surfaceID]?.cancel() + fixture.state.retireRemotePaneTracker(surfaceID: fixture.surfaceID) + } + @Test func theEnvironmentNeverAsks() async { let probe = Probe() let fixture = makeConsentFixture(probe: probe) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index e2b474b0..8c27d234 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -537,8 +537,10 @@ identity from the mapping and state from the screen. `ssh -G` and no side channel. The first foreground `ssh` of a pane generation asks once (`remoteConsentAskedSSHBySurface`). It only parses the command line; an `ssh` outside the grammar does not ask. The ask is the event `remoteAgentConsentNeeded(worktreeID, - surfaceID, destination)`. When the answer becomes `enabled`, the panes that asked are - woken, so their open `ssh` sessions are read without a reconnect. + surfaceID, destination)`. A change of mode runs one tick on every pane, so open `ssh` + sessions are read (or let go) without a reconnect or a key press, also in a pane whose + loop went cold while detection was off (review round 1). A pane that gets a tracker keeps + ticking; the others go cold again. - **Panel.** `AppFeature.State.remoteAgentConsentPrompt` holds the question; a stored answer or Not Now (`isRemoteAgentConsentDeferred`, until the next launch) drops new asks. The toolbar status shows "Detect agents on ?" and opens its popover at once -- 2.54.0 From 363302941fc32d4a84f75c748f0008a75b4ac046 Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 21:38:36 +0900 Subject: [PATCH 50/51] Give a cold pane its state back before the consent tick Cold cleanup removes the state of a pane without an agent, and a tick cannot publish an agent without it, so the restarted loop connected but never listed the remote agent. --- .../WorktreeTerminalState+AgentDetection.swift | 5 +++++ App/Tests/RemoteAgentDetectionTests.swift | 18 +++++++++++------- docs-ai/082-ssh-remote-agents/001-action.md | 5 +++-- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift index 9484f944..099d28c2 100644 --- a/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift +++ b/App/Sources/Features/Terminal/Models/WorktreeTerminalState+AgentDetection.swift @@ -579,8 +579,13 @@ extension WorktreeTerminalState { remoteAgentDetectionConsent = consent guard remoteAgentDetectionMode != previousMode else { return } if remoteAgentDetectionMode == .enabled { remoteConsentAskedSSHBySurface.removeAll() } + let now = Date() for (surfaceID, view) in surfaces { guard let tabId = tabId(containing: surfaceID) else { continue } + // A cold pane has no state, and a tick cannot publish an agent without one. + if surfaceAgentStates[surfaceID] == nil { + surfaceAgentStates[surfaceID] = PaneAgentState(lastChangedAt: now) + } startAgentDetectionTaskIfNeeded(for: view, tabId: tabId) interruptAgentDetectionSleep(forSurfaceID: surfaceID) } diff --git a/App/Tests/RemoteAgentDetectionTests.swift b/App/Tests/RemoteAgentDetectionTests.swift index cfbfc946..e58e6abf 100644 --- a/App/Tests/RemoteAgentDetectionTests.swift +++ b/App/Tests/RemoteAgentDetectionTests.swift @@ -536,20 +536,24 @@ struct RemoteAgentDetectionTests { #expect(fixture.state.remoteConsentAskedSSHBySurface.isEmpty) } - /// Review R1: a pane whose loop went cold while detection was off has nothing to wake it; - /// turning detection on must start its loop, whether or not it ever asked. - @Test func turningDetectionOnRestartsAColdSSHPane() async { + /// Review R1 (rounds 1 and 2): a pane whose loop went cold while detection was off, or + /// while the question waited, has nothing to wake it and no pane state. Turning detection + /// on must start its loop and publish the remote agent, with no key press or manual tick. + @Test(arguments: [RemoteAgentDetectionConsent.disabled, .undecided]) + func turningDetectionOnPublishesTheAgentOfAColdSSHPane(before: RemoteAgentDetectionConsent) async { let probe = Probe() let fixture = makeConsentFixture(probe: probe) - fixture.state.setRemoteAgentDetectionConsent(.disabled) + fixture.state.setRemoteAgentDetectionConsent(before) probe.job = sshJob await detect(fixture) #expect(fixture.source.attempts == 0) - // The loop finished cold: no task is left for this surface. - fixture.state.agentDetectionTasks[fixture.surfaceID] = nil + fixture.state.finishColdAgentDetection(forSurfaceID: fixture.surfaceID) + #expect(fixture.state.surfaceAgentStates[fixture.surfaceID] == nil) fixture.state.setRemoteAgentDetectionConsent(.enabled) - await settle { fixture.source.attempts == 1 } + await settle { fixture.source.recipes.count == 1 } + fixture.source.push(snapshot(1, pane: fixture.surfaceID, foreground: remoteProcess(200, name: "claude"))) + await settle { fixture.state.surfaceAgentStates[fixture.surfaceID]?.detectedAgent == .claude } fixture.state.agentDetectionTasks[fixture.surfaceID]?.cancel() fixture.state.retireRemotePaneTracker(surfaceID: fixture.surfaceID) } diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 8c27d234..84adb070 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -539,8 +539,9 @@ identity from the mapping and state from the screen. grammar does not ask. The ask is the event `remoteAgentConsentNeeded(worktreeID, surfaceID, destination)`. A change of mode runs one tick on every pane, so open `ssh` sessions are read (or let go) without a reconnect or a key press, also in a pane whose - loop went cold while detection was off (review round 1). A pane that gets a tracker keeps - ticking; the others go cold again. + loop went cold while detection was off or the question waited. A cold pane also gets its + pane state back first: a tick cannot publish an agent without one (review rounds 1 and + 2). A pane that gets a tracker keeps ticking; the others go cold again. - **Panel.** `AppFeature.State.remoteAgentConsentPrompt` holds the question; a stored answer or Not Now (`isRemoteAgentConsentDeferred`, until the next launch) drops new asks. The toolbar status shows "Detect agents on ?" and opens its popover at once -- 2.54.0 From c474f9eb74e7a9ea313ba222fd188bf0cd541d5e Mon Sep 17 00:00:00 2001 From: onevcat Date: Sun, 18 Oct 2026 21:43:12 +0900 Subject: [PATCH 51/51] Add the S4c row to the 082 action log timeline --- docs-ai/082-ssh-remote-agents/001-action.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs-ai/082-ssh-remote-agents/001-action.md b/docs-ai/082-ssh-remote-agents/001-action.md index 84adb070..3de0e511 100644 --- a/docs-ai/082-ssh-remote-agents/001-action.md +++ b/docs-ai/082-ssh-remote-agents/001-action.md @@ -16,6 +16,7 @@ sections below record what shipped and what differs from the plan. | 2026-10-18 | S4b: pane and tab host label, ssh-ended marker; remote detection on by default. Review Loop: 3 rounds plus a 2-round follow-up, clean | #931 | | 2026-10-18 | S5a: probe format 2 maps a session's tmux client to its active pane, with the pane's foreground job and visible screen. Review Loop clean after 3 rounds | #932 | | 2026-10-18 | S5b: inside tmux, identity from the mapping and state from the capture. Review Loop clean after 3 rounds. L0 complete | #933 | +| 2026-10-18 | S4c: consent before the first side-channel connection (stored `undecided`/`enabled`/`disabled`, toolbar consent panel, Settings switch) and the side-channel toolbar status. Review Loop clean after 3 rounds | #937 | ## S1 — Typed process references -- 2.54.0