Skip to content

fix(agent-hooks): drop hook status whose cwd disproves its pane - #11094

Closed
kunsanglee wants to merge 20 commits into
stablyai:mainfrom
kunsanglee:fix/agent-hook-pane-attribution-guard
Closed

kunsanglee wants to merge 20 commits into
stablyai:mainfrom
kunsanglee:fix/agent-hook-pane-attribution-guard

Conversation

@kunsanglee

@kunsanglee kunsanglee commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A session running in one workspace can have its agent status land on a different workspace's sidebar card, while the workspace it actually runs in shows nothing.

Agent CLIs increasingly pre-warm a shared background daemon and hand later sessions to it. The daemon keeps the environment of whichever pane spawned it, including ORCA_PANE_KEY, ORCA_TAB_ID, and ORCA_WORKTREE_ID. Every session it hosts afterwards inherits that first pane's Orca identity, and the managed hook script posts those env values verbatim. When two sessions land on the same pane key the server merges their fields into one row, so the visible row mixes one session's prompt with another's subagents.

Observed on 1.4.158 with Claude Code: one claude daemon run process spawned from a workspace pane at 09:23, and every session claimed from its pre-warmed spares carried that pane's ORCA_PANE_KEY. last-status.json held a single entry keyed to that pane whose transcript path pointed at an entirely different project, and the pane actually running that project had no entry at all.

This change makes the hook server verify pane attribution instead of trusting inherited env. normalizeHookPayload now keeps the session cwd the agent reports in its own payload, and the server cross-checks it against the worktree path already encoded in the reported worktreeId (<repoId>::<path>). When the two are disjoint the event is refused rather than written to the wrong pane, and agent_hook_unattributed is tracked with a new cwd_worktree_mismatch reason.

Ambiguity keeps today's behavior, since dropping a real status row is the worse failure:

  • no cwd in the payload, for sources that don't expose one
  • a worktree id with no path component, such as ephemeral setup terminals
  • a relative or otherwise unrooted path on either side
  • either path nested inside the other, covering subdirectory starts and folder workspaces
  • mixed notations one host can express two ways: a drive-rooted worktree against a /mnt/c/… WSL cwd, or a UNC path on either side, since UNC aliases the other notations (\\wsl$\Ubuntu\mnt\c\x is C:\x, and \\server\share can be a mapped drive)
  • case, Unicode normalization form, and trailing-separator differences

Path comparison itself is delegated to src/shared/cross-platform-path.ts, the repository's canonical path layer: resolveRuntimePath collapses . and .. before any containment test, so a cwd like /repo/../other cannot pose as living inside /repo, and isPathInsideOrEqual folds NFC so an NFD workspace path from the folder picker still matches the NFC cwd an agent reports (#10832). No filesystem access is involved.

The relay forwards sourceCwd beside the normalized payload, because payload normalization strips cwd, so remote sessions get the same check. sourceCwd stays transport-only and is stripped before last-status.json is written, matching how promptInteractionKey is already handled — the hydrate whitelist does not read it back, and persisting it would make hydration lossy and defeat the write dedupe that assumes losslessness.

Screenshots

No visual change in the sense of UI code: no renderer file is touched, and no layout, token, or component changes. The user-visible effect is which sidebar card an agent row appears on. I did not attach a capture of the failure because reproducing it on screen requires a daemon already holding another pane's environment, and my only capture of it is a personal workspace with unrelated private content.

Testing

  • pnpm lint
  • pnpm typecheck
  • pnpm test
  • pnpm build
  • Added or updated high-quality tests that would catch regressions, or explained why tests were not needed

Notes on how these were run and what they showed:

  • pnpm is not installed on this machine, so each gate was run through its underlying commands from package.json rather than the wrapper: all four oxlint stages including both --type-aware configs, then every check script in the lint chain (styled scrollbars, quadratic buffer concat, reliability gates, max-lines ratchet, bundled skill guides, skill bundle manifest, localization catalog and coverage). All passed. Typecheck ran all three projects (node, cli, web). Build ran relay, cli, electron-vite, and web-from-renderer to completion. build:native shells out to pnpm itself, so I ran its two macOS targets directly — build-computer-macos.mjs and build-notification-status-macos.mjs — and both linked and signed successfully.
  • pnpm test: 3,658 files pass, 10 fail (49 assertions). Every failure is a renderer file failing on window.localStorage.clear is not a function in the test environment. These are pre-existing: I checked out clean upstream/main (89968a106) and ran the same files there, where they fail identically. The exact set of affected renderer files shifts between runs — a second full run reported 12 files and 51 assertions — which fits an environment problem rather than anything in this change. Nothing this PR touches fails: src/main/agent-hooks, src/shared/agent-hook*, and src/relay pass in full at 1,544 tests.
  • New tests. src/shared/agent-hook-cwd-attribution.test.ts covers the comparison contract: containment in both directions, the mixed-notation and UNC refusals, dot segments that resolve outside the worktree, drive roots, folder-workspace instance ids, and an NFD-vs-NFC non-ASCII path that must not read as a conflict. src/main/agent-hooks/server-cwd-attribution.test.ts covers the local HTTP path, the relay path, the no-cwd passthrough that must stay attributed, and the once-per-runtime telemetry cap together with the stop() reset that lifts it. src/relay/agent-hook-server.test.ts asserts the relay forwards the cwd it parsed, which is the field the remote check depends on. src/main/agent-hooks/server.test.ts asserts sourceCwd never reaches last-status.json. Each of these was confirmed to fail with its production change reverted before being kept.

AI Review Report

Reviewed with Claude Code, including a pass dedicated to reuse and simplification after the first implementation was complete. That pass is what produced the delegation to cross-platform-path.ts described below, and it is worth stating plainly: two of this branch's earlier commits fixed boundary bugs that only existed because the path logic had been hand-rolled instead of reused.

  • Cross-platform (macOS, Linux, Windows). Path comparison is the whole change, so this got the most attention. It is now delegated to src/shared/cross-platform-path.ts, which the repository already uses at hundreds of call sites, rather than reimplemented: isRuntimePathAbsolute gates unrooted paths, isWindowsAbsolutePathLike separates drive-rooted from POSIX notation, resolveRuntimePath collapses dot segments, and isPathInsideOrEqual performs containment with NFC folding and correct drive-root boundaries. The one deliberate deviation is that this guard case-folds POSIX paths, which the shared comparison helper declines to do because POSIX filesystems are byte-exact. The asymmetry is the reason: the shared helper's callers use it to pick candidates, where a missed match costs nothing, while here a missed match drops a live status row. The reason is stated in a comment at the call site. Drive-rooted and POSIX paths are never compared against each other, and a UNC path on either side bails, which together leave the guard inert on WSL; that is also stated in a comment, since it is a real coverage gap rather than an oversight. No shortcut, label, menu accelerator, or Electron platform surface is touched. Comparison is pure string work with no filesystem access.
  • SSH, remote, and local. The local HTTP path and the relay ingestRemote path are both guarded. Since payload normalization strips cwd before the envelope is built, the relay had to forward it explicitly or the remote check would have silently never fired — that was caught during review and fixed, with a relay test pinning it. Remote worktree paths and remote cwds are both remote-filesystem paths, so they compare on equal terms. The guard is applied at the two entry points that can carry a sourceCwd rather than at the shared applyNormalizedStatus funnel: four of that function's six callers cannot structurally carry the field, so funnelling would mean a nullable return threaded through six call sites in exchange for four dead checks. The invariant is instead recorded on the field declaration, which is what a future third entry point has to touch.
  • Agent and integration compatibility. The guard is keyed on a field agents may or may not send. Sources that report no cwd are unaffected and stay attributed exactly as before, which is asserted by a test. The cwd is read with the listener's existing readBoundedString over the same ['cwd', 'workspaceRoot', 'workspace_root'] key list the Grok session reader already uses, so the two cannot drift and the established 4096-character bound applies. When workspaceRoot names a project root rather than the session directory it sits at or above the worktree, which the nested-path allowance treats as consistent.
  • Performance. Measured at roughly 2µs per hook event, against a per-hook end-to-end cost of about 317µs in this repository's own hook flood benchmark. The common case short-circuits after one containment test; the four-normalization path only runs for events that are actually dropped. A composition using createNormalizedPathInsideOrEqualMatcher to avoid renormalizing was built and measured, and was slower in the common case, so it was not adopted. No new allocation in any hot loop, no I/O, no new timers or listeners.
  • UI quality. No renderer code touched.
  • Regression risk from over-blocking. The deliberate design choice throughout is that any unclear comparison passes. Each bail-out branch has a test. The one accepted behavior change is described under Notes.

Security Audit

  • Input handling. sourceCwd crosses the SSH trust boundary on the relay path, so ingestRemote type-checks it, bounds its length against the same 4096-character limit the local path enforces, trims it, and treats empty as absent. On the local path the value comes from the same hook payload the server already parses, through the listener's existing bounded reader. It is only ever compared as a string.
  • Path handling. No filesystem access is added: no stat, no realpath, no reads or writes driven by the value. The value is normalized only for comparison, so a hostile path cannot cause traversal, and it is never used to build a filesystem operation. Traversal segments are collapsed before comparison, which closes the one way a crafted cwd could have kept a pane it does not own.
  • Command execution. None added.
  • Auth and secrets. Unchanged. The hook token check and the trust boundary stay exactly where they were.
  • Telemetry. The new cwd_worktree_mismatch reason is a closed enum value. No path, repo name, or other identifying string enters telemetry, matching the existing constraint on that event's schema. It is emitted once per runtime rather than once per dropped hook: a mis-attributing daemon fires on every hook of every session it hosts, and the per-session ceiling in src/main/telemetry/burst-cap.ts is shared across all event names and never refills, so an uncapped emit here could have exhausted it and silenced the rest of the session's telemetry. Since reason carries no pane, repeat events add no signal. A test pins both the cap and its reset in stop().
  • Logging. One console.warn, once per runtime, includes the pane key, worktree id, and cwd. It goes to the local Electron console only. This is a deliberate choice: without those three values the warning cannot be acted on, and the same process already persists transcript paths locally.
  • Persistence. sourceCwd is excluded from last-status.json. It is transport-only, the hydrate whitelist never reads it back, and keeping it out means no absolute path is added to what that file already stores.
  • Denial of service. The guard can only drop events, never amplify them, and holds no per-event state. The report-once flag is a single boolean, reset in stop(), so it cannot grow.
  • Follow-up. None blocking.

Notes

  • Accepted behavior change. Launching an agent from a pane after cd-ing outside that workspace now drops its status instead of showing it on that pane's card. At the hook boundary this is indistinguishable from the inherited-env case: both are a foreign cwd arriving with a valid pane's env. Refusing is the safer of the two, but it is a real change for that workflow.
  • Deliberately out of scope. This refuses a wrong attribution but does not re-attribute the event to the pane that should have received it. Doing that needs pane inventory at the hook server, which is a larger change and belongs in its own PR. toAgentStatusIpcPayload not carrying sourceCwd is the one-line seam where that follow-up would start, since the renderer already has a resolver for "which worktree owns this session cwd".
  • A related exposure I did not touch. The same inherited-env assumption exists on CLI paths that read process.env.ORCA_PANE_KEY and act on it, for example src/cli/handlers/orchestration.ts resolving a pane to write to and src/cli/handlers/worktree.ts picking a parent workspace, with no server-side cross-check. Those are arguably more consequential than a misplaced sidebar row, and each already has process.cwd() in hand, so the predicate added here would apply directly. It is a separate change and I have not made it. Flagging it here rather than leaving it undocumented.
  • Upstream cause. The underlying behavior is in the agent CLI, not Orca: a shared daemon runs hooks with the environment captured when it started rather than the environment of the session it is hosting. Orca already strips inherited pane identity from the processes it spawns itself, via PANE_IDENTITY_ENV_KEYS, but it cannot reach a daemon it does not spawn, which is why the fix is verification on receipt. Detecting rather than preventing inherited pane identity also matches the existing precedent in src/shared/agent-status-identity.ts, which handles nested child hooks inheriting a parent's ORCA_PANE_KEY the same way.
  • No platform-specific, git-provider-specific, or integration-specific behavior beyond the path-notation handling described above.
  • On the docstring-coverage warning. Every function and method this PR adds to source is documented, as are the test fixtures. The remaining undocumented callables are Vitest describe/it/mock/beforeEach callbacks. Adding JSDoc to those would conflict with AGENTS.md, which asks for concise non-obvious comments rather than restatements, so I left them alone; CodeRabbit reviewed and agreed in the thread.

X (Twitter): @kunsanglee

🤖 Generated with Claude Code

Agent CLIs increasingly pre-warm a shared background daemon, and that daemon
keeps the Orca pane env of whichever pane happened to spawn it. Every session
it later hosts inherits ORCA_PANE_KEY/ORCA_WORKTREE_ID from that first pane, so
a session running in workspace A reports workspace B's pane and its prompt,
state, and subagents render on B's sidebar card while A shows nothing.

Cross-check the session cwd the agent reports in its own hook payload against
the worktree path already encoded in the reported worktreeId, and refuse the
event when the two are disjoint. Ambiguity keeps the old behavior: a missing
cwd, a non-path worktree id, mixed WSL/UNC notation, or either path nested in
the other all stay attributed, since dropping a real status row is the worse
failure. The relay forwards cwd alongside the normalized payload so remote
sessions get the same check.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Agent hook payloads now extract and preserve the agent-reported session CWD through normalization and relay forwarding. Shared path utilities compare CWDs with reported worktrees across supported path formats. The main hook server filters contradictory local and remote status events, records cwd_worktree_mismatch telemetry, emits a warning once per lifecycle, and resets that warning state on stop. Tests cover path comparison, relay propagation, accepted and suppressed hooks, missing CWDs, telemetry, and persistence filtering.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change: rejecting hook status when cwd conflicts with pane attribution.
Description check ✅ Passed The description follows the template and includes summary, screenshots, testing, AI review, security audit, and notes with relevant details.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cc09d14c-d4a3-4bb8-9ab5-5802c5ebcf85

📥 Commits

Reviewing files that changed from the base of the PR and between 3f5098a and 7c2debc.

📒 Files selected for processing (9)
  • src/main/agent-hooks/server-cwd-attribution.test.ts
  • src/main/agent-hooks/server.ts
  • src/relay/agent-hook-server.test.ts
  • src/relay/agent-hook-server.ts
  • src/shared/agent-hook-cwd-attribution.test.ts
  • src/shared/agent-hook-cwd-attribution.ts
  • src/shared/agent-hook-listener.ts
  • src/shared/agent-hook-relay.ts
  • src/shared/telemetry-events.ts

Comment thread src/shared/agent-hook-cwd-attribution.ts Outdated
`/repo/../other` starts with `/repo` as plain text, so the disjoint-path check
read it as living inside the worktree and let a foreign session keep the pane
it had inherited. Collapse `.` and `..` lexically first, for POSIX and
drive-rooted paths alike, with an over-popping prefix landing on the root
rather than escaping it.
@kunsanglee

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

The docstring coverage check still reports the value from the first commit. Every function added in src/shared/agent-hook-cwd-attribution.ts now carries a docstring, as does the new method in src/main/agent-hooks/server.ts. Please re-evaluate against e2488d1b3.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit="e2488d1b3"

git rev-parse --verify "${commit}^{commit}"
printf '\n== Changed files at %s ==\n' "$commit"
git diff-tree --no-commit-id --name-status -r "$commit"

printf '\n== Outline: cwd attribution utility ==\n'
git show "${commit}:src/shared/agent-hook-cwd-attribution.ts" > /tmp/agent-hook-cwd-attribution.ts
ast-grep outline /tmp/agent-hook-cwd-attribution.ts --items all || true
nl -ba /tmp/agent-hook-cwd-attribution.ts | sed -n '1,240p'

printf '\n== Outline: hook server ==\n'
git show "${commit}:src/main/agent-hooks/server.ts" > /tmp/agent-hooks-server.ts
ast-grep outline /tmp/agent-hooks-server.ts --items all --match 'AgentHookServer|shouldDrop|cwd|worktree' || true
rg -n -i -C 8 'cwd|worktree|drop|attribution|docstring' /tmp/agent-hooks-server.ts

printf '\n== Docstring-check configuration and scripts ==\n'
rg -n -i -C 3 'docstring.{0,30}(coverage|check)|coverage.{0,30}docstring|typedoc|documentation coverage' \
  -g 'package.json' -g '*config*' -g '*.json' -g '*.yaml' -g '*.yml' -g '*.js' -g '*.ts' \
  . 2>/dev/null | head -n 300

Length of output: 2183


🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit="e2488d1b3"
parent="$(git rev-parse "${commit}^")"

printf '%s\n' '== src/shared/agent-hook-cwd-attribution.ts at requested commit =='
git show "${commit}:src/shared/agent-hook-cwd-attribution.ts" | sed -n '1,220p'

printf '%s\n' '== Relevant AgentHookServer declarations at requested commit =='
git show "${commit}:src/main/agent-hooks/server.ts" \
  | grep -n -i -C 12 -E 'cwd|worktree|drop|attribution' \
  | head -n 260

printf '%s\n' '== Commits that introduced/changed server cwd attribution code (up to requested commit) =='
git log --format='%H %s' "${commit}" -- src/main/agent-hooks/server.ts | head -n 15
printf '%s\n' '== Diff for server attribution change from the immediate parent =='
git diff "${parent}" "${commit}" -- src/main/agent-hooks/server.ts || true

printf '%s\n' '== Documentation / coverage configuration candidates =='
find . -maxdepth 3 -type f \
  \( -name 'package.json' -o -name '*config*' -o -name '*.json' -o -name '*.yaml' -o -name '*.yml' -o -name '*.js' -o -name '*.ts' \) \
  -not -path '*/node_modules/*' -print0 \
  | xargs -0 grep -n -i -E 'docstring.{0,40}(coverage|check)|coverage.{0,40}docstring|typedoc|documentation coverage' \
  2>/dev/null \
  | head -n 300 || true

printf '%s\n' '== JSDoc-style comments adjacent to functions in the utility file =='
git show "${commit}:src/shared/agent-hook-cwd-attribution.ts" \
  | grep -n -E -C 3 '(^/\*\*|function |export function )'

Length of output: 17688


🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit="e2488d1b3"
server_file="/tmp/agent-hooks-server-${commit}.ts"
git show "${commit}:src/main/agent-hooks/server.ts" > "$server_file"

printf '%s\n' '== hookCwdContradictsWorktree call sites and enclosing method =='
grep -n -C 8 'hookCwdContradictsWorktree' "$server_file" || true
awk '
  /hookCwdContradictsWorktree/ { start=NR-35; if (start<1) start=1; end=NR+45 }
  NR >= start && NR <= end { printf "%d:%s\n", NR, $0 }
' "$server_file"

printf '%s\n' '== JSDoc comments immediately associated with private cwd/worktree methods =='
awk '
  /\/\*\*/ { comment=$0; incomment=1; next }
  incomment { comment=comment "\n" $0; if ($0 ~ /\*\//) incomment=0; next }
  /private .*cwd|private .*Cwd|private .*worktree|private .*Worktree|private .*attribution|private .*Attribution/ {
    printf "LINE %d\n%s\n%s\n\n", NR, comment, $0
  }
' "$server_file"

printf '%s\n' '== Relevant package scripts and tooling configuration =='
find . -maxdepth 3 -type f \
  \( -name 'package.json' -o -name 'biome.json' -o -name 'biome.jsonc' -o -name 'eslint.config.*' -o -name '.eslintrc*' -o -name 'typedoc.json' -o -name 'vitest.config.*' \) \
  -not -path '*/node_modules/*' \
  -print \
  -exec sed -n '/docstring/Ip;/typedoc/Ip;/documentation/Ip;/scripts/,/}/p' {} \;

printf '%s\n' '== Diff that introduced the server cwd attribution implementation =='
git log -S'hookCwdContradictsWorktree' --format='%H %P %s' "${commit}" -- src/main/agent-hooks/server.ts

Length of output: 20850


@kunsanglee Re-evaluated against e2488d1b3. You’re right: all functions in src/shared/agent-hook-cwd-attribution.ts, including internal helpers, now have JSDoc comments, and AgentHookServer.shouldSuppressForeignCwdStatus is documented as well. The prior docstring-coverage finding was stale and is withdrawn.

@kunsanglee

Copy link
Copy Markdown
Contributor Author

@coderabbitai Docstring coverage moved 40% → 50% after e2488d1b3, and 897e9c384 documents the remaining test fixture. Could you list which functions are still counted as undocumented?

Asking because every function and method added to source in this PR now carries one — the five in src/shared/agent-hook-cwd-attribution.ts and shouldSuppressForeignCwdStatus in src/main/agent-hooks/server.ts. If the denominator includes describe/it callbacks and inline test arrow functions, documenting those would work against this repo's guidance in AGENTS.md against comments that restate the obvious, so I'd rather confirm the target before adding anything.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit="897e9c384"
git rev-parse --verify "${commit}^{commit}"

printf '%s\n' "== Repository guidance =="
if [ -f AGENTS.md ]; then
  nl -ba AGENTS.md | sed -n '1,260p'
else
  fd -HI '^AGENTS\.md$' . -x sh -c 'echo "== $1 =="; nl -ba "$1" | sed -n "1,260p"' sh {} \;
fi

printf '%s\n' "== Files changed through requested commit (PR-relevant) =="
git diff --name-status "$(git merge-base "${commit}" HEAD 2>/dev/null || git rev-parse "${commit}^")" "${commit}" -- \
  src/shared/agent-hook-cwd-attribution.ts \
  src/main/agent-hooks/server.ts \
  src/shared/agent-hook-cwd-attribution.test.ts \
  src/main/agent-hooks/server-cwd-attribution.test.ts || true

printf '%s\n' "== Docstring coverage configuration/implementation candidates =="
rg -n -i -C 4 \
  'docstring|jsdoc|documentation.{0,30}coverage|coverage.{0,30}documentation|undocumented' \
  -g 'package.json' -g 'AGENTS.md' -g '*config*' -g '*.json' -g '*.jsonc' -g '*.yaml' -g '*.yml' \
  -g '*.js' -g '*.cjs' -g '*.mjs' -g '*.ts' . | head -n 500

printf '%s\n' "== Package scripts =="
fd -HI '^package\.json$' . -x sh -c 'echo "== $1 =="; jq -r ".scripts // {} | to_entries[] | \"\(.key): \(.value)\"" "$1"' sh {} \;

printf '%s\n' "== Requested commit: source and test function outlines =="
for path in \
  src/shared/agent-hook-cwd-attribution.ts \
  src/main/agent-hooks/server.ts \
  src/shared/agent-hook-cwd-attribution.test.ts \
  src/main/agent-hooks/server-cwd-attribution.test.ts
do
  if git cat-file -e "${commit}:${path}" 2>/dev/null; then
    tmp="/tmp/$(basename "$path")"
    git show "${commit}:${path}" > "$tmp"
    echo "== ${path} =="
    ast-grep outline "$tmp" --items all || true
  fi
done

printf '%s\n' "== JSDoc/function adjacency in PR files at requested commit =="
for path in \
  src/shared/agent-hook-cwd-attribution.ts \
  src/main/agent-hooks/server.ts \
  src/shared/agent-hook-cwd-attribution.test.ts \
  src/main/agent-hooks/server-cwd-attribution.test.ts
do
  if git cat-file -e "${commit}:${path}" 2>/dev/null; then
    echo "== ${path} =="
    git show "${commit}:${path}" | nl -ba | rg -n -C 3 '\/\*\*|(^|[[:space:]])(export )?(async )?function\b|=>|shouldSuppressForeignCwdStatus'
  fi
done

Length of output: 2634


🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit="897e9c384"

numbered() {
  awk '{ printf "%6d\t%s\n", NR, $0 }' "$1"
}

printf '%s\n' "== Repository guidance =="
if [ -f AGENTS.md ]; then
  numbered AGENTS.md | sed -n '1,300p'
else
  fd -HI '^AGENTS\.md$' . -x sh -c 'echo "== $1 =="; awk "{ printf \"%6d\\t%s\\n\", NR, \$0 }" "$1" | sed -n "1,300p"' sh {} \;
fi

printf '%s\n' "== Documentation-check candidates =="
rg -n -i -C 4 \
  'docstring|jsdoc|documentation.{0,30}coverage|coverage.{0,30}documentation|undocumented' \
  -g 'package.json' -g 'AGENTS.md' -g '*config*' -g '*.json' -g '*.jsonc' -g '*.yaml' -g '*.yml' \
  -g '*.js' -g '*.cjs' -g '*.mjs' -g '*.ts' . | head -n 600 || true

printf '%s\n' "== Package scripts =="
fd -HI '^package\.json$' . -x sh -c \
  'echo "== $1 =="; grep -n -i -C 2 -E "\"(doc|string|jsdoc|test|lint|check)'"'"'" "$1" || true' sh {} \;

printf '%s\n' "== PR files at requested commit: AST outlines =="
for path in \
  src/shared/agent-hook-cwd-attribution.ts \
  src/main/agent-hooks/server.ts \
  src/shared/agent-hook-cwd-attribution.test.ts \
  src/main/agent-hooks/server-cwd-attribution.test.ts
do
  if git cat-file -e "${commit}:${path}" 2>/dev/null; then
    tmp="/tmp/$(basename "$path")"
    git show "${commit}:${path}" > "$tmp"
    echo "== ${path} =="
    ast-grep outline "$tmp" --items all || true
  fi
done

printf '%s\n' "== JSDoc and candidate declarations in PR files at requested commit =="
for path in \
  src/shared/agent-hook-cwd-attribution.ts \
  src/main/agent-hooks/server.ts \
  src/shared/agent-hook-cwd-attribution.test.ts \
  src/main/agent-hooks/server-cwd-attribution.test.ts
do
  if git cat-file -e "${commit}:${path}" 2>/dev/null; then
    echo "== ${path} =="
    git show "${commit}:${path}" \
      | awk '{ printf "%6d\t%s\n", NR, $0 }' \
      | rg -n -C 3 '\/\*\*|(^|[[:space:]])(export )?(async )?function\b|=>|shouldSuppressForeignCwdStatus' \
      || true
  fi
done

Length of output: 43859


🏁 Script executed:

#!/bin/bash
set -euo pipefail

tip="897e9c384"
base="$(git merge-base "$tip" origin/main 2>/dev/null || git merge-base "$tip" main 2>/dev/null || git rev-parse "${tip}^")"

printf '== Range ==\nbase=%s\ntip=%s\n' "$base" "$tip"
git log --format='%h %s' "${base}..${tip}"

printf '\n== Changed TypeScript files ==\n'
git diff --name-status "$base" "$tip" -- '*.ts'

printf '\n== Added lines that introduce or invoke callable expressions ==\n'
git diff --unified=4 "$base" "$tip" -- '*.ts' \
  | awk '
      /^\+\+\+ / { file=$2; sub("^b/", "", file); next }
      /^\+/ && !/^\+\+\+/ &&
      ($0 ~ /(function[[:space:]]+[A-Za-z_$]|[A-Za-z_$][A-Za-z0-9_$]*[[:space:]]*[:=][[:space:]]*(async[[:space:]]*)?\([^)]*\)[[:space:]]*=>|=>|describe\(|it\(|beforeEach\(|afterEach\(|vi\.mock\()/) {
        print "FILE " file "\n" $0
      }' \
  | sed -n '1,600p'

printf '\n== Full PR diffs for the new attribution source/tests ==\n'
git diff --unified=6 "$base" "$tip" -- \
  src/shared/agent-hook-cwd-attribution.ts \
  src/shared/agent-hook-cwd-attribution.test.ts \
  src/main/agent-hooks/server-cwd-attribution.test.ts \
  src/main/agent-hooks/server.ts \
  src/relay/agent-hook-server.test.ts \
  src/relay/agent-hook-server.ts \
  src/shared/agent-hook-listener.ts \
  src/shared/agent-hook-relay.ts \
  src/shared/telemetry-events.ts | sed -n '1,2200p'

printf '\n== Function-like nodes in newly added test files at tip ==\n'
for path in src/shared/agent-hook-cwd-attribution.test.ts src/main/agent-hooks/server-cwd-attribution.test.ts; do
  echo "== $path =="
  git show "${tip}:${path}" > "/tmp/$(basename "$path")"
  ast-grep run --lang ts --pattern '$FUNC($$$ARGS) => { $$$BODY }' "/tmp/$(basename "$path")" || true
  ast-grep run --lang ts --pattern 'function $FUNC($$$ARGS) { $$$BODY }' "/tmp/$(basename "$path")" || true
done

Length of output: 31841


@kunsanglee I found no repository-local docstring-coverage checker/configuration, so I cannot verify the external metric’s exact denominator. However, at 897e9c384, all named functions and methods introduced by this PR are documented:

  • readHookPayloadCwd
  • collapseDotSegments
  • normalizeComparablePath
  • isSameOrInside
  • hookCwdContradictsWorktree
  • AgentHookServer.shouldSuppressForeignCwdStatus
  • buildBody (the test fixture added in 897e9c384)

The only added callables without JSDoc are test/mocking callbacks:

  • src/shared/agent-hook-cwd-attribution.test.ts
    • 2 × describe(...) callbacks
    • 10 × it(...) callbacks
  • src/main/agent-hooks/server-cwd-attribution.test.ts
    • vi.hoisted(...) callback
    • 2 × vi.mock(...) factory callbacks
    • beforeEach(...) callback
    • 1 × describe(...) callback
    • 3 × it(...) callbacks
    • local postHook arrow function

That is 21 undocumented callback expressions total. There are no remaining undocumented production functions in the PR.

So, if the metric still reports only 50%, it is either including these test callbacks (and possibly nested callback expressions) or is stale. I agree that adding boilerplate JSDoc to describe/it/mock callbacks would conflict with AGENTS.md’s requirement for concise, non-obvious “why” comments. I would not add those comments solely to satisfy this metric.

🐇🔎


✏️ Learnings added
Learnt from: kunsanglee
URL: https://github.com/stablyai/orca/pull/11094

Timestamp: 2026-07-28T06:00:42.512Z
Learning: For this TypeScript repository, `AGENTS.md` requires comments to be concise and explain why rather than obvious implementation details. In particular, do not request boilerplate JSDoc for Vitest `describe`/`it` callbacks, mock factories, hooks, or similarly self-describing inline test callbacks merely to raise docstring-coverage metrics.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@kunsanglee

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ee837220-5d23-43c6-8be7-406c11e7d713

📥 Commits

Reviewing files that changed from the base of the PR and between 3f5098a and 897e9c3.

📒 Files selected for processing (9)
  • src/main/agent-hooks/server-cwd-attribution.test.ts
  • src/main/agent-hooks/server.ts
  • src/relay/agent-hook-server.test.ts
  • src/relay/agent-hook-server.ts
  • src/shared/agent-hook-cwd-attribution.test.ts
  • src/shared/agent-hook-cwd-attribution.ts
  • src/shared/agent-hook-listener.ts
  • src/shared/agent-hook-relay.ts
  • src/shared/telemetry-events.ts

Comment thread src/shared/agent-hook-cwd-attribution.ts Outdated
…ation

`C:\..\..` collapsed to a slashless `c:`, which no longer matched the
drive-rooted test. A worktree or cwd in that shape would then be compared
against a POSIX path as if both used the same notation, and the mixed-notation
bail-out that keeps unclear pairs attributed could not fire — the one way this
guard could drop a legitimate status row. Terminate collapsed drive roots with
their slash and let the containment check accept any root-terminated prefix.
@kunsanglee

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

Last full round covered up to 897e9c384; 683e17983 has only been looked at through the inline thread. Since the previous two rounds each found a defect in this same lexical path handling, please re-examine the whole diff at the current tip.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

@kunsanglee Understood. I’ll re-review the complete diff at the current tip, with particular attention to the lexical path normalization and containment logic introduced or changed in 683e17983.

🐇🔍

✅ Action performed

Full review finished.

The guard hand-rolled path normalization that `cross-platform-path.ts`
already provides. Two of this branch's earlier commits fixed bugs the
reuse never had, and the copy missed NFC folding, so a non-ASCII
workspace path dropped every status from that workspace (stablyai#10832).

Delegate to `resolveRuntimePath` and `isPathInsideOrEqual`, keep the
notation bails as guard policy, and drop `readHookPayloadCwd` in favour
of `readBoundedString` — which also restores the 4096 cwd bound the
hand-rolled reader lost. Stop persisting `sourceCwd`, a transport-only
field the hydrate whitelist drops.
@kunsanglee

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

The path-comparison logic was replaced wholesale in 3989d46. The hand-rolled normalizer is gone in favour of resolveRuntimePath / isPathInsideOrEqual from src/shared/cross-platform-path.ts, which is the repo's canonical path layer. Worth re-checking the boundary cases from scratch rather than diffing against the earlier rounds.

Notable behaviour changes since the last review:

  • NFC folding now applies, fixing a real regression where a non-ASCII workspace path (NFD from the folder picker vs NFC from the agent) read as disjoint and dropped every status from that workspace (fix(daemon): bound NDJSON and binary-frame ingress #10832). Regression test added.
  • The guard now bails on any UNC path on either side, because UNC aliases the other notations (\\wsl$\Ubuntu\mnt\c\x is C:\x, and \\server\share is a mapped drive), so it can never disprove the other side.
  • sourceCwd is no longer persisted to last-status.json; it is transport-only and the hydrate whitelist drops it, so persisting it made hydration lossy and defeated the write dedupe.
  • readHookPayloadCwd was dropped in favour of the existing readBoundedString, which restores a 4096-char bound the hand-rolled reader had lost.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

@kunsanglee Understood. I’ll re-review the full diff at the current tip from scratch, concentrating on the canonical cross-platform-path.ts behavior and the NFC, UNC, persistence/dedupe, and bounded-cwd boundary cases you called out.


✏️ Learnings added
Learnt from: kunsanglee
URL: https://github.com/stablyai/orca/pull/11094

Timestamp: 2026-07-28T08:01:08.136Z
Learning: For PR `#11094`, path comparisons for agent-hook cwd attribution must use the repository’s canonical `src/shared/cross-platform-path.ts` helpers (`resolveRuntimePath` and `isPathInsideOrEqual`), not a bespoke lexical normalizer. The attribution guard must normalize Unicode via NFC, conservatively decline comparison when either side is UNC because UNC aliases other path forms, keep `sourceCwd` transport-only rather than persisting it in `last-status.json`, and use `readBoundedString` when extracting cwd so the established 4096-character bound is enforced.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Full review finished.

The cwd guard emitted telemetry on every dropped hook. A daemon that
mis-attributes one pane fires on every hook of every session it hosts,
and the per-session telemetry ceiling is shared by all events and never
refills — so the guard could silence the rest of the session's
telemetry within the hour. Move the track call inside the warn-once
gate that already existed beside it; `reason` carries no pane, so
repeat events add no signal.
@kunsanglee

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

Re-requesting against the current head 36cd6e28c. The previous request was made at 3989d46b2, and a second commit landed five minutes later that the earlier pass could not have seen.

36cd6e28c moves the agent_hook_unattributed telemetry call inside the warn-once gate that already sat beside it. The guard emitted one event per dropped hook, but a daemon that mis-attributes a pane fires on every hook of every session it hosts, and the per-session ceiling in src/main/telemetry/burst-cap.ts is shared across all event names and never refills — so the guard could exhaust it and silence the rest of the session's telemetry. The reason enum carries no pane, so repeat events added no signal.

Worth checking in particular: whether once-per-runtime is the right granularity given stop() resets the flag, and whether the new test in src/main/agent-hooks/server-cwd-attribution.test.ts pins the invariant at the right level.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026 •

Copy link
Copy Markdown
Contributor

@kunsanglee Understood. I’ll re-review the complete PR at 36cd6e28c, specifically validating the warn-once telemetry scope and reset lifecycle against the shared, non-refilling session burst cap, and checking that the attribution test asserts the intended once-per-server-runtime invariant rather than only an incidental call count.


✏️ Learnings added
Learnt from: kunsanglee
URL: https://github.com/stablyai/orca/pull/11094

Timestamp: 2026-07-28T09:06:59.901Z
Learning: For PR `#11094`, `AgentHookServer` must emit `agent_hook_unattributed` with `reason: "cwd_worktree_mismatch"` at most once per running server instance: repeated dropped hooks add no pane-identifying signal and can exhaust the shared, non-refilling per-session telemetry burst cap in `src/main/telemetry/burst-cap.ts`. The warn-once state may reset when `AgentHookServer.stop()` ends that server runtime; tests in `src/main/agent-hooks/server-cwd-attribution.test.ts` should pin this lifecycle-level invariant.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/main/agent-hooks/server-cwd-attribution.test.ts (1)

97-118: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover telemetry reset across stop()/start().

This verifies the per-runtime cap but not the required reset. Add a mismatch → stop() → start() → mismatch case and expect two telemetry calls.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e6c77993-5d6f-40a7-aa71-cff701f9bd23

📥 Commits

Reviewing files that changed from the base of the PR and between 3f5098a and 36cd6e2.

📒 Files selected for processing (10)
  • src/main/agent-hooks/server-cwd-attribution.test.ts
  • src/main/agent-hooks/server.test.ts
  • src/main/agent-hooks/server.ts
  • src/relay/agent-hook-server.test.ts
  • src/relay/agent-hook-server.ts
  • src/shared/agent-hook-cwd-attribution.test.ts
  • src/shared/agent-hook-cwd-attribution.ts
  • src/shared/agent-hook-listener.ts
  • src/shared/agent-hook-relay.ts
  • src/shared/telemetry-events.ts

The once-per-runtime cap was covered but the `stop()` reset that lifts it
was not, so removing that line failed no test. Add a restart case, and
fold the thrice-repeated relay envelope into one local builder.
@kunsanglee

Copy link
Copy Markdown
Contributor Author

Addressed the nitpick in ddbced31d.

The per-runtime cap was covered but the stop() reset that lifts it was not — I confirmed that by deleting the reset line and watching the suite stay green. Added a restart case that fails without it, and folded the relay envelope that was by then repeated three times in the file into one local builder.

I used stop() alone rather than stop() → start(): the reset lives in stop() and ingestRemote does not need the listener bound, so adding start() would make the test async without exercising anything more.

Conflicts in src/main/agent-hooks/server.ts — main's launch-token authority
layer landed on the same four sites as this branch's cwd guard. Kept both:
the cwd guard runs first on each ingest path so a disproven hook never records
an authority observation, and serializeStatusFile now strips sourceCwd
alongside promptInteractionKey before hashing launchToken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnMabSU7QxCeHwtropbTUq
@greptile-apps

greptile-apps Bot commented Jul 30, 2026 •

Copy link
Copy Markdown

Greptile Summary

This PR fixes mis-attributed agent status rows caused by shared daemon processes inheriting the pane identity (ORCA_PANE_KEY, ORCA_WORKTREE_ID) of the pane that first spawned them, causing every later session's hooks to land on the wrong workspace sidebar card. The fix adds a server-side cwd attribution guard that cross-checks the session cwd the agent reports in its own payload against the worktree path encoded in the inherited worktreeId, refusing the event when the two are clearly disjoint.

  • New src/shared/agent-hook-cwd-attribution.ts exports hookCwdContradictsWorktree (pure string containment, used at the Orca ingestRemote boundary where remote paths can't be resolved locally) and hookCwdContradictsWorktreeAfterLocalResolve (adds existsSync/realpathSync.native to clear false positives from symlink aliasing, used at local HTTP ingress on both the relay and Orca). Both delegate to the existing cross-platform-path.ts utilities and bail out for ambiguous cases (UNC paths, mixed WSL notation, relative paths, one-side-unresolvable paths).
  • Guard placement is pre-normalization in both the relay and Orca HTTP handlers to prevent a foreign event from seeding per-pane listener state (subagent rosters, lead-turn records) before being dropped; the relay's applyEvent funnel additionally clears sourceCwd on events that survived via symlink aliasing so Orca's downstream raw re-check does not re-drop proven-legitimate rows.
  • sourceCwd is transport-only: excluded from last-status.json persistence and the hydrate whitelist; telemetry emits cwd_worktree_mismatch at most once per runtime to avoid exhausting the per-session telemetry ceiling.

Confidence Score: 5/5

  • Safe to merge. The guard is conservative by design — any ambiguous comparison passes through rather than dropping — and the new filesystem calls only execute on the exceptional path where string comparison has already found a contradiction.
  • The change is self-contained: new logic lives in a well-tested new module, touches two existing HTTP handlers at clearly identified entry points, and adds no new state beyond two bounded sets and one boolean per server instance. All noted edge cases (symlinks, UNC, WSL, firmlinks, dot segments, NFC/NFD, folder-workspace instance ids) have corresponding tests confirmed to fail when the production change is reverted. The persistence exclusion and telemetry cap are each pinned by dedicated tests. No renderer, auth, or persistence schema is modified.
  • No files require special attention. The core logic in src/shared/agent-hook-cwd-attribution.ts and its placement in src/relay/agent-hook-server.ts and src/main/agent-hooks/server.ts are the most consequential, and both are well-covered by the new integration tests.

Important Files Changed

Filename Overview
src/shared/agent-hook-cwd-attribution.ts New module implementing the core cwd-vs-worktree contradiction logic. Two exported functions: hookCwdContradictsWorktree (pure string, no I/O) and hookCwdContradictsWorktreeAfterLocalResolve (adds symlink resolution via existsSync/realpathSync.native, plus macOS firmlink folding). Edge cases are comprehensively handled: UNC paths, mixed POSIX/Windows notation, WSL, relative paths, dot-segment traversal, NFC normalization, and folder-workspace instance suffixes. Design is conservative — unclear comparisons return false (keep).
src/main/agent-hooks/server.ts Adds shouldSuppressForeignCwdStatus to AgentHookServer. The guard fires in two places on the local HTTP path: before normalization (preventing listener state pollution) and again after normalization as drift armor. For ingestRemote, only the raw string check runs since Orca cannot resolve the relay host's paths. sourceCwd is excluded from last-status.json persistence and the PersistedAgentHookEventPayload type. Telemetry is capped to once-per-runtime via a single boolean reset in stop(). Warning set is bounded to FOREIGN_CWD_WARN_PANE_CAP and pre-sliced before retention.
src/relay/agent-hook-server.ts Adds pre-normalization refusal at the relay HTTP boundary using hookCwdContradictsWorktreeAfterLocalResolve. The applyEvent funnel clears sourceCwd when the raw check detects a contradiction that was already judged keep-worthy by the local-resolve check (symlink aliasing) — this prevents Orca's downstream raw guard from re-dropping a proven-legitimate row or poisoning the replay cache. Warning is per-pane once, bounded to FOREIGN_CWD_WARN_PANE_CAP, and cleared on stop().
src/shared/agent-hook-listener.ts Adds readHookBodyCwdAttribution (pre-normalization read of paneKey, worktreeId, sourceCwd) and populates sourceCwd in normalizeHookPayload output. Renames GROK_SESSION_CWD_MAX_LENGTH to HOOK_CWD_MAX_LENGTH and exports it; unifies HOOK_CWD_KEYS constant used by both the attribution reader and the Grok session metadata reader. The sourceCwd field is documented as transport-only on AgentHookEventPayload with a comment explaining the guard invariant.
src/relay/agent-hook-envelope-build.ts One-line addition: sourceCwd is forwarded in the built relay envelope. The comment correctly explains that applyEvent has already sanitized the value before this function is reached, so both live and replay legs are safe.
src/main/ssh/ssh-relay-session.ts Passes sourceCwd from the relay envelope through to ingestRemote with a loose type check (string or undefined). Length bounding is enforced at the ingestRemote trust boundary in server.ts, not here — consistent with how other relay-forwarded fields like providerSession are handled.
src/shared/telemetry-events.ts Adds 'cwd_worktree_mismatch' to the agentHookUnattributed reason enum. The schema remains strict (closed enum), so no identifying string (path, repo name) enters telemetry — consistent with existing constraints on this event.

Sequence Diagram

sequenceDiagram
    participant Agent as Agent CLI (shared daemon)
    participant RelayHTTP as Relay HTTP Server
    participant OrcaHTTP as Orca Hook HTTP Server
    participant ingestRemote as Orca ingestRemote
    participant Status as Last-Status Store

    Note over Agent: Inherits pane env from first session<br/>(ORCA_PANE_KEY, ORCA_WORKTREE_ID)

    Agent->>RelayHTTP: "POST /hook/claude {paneKey, worktreeId, payload.cwd}"
    RelayHTTP->>RelayHTTP: readHookBodyCwdAttribution(body)
    RelayHTTP->>RelayHTTP: "hookCwdContradictsWorktreeAfterLocalResolve()<br/>(existsSync + realpathSync.native)"

    alt cwd disproves worktree (disjoint paths, no symlink alias)
        RelayHTTP-->>Agent: 204 (dropped)
        RelayHTTP->>RelayHTTP: warnForeignCwdOnce() → stderr
    else cwd consistent (or symlink alias resolved)
        RelayHTTP->>RelayHTTP: "normalizeHookPayload()<br/>applyEvent(): clears sourceCwd if<br/>raw contradiction survived local resolve"
        RelayHTTP->>ingestRemote: forward envelope + sourceCwd
        ingestRemote->>ingestRemote: re-validates sourceCwd length
        ingestRemote->>ingestRemote: "shouldSuppressForeignCwdStatus()<br/>hookCwdContradictsWorktree (no fs access)"
        alt contradicts (remote paths, raw comparison)
            ingestRemote-->>Agent: (dropped)
            ingestRemote->>ingestRemote: "track('agent_hook_unattributed')<br/>once per runtime"
        else consistent
            ingestRemote->>Status: "applyNormalizedStatus()<br/>persists without sourceCwd"
        end
    end

    Agent->>OrcaHTTP: POST /hook/claude (local HTTP path)
    OrcaHTTP->>OrcaHTTP: "readHookBodyCwdAttribution(body)<br/>shouldSuppressForeignCwdStatus(resolve=true)"
    alt drops
        OrcaHTTP-->>Agent: 204 (dropped, telemetry emitted)
    else keeps
        OrcaHTTP->>OrcaHTTP: "normalizeLocalHookPayload()<br/>drift-armor re-check"
        OrcaHTTP->>Status: "applyNormalizedStatus()<br/>persists without sourceCwd"
    end
Loading

Reviews (5): Last reviewed commit: "Merge upstream/main into fix/agent-hook-..." | Re-trigger Greptile

The mux-notification handler rebuilds the ingestRemote envelope field by
field and omitted sourceCwd, leaving the cwd attribution guard inert for
every SSH event (live and replay).
…olving symlink aliases

- Re-judge a would-drop on locally resolved paths so one directory spelled
  two ways (macOS /tmp vs /private/tmp, symlinked roots) is not read as a
  foreign session; the relay strips sourceCwd it proved alias-clean so
  Orca's raw re-guard cannot re-drop the row.
- Run the guard before normalizeHookPayload on both local and relay HTTP
  ingest so a foreign daemon-hosted session cannot seed per-pane subagent
  rosters or the replay cache that later legitimate events re-emit.
- Warn per pane (bounded) instead of once per runtime; telemetry stays
  latched. Exclude sourceCwd from the persisted payload type.
brennanb2025 and others added 7 commits July 30, 2026 09:00
…oint

- The assistant-message retry and codex subagent poll re-normalize the raw
  body and re-attached the contradicting cwd, so main's raw re-guard dropped
  enrichment updates and poisoned the replay cache for symlink-aliased remote
  workspaces; move the strip into applyEvent, which every cache/forward leg
  funnels through.
- Keep, not drop, when a local path exists but cannot be resolved, and fold
  the macOS data-volume firmlink prefix — both only rescue rows.
- Bound warn-set keys and logged values on both hook servers.
- Pin the retry-leg strip, the relay guard-before-normalize ordering, and
  cwd-side symlink resolution with discriminating tests.
…ion is stat-able locally

A recorded worktree spelling that cannot be stat-ed on its own host (renamed,
deleted, whitespace-mangled by the transit trim) cannot prove a foreign
session; both-nonexistent still drops so foreign-host verdicts stand. Pins
the exists-but-unresolvable keep with deterministic fs stubs.
One-side-stat-able and unresolvable keeps also survive the refusal now;
the surviving raw contradiction is host-judged keep-worthy, not always
symlink aliasing.
Five files conflicted, all where upstream's claudeRunningNonAgentTask work and
this branch's sourceCwd work touch the same lists and literals. Both sides kept
everywhere; the only non-additive resolution is the local HTTP ingest, where
upstream replaced the bare normalizeHookPayload call with
normalizeLocalHookPayload returning { event, onAccepted }. The pre-normalization
cwd refusal and the post-normalization drift guard were re-applied on top of the
new shape, so the guard still runs before listener state is seeded.

In src/relay/agent-hook-server.test.ts the two sides' new tests overlapped as one
block; upstream's background-work test and this branch's four cwd tests are both
present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HL8CxXZYghUKJEQpYhKeGg
# Conflicts:
#	src/main/agent-hooks/server.ts
# Conflicts:
#	src/main/agent-hooks/server.ts
# Conflicts:
#	src/relay/agent-hook-server.ts
@kunsanglee

Copy link
Copy Markdown
Contributor Author

Conflicts resolved against current main in cfd2c34. Two moves, neither of them a change to what this PR does.

src/main/agent-hooks/server.test.ts was split across the server-*.test.ts files in #14728, and this branch's only edit to it was one last-status case. It moved to server-last-status-write.test.ts verbatim — the cwd field on the posted body and the sourceCwd assertion that goes with it.

src/shared/worktree-id.ts became src/shared/worktree/id.ts in #14437. The two files this branch adds import WORKTREE_ID_SEPARATOR, splitWorktreeIdForFilesystem and FOLDER_WORKSPACE_INSTANCE_SEPARATOR from it; git resolved everything else on its own but not these, since the path no longer exists.

Verified after installing against this branch's lockfile: tsc clean on all three projects, oxlint clean, and src/main plus src/shared at 2551 files / 26541 tests passing. server-last-status-write.test.ts was run on its own to confirm the moved case executes rather than being filtered out.

Still sequenced behind #14615 as @brennanb2025 described — this is only to keep the branch mergeable while that lands.

Two things main moved under this branch.

`src/main/agent-hooks/server.test.ts` was split across many `server-*.test.ts`
files (stablyai#14728). This branch's only edit to it was one last-status test, which now
lives in `server-last-status-write.test.ts` — moved there verbatim, including the
`cwd` field and the `sourceCwd` assertion.

`src/shared/worktree-id.ts` became `src/shared/worktree/id.ts` (stablyai#14437). The two
files this branch adds import three symbols from it and were the only references
the merge did not resolve; nothing else about them changed.

Verified in this worktree after installing against the branch lockfile: tsc clean
on all three projects, oxlint clean, and `src/main` plus `src/shared` at 2551
files / 26541 tests passing.
@kunsanglee
kunsanglee force-pushed the fix/agent-hook-pane-attribution-guard branch from cfd2c34 to fd9fe14 Compare August 15, 2026 10:22
One conflicting file, src/relay/agent-hook-server.ts, and the conflict is
entirely stablyai#14725's split of that file: main moved the endpoint coordinates,
the envelope builder and the assistant-message/codex retry scheduling out
into three modules, so every constant, import and method this branch had
added sat next to something that had left.

Resolved by keeping only what the merged file still reaches: the cwd
attribution imports and FOREIGN_CWD_WARN_PANE_CAP stay, the retry and
endpoint constants go with the code that moved, and the class keeps both
sides' fields.

The one resolution git could not have reached on its own is in a file it
reported as clean. This branch added `sourceCwd` to the envelope inside the
private `forwardEvent`; stablyai#14725 extracted that method into
buildRelayHookEnvelope in agent-hook-envelope-build.ts, before the field
existed, and repointed both the live and replay call sites at it. Taking
either side alone drops the field silently — the method is dead with no
callers, and the builder never carried it. `sourceCwd` now lives in the
builder, which is what both legs go through.

Verified by removing that one line again: 'forwards a parsed Claude
UserPromptSubmit POST as a normalized envelope' fails with
`expected undefined to be '/srv/app'`. relay, shared agent-hook and
main/agent-hooks suites: 197 files, 2010 passing. tsc clean on the node
project, oxlint and oxfmt clean, 85 reliability gates, ratchet at 190.
Three additions landing on the same lines, none of them touching what this
branch decides.

`observation` joined `sourceCwd` on the never-persisted list, and its
destructure now reads main's `enrichedPayload` so the child-only boundary flag
survives a write. The retired-pane fence helpers sit beside the cwd guard rather
than in place of it.

The guard's three call sites are unchanged: relay ingest, the pre-validation
HTTP check, and the normalized local event.
@Jinwoo-H

Copy link
Copy Markdown
Contributor

Closing this PR as superseded by merged PR #14615, which now owns the same agent-hook pane/session cwd-attribution behavior on current main. We will not merge this older branch. The useful diagnosis and implementation work are retained in our owner follow-up, with full credit to @kunsanglee. No action is needed from you. Thank you for the contribution.

@Jinwoo-H Jinwoo-H closed this Aug 31, 2026
@Jinwoo-H Jinwoo-H reopened this Aug 31, 2026
@Jinwoo-H

Copy link
Copy Markdown
Contributor

Correction to my previous comment: I closed this PR prematurely after treating merged PR #14615 as a complete replacement. The two changes are complementary: #14615 handles spawn-time session pinning, while this PR provides a defensive cwd-versus-pane attribution guard for sessions that cannot be pinned. I have reopened this PR and restored it to our owner queue. We will evaluate and, if warranted, reimplement/port the guard ourselves on current main; no action is requested from you. Full credit to @kunsanglee is preserved. Apologies for the incorrect closure.

@Jinwoo-H

Copy link
Copy Markdown
Contributor

Closing this submitted branch and routing the bug to canonical owner issue #17578. We will carry the work ourselves on current main and will not request contributor follow-up. Full contributor credit is preserved for @kunsanglee. No action is requested from you. Thank you.

@Jinwoo-H Jinwoo-H closed this Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants