From fa15732931fc14a20dafa61a7a5bd270149f23d7 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 17:42:23 +0000 Subject: [PATCH 1/7] fix(session): harden move_session across current dev Merge PR #4688 onto current dev, preserve current-dev autoresearch changes, isolate non-owner sessions from process-global cache resets, and retain MCP callbacks when the owned manager is rebound. Lore-id: pr4688-current-dev-fix-forward Constraint: no release, tag, or publish mutation Confidence: high Scope-risk: wide Reversibility: revertible Tested: bun --cwd=packages/coding-agent run check; bun test packages/coding-agent/test/move-session-tool.test.ts; bun test packages/coding-agent/test/tools/tool-catalog.test.ts packages/coding-agent/test/tool-discovery/initial-tools.test.ts packages/coding-agent/src/tools/descriptors.test.ts Not-tested: Windows and remote CI lanes --- packages/coding-agent/CHANGELOG.md | 9 +- .../generate-sdk-operation-inventory.ts | 8 + .../scripts/generate-tool-catalog.ts | 1 + .../modes/controllers/command-controller.ts | 16 +- .../src/prompts/tools/move-session.md | 15 + .../src/runtime/optional-runtime-services.ts | 5 +- .../src/runtime/workspace-tree-service.ts | 14 +- .../operation-inventory.generated.json | 44 + packages/coding-agent/src/sdk/session.ts | 396 +++++++- .../coding-agent/src/session/agent-session.ts | 154 ++- .../src/session/session-manager.ts | 211 +++- .../src/tools/descriptors.test.ts | 2 + .../coding-agent/src/tools/descriptors.ts | 8 + packages/coding-agent/src/tools/index.ts | 12 + .../coding-agent/src/tools/move-session.ts | 107 ++ packages/coding-agent/src/tools/renderers.ts | 2 + .../src/tools/tool-catalog.generated.ts | 26 +- .../test/move-session-tool.test.ts | 911 ++++++++++++++++++ .../test/tool-discovery/initial-tools.test.ts | 10 + .../test/tools/tool-catalog.test.ts | 6 + .../test/windows-avx2-probe.windows.test.ts | 1 + packages/utils/src/dirs.ts | 5 +- scripts/ci-dev-affected.ts | 2 + 23 files changed, 1889 insertions(+), 76 deletions(-) create mode 100644 packages/coding-agent/src/prompts/tools/move-session.md create mode 100644 packages/coding-agent/src/tools/move-session.ts create mode 100644 packages/coding-agent/test/move-session-tool.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a513ed6010..2c9d31e47c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,7 +21,11 @@ - Coordinator stop and idle-reap now initialize canonical namespace state before reading durable deletion recovery. Fresh or upgraded projection-only sessions were misreported as `state_corrupt` before the broker close was attempted, and completed deletion receipts were excluded from the idempotent missing-session lookup; both paths now preserve strict malformed-state rejection while allowing safe cleanup and replay. - Fixed provider safety-stop classification being lost before session persistence (#4777). The managed provider-envelope boundary now preserves only the allowlisted `provider_safety_stop` kind, typed safety stops remain terminal even with transport facts on a multi-model fallback chain, and the regression e2e test is selected both by the focused affected-path route and exactly one normal coding-agent shard. - Retained-publication acquisition diagnostics now name the stage that actually refused instead of always claiming a failed open, and the native errno vocabulary is closed over the actionable `open(2)` refusal set (#4764). `nativeRetainedObstruction()` rendered every unmatched reason as "could not be opened", so a `broker.json` FIFO — which opens fine `O_RDWR | O_NOFOLLOW | O_NONBLOCK` and then refuses `read_to_end` with `WouldBlock` — was reported as an open failure that had in fact succeeded, `clone`/`metadata` refusals of already-open descriptors were reported as opens, and `unsupported-platform` read as an open error. The structured native reason now drives the prose: `errno-`/`io-` render as open refusals, `read-` as a read failure, `clone-`/`metadata[-kind]` as inspection failures of an already-open object, `unsupported-platform` as unimplemented authority, and any future reason falls back to a stable "withheld publication authority (reason)" sentence instead of misnaming the stage; the structured reason is preferred whenever the native message parses, so the observed-state fallback is never entered for a stage-named refusal. The Rust `errno_name()` vocabulary adds `EAGAIN`, `EMFILE`, `ENAMETOOLONG`, `ENFILE`, `ENOMEM`, `EOVERFLOW`, and `EPERM` alongside the existing six — descriptor exhaustion and LSM/seccomp denial no longer collapse to `UNKNOWN` — while every unlisted errno still renders as `UNKNOWN` and no errno number or OS string ever leaves the native layer. -- The `errno_name()` closed-vocabulary fixtures introduced with the retained-publication diagnostic (#4764) are now gated `#[cfg(all(test, unix))]` like every sibling test module in `path_identity.rs`. The module was file-level `#[cfg(test)]` while importing `super::publication::errno_name`, an item that only exists in the unix `mod publication`; on Windows the `#[cfg(not(unix))]` sibling has no such item, so `cargo test -p pi-natives` failed to compile on a Windows dev machine (CI was unaffected — its Windows jobs build without `--tests`). Unix coverage is unchanged: both fixtures still run. +- `move_session` no longer lets a cwd transition commit inside an in-flight tool's execution (#4629). The previous fence re-checked the cwd generation and then yielded, so a move that started inside a tool's first `await` retargeted work already admitted against the launcher root: bash computed `commandCwd` from the live session cwd only after `expandInternalUrls`, running a root-A command in root B. Relative-path tools now hold a shared read lease on the session cwd for their whole execution (`SessionManager.runWithCwdReadLease`), and writers drain outstanding leases before committing, so the cwd observed at admission stays authoritative until the tool finishes. Announced writers also block newly arriving readers, so a stream of tool calls cannot starve a queued move, and async bash jobs stay bound to the cwd they were admitted for. +- `move_session` re-roots the state the model is shown at the new cwd (#4629). The volatile per-turn message reported the post-move cwd while attaching the launch-bound workspace tree, and subagents launched at the live cwd inherited startup `contextFiles`, `skills`, and `workspaceTree` — pairing the new cwd with the retired root's files and project instructions, which drives wrong path selection rather than merely stale display. Project context files and skills are now re-discovered after a committed move, the workspace-tree service resolves the live cwd per scan instead of capturing the launch root, the cached tree is retired so the next turn re-scans, and the system prompt is rebuilt from the live cwd. +- `move_session` now completes every fallible step before committing (#4629). Process chdir, capability reset, LSP shutdown, and plugin/MCP/Python authority rebinding previously ran *after* `moveTo` committed and the one-move budget was consumed, with rebind failures swallowed — so the tool could reject after the session had already moved, or succeed holding launch-root tool authority. Those steps now run ahead of the commit and restore launch-root authority on failure, so a rejected rescope leaves the session exactly where it was and does not consume the one-move budget. +- `move_session` now verifies process-cwd identity against the pinned directory handle (#4629). The no-follow handle was only ever `stat()`ed while the commit and `setProjectDir` resolved the target *name*, so a replacement after the last comparison escaped the validated descendant — the confinement the tool exists to enforce. Node exposes no `fchdir`, so the post-`chdir` cwd's inode identity is now compared against the pinned handle and the chdir is rolled back on mismatch. +- Process-cwd authority is now an explicit claim rather than an inference (#4629). `move_session` decided it owned the process cwd when `process.cwd() === session.cwd`, which two sessions launched at the same root both satisfy, so rescoping one called process-global `setProjectDir`, `resetCapabilities`, `shutdownAllLspClients`, and plugin-cache clearing underneath its sibling — reaching real execution through consumers such as browser tab work. `SessionManager.claimProcessCwdOwnership` grants the claim to one live manager; a session without it moves its own cwd and leaves process-global state untouched. - Conventional MCP autoload now reads the user scope from the agent directory instead of a home-relative `/.gjc/agent` path (#4767). Every writer and denylist reader already resolved user scope through `getMCPConfigPath("user")` (the agent directory), while native discovery derived it from the load context's home, so the two disagreed the moment an agent-directory profile was in play: `GJC_CODING_AGENT_DIR= gjc mcp add ` wrote `/mcp.json` and reported the server as loaded by ordinary sessions at startup, but startup read `~/.gjc/agent/mcp.json` — the profile's own registrations never loaded and the default profile's servers loaded into the profile instead. `loadAllMCPConfigs` accepts an `agentDir`, sessions created with their own `agentDir` pass it, and the `disabledServers` denylist follows the same scope, so discovery, `gjc mcp add`, the `/mcp` wizard, and `gjc customize doctor` all name one file. This also restores isolation for the MCP autoload suites, which established their temp user scope with `setAgentDir()`: after the trusted-home provenance rework their `os.homedir()` mock no longer reached discovery, so on a developer machine the red-team suite read the real `~/.gjc/agent/mcp.json` and in CI it found nothing. - Immediate `turn.prompt` after `turn.abort` acknowledgement now starts exactly one successor turn instead of being silently consumed by aborted-turn teardown. Abort unwind no longer classifies the successor as steering into the dying loop, delayed `agent_end` is paired with the aborted invocation rather than the successor, and abort acknowledgement waits for that serialized terminal transition — no client-side sleep (#4749). Retention is bounded in the other direction too: abort stays terminal across overlapping aborts. Every abort request advances an abort-admission epoch synchronously, including one that shares an already in-flight unwind, so a prompt admitted between two aborts is refused as a cancelled preflight instead of refreshing its generation and starting work the user already aborted twice. An abort sharing another abort's unwind also applies its own request-scoped effects (preflight cancellation, and abort visibility so a later real abort is not silenced by an earlier `silent: true` abort). A prompt admitted after an abort settles still starts a normal successor turn. - Esc/Ctrl+C now recover a WSL/basic-terminal session whose busy indicator outlived its turn (#4741). Both the global interrupt/clear listener and the editor escape handler treated any mounted working loader as unconditionally cancellable work: after a completed turn left the loader mounted, every press ran a no-op abort, consumed the key, and reset the escape gestures, so the composer stayed `working [esc]`/`Fetching … [esc]` and Esc, Ctrl+C, and Ctrl+X never did anything (Ctrl+Z suspend/resume was the only escape). The loading branch now stops the stale indicator via the shared activity-indicator stop and lets the key fall through to idle semantics when there is no pending submission (including a started one still inside prompt preflight, exposed through the new `hasPendingSubmission()` context query), no queued steering/follow-up/compaction messages, and the session is neither streaming nor compacting; active work (streaming, queued messages, pending optimistic or started-preflight submission) still aborts exactly as before. Recovery gates on drainable queues only: the new `AgentSession.drainableQueuedMessageCount` counts exactly the steering and follow-up entries that `clearQueue()`/`popLastQueuedMessage()`/`getQueuedMessageEntries()` return, whereas the aggregate `queuedMessageCount` also counts hidden next-turn context that no key press can drain and that deliberately survives turn completion (a `todo_write` failure reminder queued with `deliverAs: "nextTurn"` and no `triggerTurn`) — gating on the aggregate left a permanently nonzero count that reproduced the same lockout while idle. Hidden next-turn ordering and delivery are unchanged; recovery neither clears nor delivers those entries. The same correction applies to the two adjacent gates whose handlers are also visible-queue-only: `app.message.sendNow` no longer advertises itself when only hidden context is queued (its only outcome was "No visible queued message to send"), and an empty submit while streaming no longer aborts the live turn to flush a queue that holds nothing drainable. @@ -50,6 +54,7 @@ - Fixed notify recovery feeding native exact-unlink exchange debris back through the exchange, which made a fresh notification daemon spin at ~100% CPU and accumulate unbounded `.gjc-delete-notification-endpoint-*` and `.gjc-exact-unlink-placeholder-*` artifacts (#4701). The native exchange always detaches a target into a fresh quarantine destination and leaves durable retained records, so recovery's endpoint scan re-classified those `.json` quarantine targets as live endpoints and re-quarantined them, while the debris sweep removed each aged stub by re-running the same exchange — manufacturing fresh quarantine/placeholder debris every pass and re-attacking a live endpoint publication. Recovery now treats native exchange debris paths as non-endpoint (never routed through the exchange) and `sweepNotificationDebris` removes positively-stale inert debris by a direct identity-checked unlink (re-verified at mutation time so a live successor is retained, never unlinked). Live endpoints stay published and matchable, health's endpoint census is no longer inflated by debris, and repeated recovery is a fixed point. - Fixed ralplan run fragmentation by writer cwd: `gjc ralplan` and `gjc ralplan --write` now accept an additive `--worktree-root ` that binds run state, stage artifacts, the `index.jsonl` ledger, HUD state, stuck markers, and review-budget accounting to one canonical git worktree regardless of the writer's ambient cwd (#4693). Without the flag, cwd-based behavior is unchanged. An explicit target is canonicalized and must be an existing git worktree root (missing, non-directory, non-git, and subdirectory targets fail before any filesystem mutation); explicit-target mode requires exact canonical `worktreeRoot` equality with the seeded run's `repository_binding` — a linked worktree that merely shares `commonDir` is rejected — and refuses to write where no seeded run state exists, so a role lane can never stamp fragmented authority in a second `.gjc` tree. A relative `--artifact` file is still resolved from the invoking cwd, and the bundled ralplan skill now instructs Planner/Architect/Critic/revision/disposition/post-interview/ADR/final writes to propagate the seed receipt's `repository_binding.worktreeRoot`. This removes the false terminal `PLANNING-STUCK` a dispatcher-cwd re-review hit when its local ledger lacked the target worktree's `revision` opener. - Windows ACP `session.create` no longer leaks `terminal_uncertain` when a lifecycle child publishes ready/endpoint evidence and then exits before the broker's final live admission (#4709). The ready-then-exit tolerance and rollback-receipt relaxation are win32-only through a testable host-platform seam; other platforms keep the prior fail-closed `terminal_uncertain` path. The typed broker/ACP code is `ready_then_exited` so MCP launch remapping cannot misattribute it as an MCP unavailable failure. A session host that rejects its liveness watcher after readiness writes no startup receipt, so the exact ready-plus-death classification is never preempted; a host that tore down gracefully is proven ready through the broker's own host registration when its endpoint file is gone. The ready-authority probe is typed (`matched`/`absent_indexed`/`absent_unindexed`/`not_published`/`malformed`/`io_error`): the broker-index fallback fires only on a proven-absent (ENOENT) endpoint, and a corrupt or unreadable endpoint surfaces as the new honest terminal `endpoint_unreadable` instead of a false `spawn_failed` claim about the child. Owned-artifact deletion re-checks the lifecycle marker immediately before unlinking the endpoint so a PID-reusing successor's endpoint can never be removed. Detached-host stderr is discarded by the OS (`stdio: "ignore"`): a parent-owned stderr pipe is not detached by `unref()` and would retain the broker process for the host's lifetime, and the host handles launch configuration and credential-bearing environment material, so its output is never captured, persisted, or included in caller-visible error strings. A dead child is never admitted as live. +- Added an agent-invokable session rescope: a `move_session` tool the model can call when a session launched from a broad multi-repo root has converged on one repository (#4629). It runs the same sequence as the text/ACP `/move` handler (`flush` → `moveTo` → `setProjectDir` → plugin/capability cache reset → SSH tool refresh), so tool path resolution, the bash default cwd, and relative-path resolution all follow the move. The tool is exposed through an optional `ToolSession.rescopeSessionCwd` accessor wired only for top-level sessions (`taskDepth 0`) without a restricted bash surface — subagents and read-only profiles never see it — and rejects non-directory targets without moving. Unlike the user-driven `/move`, the model-invoked accessor only narrows: the canonical (realpath) target must be a strict descendant of the canonical current cwd; the committed move revalidates a no-follow directory identity so a swapped symlink cannot escape; SessionManager serializes model and SDK/ACP moves and dispose joins the in-flight transition; restored-or-live workflow skills refuse the move; and cwd-capturing MCP/custom/Python/project authority is revoked and recreated at the target. Like the user-driven `/move`, the volatile workspace-tree snapshot itself still follows the launch cwd until #4628 lands. - Handled tool failures are now recorded and relayed alongside fatal crashes. A tool that throws and is caught never reaches the `uncaughtException` path, so those errors were invisible to both `gjc crash list` and the upstream relay. They are captured at `finishExecuteToolSpan`, which already holds the live `Error` with an intact stack, and only when `status === "error"` with a non-empty stack -- aborted calls, blocked calls, and non-`Error` throws are excluded because without a stack the v1 fingerprint degrades to `` and unrelated failures collapse into one group. Hooking `logger.error` was rejected for the same reason: nearly every call site passes `String(error)` or `error.message`, so the stack is already gone. Handled errors use their own files rather than the fatal store, since they are high-volume and would otherwise evict the rare fatal records under a shared cap; record format, redaction, fingerprint, and the `sanitizeExternalCrashV1` egress contract are reused verbatim. Upstream they differ only by `level` (`error` rather than `fatal`), and fatal signatures relay first so a noisy handled class cannot starve them at the per-run cap. - Closed remaining crash-relay egress and durability holes from #4658/#4698: automatic fatal and handled stores resolve through a non-XDG trusted agent path so a checkout `.env` that sets `XDG_STATE_HOME` and creates `$XDG_STATE_HOME/gjc` cannot feed forged state; the relay comparison watermark follows journal append order independently of display-time `lastSeen`; legacy `relayedAt` indexes keep coverage across upgrade and downgrade/re-upgrade; a 2xx POST whose local watermark append fails is a failed send and retries with the same event id. - `gjc config set` and `gjc config reset` now fail instead of reporting success when the setting cannot be durably written to disk. @@ -107,7 +112,7 @@ - A resident text cache demotion now names the OS failure behind it. `ResidentCacheTrustError` lifts the wrapped errno into `causeCode` and a bounded, single-line `causeSummary`; `SessionManager` logs both on `Resident cache trust rejection` and reports `residentCacheDegradedCauseCode` in observability stats; the managed-sidecar disposal warning gains the path-free `causeCode` only, so the cache path it deliberately withholds stays withheld. Previously the record carried `reason` alone, which collapses distinct failures: `blob_create_failed` reads identically whether the instance directory vanished under a live store (`ENOENT`), the process exhausted descriptors (`EMFILE`), or the tree turned read-only (`EACCES`). That mattered in practice — long-running sessions that had externalized a large edit snapshot began aborting every turn once their cache went missing, and the demotion record could not distinguish that from a hostile-path rejection without attaching a debugger to a process that was already failing. - Fixed `/usage` and `/usage check` omitting provider limits for stored OAuth accounts. Cache-only snapshots now use the provider's resolved base URL when reading usage, and explicit checks render the successful probe report directly instead of depending on a cache-key-identical readback (#4634). - `/usage` shows quota resets again. Canonicalizing multi-account management (`364f14022`) rewired the interactive `/usage` handler from the graphical panel to the account-inventory text view, and that view rendered only `label: N% used (M% left)` — no bars, no reset countdown — leaving the command unable to answer when a quota comes back and stranding `handleUsageCommand`/`renderUsageReports` as unreachable code. Plain `/usage` in the TUI renders the panel again, sourced from the same cache-only inventory snapshot the text view reads, so the cache-only contract is preserved and no fetch or probe is reintroduced; `/usage check` keeps the text path, where the per-credential health verdict is the point. Account rows on every surface (TUI, ACP, Telegram) now carry `resets in ()`, and the panel itself gained multi-account reset lines, hour-precision countdowns past 48h (`6d 14h`, previously rounded to a bare `7d` at anywhere from 6.6 to 7.4 days), and set-aware account-label truncation so pooled credentials sharing a domain no longer collapse into identical columns. -- A Round-0 deep-interview `ask` whose `deepInterview` object is present with topology identity (`round: 0`, `component: "review-topology"`, `dimension: "topology"`) but omits required topology fields (`ambiguity`, `intent_contract`) is now rejected before coercion with a targeted correction naming the omitted fields and the exact `intent_contract` shape, instead of generic zod issues plus a full payload echo. The incomplete object is not a retired-pair recovery candidate, so it fell through to schema validation whose message named neither the contract nor what a corrected retry must contain — and a metadata-only retry repeated the same invalid shape (#4649). Recovery stays fail-closed: `intent_contract` is never synthesized (items and affirmative labels are the locked-intent evidence), and the recorder still locks intent only on an affirmative user answer. Valid contract-only Round 0 and post-Round-0 payloads validate unchanged. +- A Round-0 deep-interview `ask` whose `deepInterview` object is present with topology identity (`round: 0`, `component: "review-topology", "dimension": "topology") but omits required topology fields (`ambiguity`, `intent_contract`) is now rejected before coercion with a targeted correction naming the omitted fields and the exact `intent_contract` shape, instead of generic zod issues plus a full payload echo. The incomplete object is not a retired-pair recovery candidate, so it fell through to schema validation whose message named neither the contract nor what a corrected retry must contain — and a metadata-only retry repeated the same invalid shape (#4649). Recovery stays fail-closed: `intent_contract` is never synthesized (items and affirmative labels are the locked-intent evidence), and the recorder still locks intent only on an affirmative user answer. Valid contract-only Round 0 and post-Round-0 payloads validate unchanged. - Added an opt-in crash upstream so local crash signatures can be aggregated across installs, which the agent-dir-scoped index could never answer on its own. `crashReport.upstream` (default `off`) plus `crashReport.upstreamDsn` (or `GJC_CRASH_SENTRY_DSN`) gate a hand-rolled Sentry envelope POST; no DSN literal is compiled into the binary, so a build has no destination to fall back to and `off` costs one settings read and no IO. The relay never runs on the fatal path — a crashing process still performs exactly one `O_APPEND` journal write, and relaying happens at the next startup after index compaction, bounded to 8 signatures per run with a 10s timeout. A Sentry SDK is deliberately not used: SDK defaults attach breadcrumbs, environment, and argv, which would defeat the point of the outbound sanitizer. Every crash-derived field must pass `sanitizeExternalCrashV1` and a refusal drops that signature outright rather than falling back to a less-sanitized payload; the emitted payload is a fixed key set (`event_id`, `timestamp`, `platform`, `level`, `logger`, `release`, `environment`, `fingerprint`, `exception`, `tags`, `extra`, `sdk`) with `user`, `server_name`, `contexts`, `breadcrumbs`, `request`, `modules`, env vars, argv, and hostname structurally absent. The gjc fingerprint is sent as Sentry's `fingerprint` array so grouping is ours rather than Sentry's heuristics — one upstream issue per gjc signature, verified by two events with disjoint stack frames merging into a single group. `gjc crash relay` exposes the same batch as a loud, non-zero-on-refusal command. A new `relayed` journal event stamps `relayedAt` monotonically so re-runs do not resend unless `lastSeen` advanced, and `relayedAt` is deliberately not an input to index eviction. The `gjc crash report` GitHub flow keeps its per-invocation, digest-confirmed consent boundary unchanged; the two egress channels are separate with separate rules. - Managed-fallback sessions now steer escaped-non-ASCII retries instead of re-issuing them blind, and bound them. The agent loop's `escaped_arguments_discarded` outcome reports whether the discarded attempt still lacked a transient recovery instruction, and the session's retry continuation attaches exactly one such instruction (naming the `\uXXXX` defect and demanding literal UTF-8) through the new `transientRecoveryMessage` prompt option, so the deterministic Hangul-escaping failure observed on `ask` payloads is corrected on the retry instead of exhausting the budget every turn. Because those retries are deliberately un-charged (the defect is not provider evidence), each continuation is a fresh loop with a fresh in-loop resample budget and the fallback chain never exhausts on them — so a deterministic escaper previously looped forever under managed fallback (measured: 2,940 provider calls in 5s before an external timeout). The session now bounds escaped retries per logical run (steered retry + blind retry, reset each user turn) and fails closed through the terminal exhaustion message. The instruction is transient: sent only to the provider, never committed to durable history, never riding a later request; the terminal per-call rejection stays fail-closed. - Fixed resume listing scaling its read-syscall count with total transcript bytes. The trailing `header_patch` scan walks back to BOF whenever `cwd`/`title` stay unresolved (#3633), which is the common case because only `/rename` and workspace moves ever emit a patch; because the scan borrowed the caller's 4 KiB prefix buffer, that walk cost one `read` per 4 KiB of every candidate transcript on each `--resume`, `--continue`, and picker open. The scan now owns a 64 KiB buffer, so the same bytes are covered in ~16x fewer syscalls. Measured on a real 31-session workspace holding 105 MB of transcripts (largest 41 MB): 25,715 reads / 61.9 s before, 1,652 reads / 0.5 s after, with all 24 recovered titles unchanged. Buried-title recovery, the bytes examined, the `header_patch` marker prefilter, and listing results are unchanged. diff --git a/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts b/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts index 13d85c6aca..9f2c46745c 100644 --- a/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts +++ b/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts @@ -73,6 +73,14 @@ const LOCKED_EXCLUSIONS: Readonly> = { "agent_session:setForcedToolChoice": "internal accessor/plumbing, not a user-facing control seam", "agent_session:getActiveSkillState": "internal accessor/plumbing, not a user-facing control seam", "agent_session:getActiveSkillPhase": "internal accessor/plumbing, not a user-facing control seam", + "agent_session:getEffectiveActiveWorkflowSkillState": + "internal restored-or-live workflow guard for session rescope, not a user-facing SDK control seam", + "agent_session:replaceOwnedMcpManager": + "internal cwd-rebinding helper for owned MCP authority after move_session, not a user-facing SDK control seam", + "agent_session:replaceNamedCustomTools": + "internal cwd-rebinding helper for named custom tools after move_session, not a user-facing SDK control seam", + "agent_session:recreatePythonTool": + "internal cwd-rebinding helper that recreates the python tool after move_session, not a user-facing SDK control seam", "agent_session:getDeepInterviewAskStage": "internal AskTool schema-selection accessor, not a user-facing SDK control seam", "agent_session:peekQueueInvoker": "internal accessor/plumbing, not a user-facing control seam", diff --git a/packages/coding-agent/scripts/generate-tool-catalog.ts b/packages/coding-agent/scripts/generate-tool-catalog.ts index 7ce3e32788..31c560e918 100644 --- a/packages/coding-agent/scripts/generate-tool-catalog.ts +++ b/packages/coding-agent/scripts/generate-tool-catalog.ts @@ -116,6 +116,7 @@ function makeSession(cwd: string): any { hasEditTool: true, taskDepth: 0, currentAgentType: "executor", + rescopeSessionCwd: async () => ({ from: cwd, to: cwd }), getSessionFile: () => null, getSessionSpawns: () => null, getSessionId: () => "catalog", diff --git a/packages/coding-agent/src/modes/controllers/command-controller.ts b/packages/coding-agent/src/modes/controllers/command-controller.ts index 7cc2539045..75a187b60a 100644 --- a/packages/coding-agent/src/modes/controllers/command-controller.ts +++ b/packages/coding-agent/src/modes/controllers/command-controller.ts @@ -1153,13 +1153,15 @@ export class CommandController { } try { - await this.ctx.sessionManager.flush(); - await this.ctx.sessionManager.moveTo(resolvedPath); - setProjectDir(resolvedPath); - clearClaudePluginRootsCache(); // re-warms preloadedPluginRoots with new project dir (async) - resetCapabilities(); - await this.ctx.refreshSlashCommandState(resolvedPath); - await this.ctx.session.refreshSshTool({ activateIfAvailable: true }); + await this.ctx.sessionManager.runExclusiveCwdTransition(async () => { + await this.ctx.sessionManager.flush(); + await this.ctx.sessionManager.moveTo(resolvedPath); + setProjectDir(resolvedPath); + clearClaudePluginRootsCache(); + resetCapabilities(); + await this.ctx.refreshSlashCommandState(resolvedPath); + await this.ctx.session.refreshSshTool({ activateIfAvailable: true }); + }); this.ctx.statusLine.invalidate(); this.ctx.updateEditorTopBorder(); diff --git a/packages/coding-agent/src/prompts/tools/move-session.md b/packages/coding-agent/src/prompts/tools/move-session.md new file mode 100644 index 0000000000..f965a4da0d --- /dev/null +++ b/packages/coding-agent/src/prompts/tools/move-session.md @@ -0,0 +1,15 @@ +Rescope the session to a narrower working directory. + +Use this only when the session's working directory is a broad launcher root (for example a +multi-repo workspace like `~/Projects`) and the task has clearly converged on one subdirectory +or repository: after this call, every later turn resolves relative paths and the bash default +cwd from the new directory, and project-scoped plugins/capabilities reload for it. + +- `path` must be an existing directory; relative paths resolve against the current session cwd. + The canonical target must be strictly inside the current session directory — moves to a + parent, a sibling project, or an unrelated absolute path are refused. +- A session can be moved this way at most once, and never while another move is running; a + rejected call does not consume the move. Use it once the target repo is identified — not + speculatively — because the session file and caches move with the session. +- This tool is unavailable in subagent sessions and restricted profiles; ask the top-level + session to rescope instead. diff --git a/packages/coding-agent/src/runtime/optional-runtime-services.ts b/packages/coding-agent/src/runtime/optional-runtime-services.ts index f61b381d4b..33b680abbb 100644 --- a/packages/coding-agent/src/runtime/optional-runtime-services.ts +++ b/packages/coding-agent/src/runtime/optional-runtime-services.ts @@ -20,7 +20,8 @@ export type OptionalRuntimeServicesOverrides = Partial; /** Context needed by services whose identity is scoped to the session cwd. */ export interface OptionalRuntimeServicesContext { - cwd?: string; + /** Session cwd. Pass a getter when the session can rescope (`move_session`). */ + cwd?: string | (() => string); } /** @@ -32,7 +33,7 @@ export function createOptionalRuntimeServices( overrides: OptionalRuntimeServicesOverrides = {}, context: OptionalRuntimeServicesContext = {}, ): OptionalRuntimeServices { - const cwd = context.cwd ?? process.cwd(); + const cwd = context.cwd ?? (() => process.cwd()); return { memoryBackend: overrides.memoryBackend ?? createMemoryBackendService(settings), workspaceTree: overrides.workspaceTree ?? createWorkspaceTreeService(settings, cwd), diff --git a/packages/coding-agent/src/runtime/workspace-tree-service.ts b/packages/coding-agent/src/runtime/workspace-tree-service.ts index 385441799d..eca8572723 100644 --- a/packages/coding-agent/src/runtime/workspace-tree-service.ts +++ b/packages/coding-agent/src/runtime/workspace-tree-service.ts @@ -15,8 +15,16 @@ export interface WorkspaceTreeRuntime { * Build the workspace-tree service without importing the native scanner until * the service is activated. The scan itself remains the single authority for * both eager startup and the lazy first-turn barrier. + * + * `cwd` is resolved per scan rather than captured once: a session that rescopes + * (`move_session`, `/move`) must have its refreshes re-root at the new cwd, + * otherwise every later tree describes the abandoned launcher root. */ -export function createWorkspaceTreeService(settings: Settings, cwd: string): LazyService { +export function createWorkspaceTreeService( + settings: Settings, + cwd: string | (() => string), +): LazyService { + const resolveCwd = typeof cwd === "function" ? cwd : () => cwd; return createLazyService({ id: "workspaceTree", enabled: () => settings.get("workspaceTree.mode") === "eager" || settings.get("workspaceTree.mode") === "lazy", @@ -24,7 +32,7 @@ export function createWorkspaceTreeService(settings: Settings, cwd: string): Laz const scan = async (): Promise => { if (signal.aborted) throw new Error("Workspace-tree scan was aborted before it started."); const { buildWorkspaceTree } = await import("../workspace-tree"); - const tree = await buildWorkspaceTree(cwd, { timeoutMs: WORKSPACE_TREE_SCAN_TIMEOUT_MS }); + const tree = await buildWorkspaceTree(resolveCwd(), { timeoutMs: WORKSPACE_TREE_SCAN_TIMEOUT_MS }); if (signal.aborted) throw new Error("Workspace-tree scan was aborted before it completed."); return tree; }; @@ -34,7 +42,7 @@ export function createWorkspaceTreeService(settings: Settings, cwd: string): Laz snapshot, refresh: async () => { const { buildWorkspaceTree } = await import("../workspace-tree"); - return buildWorkspaceTree(cwd, { timeoutMs: WORKSPACE_TREE_SCAN_TIMEOUT_MS }); + return buildWorkspaceTree(resolveCwd(), { timeoutMs: WORKSPACE_TREE_SCAN_TIMEOUT_MS }); }, }, }; diff --git a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json index 088e59d4a4..d0b97bb145 100644 --- a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json +++ b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json @@ -2446,6 +2446,50 @@ "testIds": "not_applicable" } }, + { + "sourceId": "agent_session:getEffectiveActiveWorkflowSkillState", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal restored-or-live workflow guard for session rescope, not a user-facing SDK control seam", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, + { + "sourceId": "agent_session:replaceOwnedMcpManager", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal cwd-rebinding helper for owned MCP authority after move_session, not a user-facing SDK control seam", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, + { + "sourceId": "agent_session:replaceNamedCustomTools", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal cwd-rebinding helper for named custom tools after move_session, not a user-facing SDK control seam", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, + { + "sourceId": "agent_session:recreatePythonTool", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal cwd-rebinding helper that recreates the python tool after move_session, not a user-facing SDK control seam", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, { "sourceId": "agent_session:getActiveSkillPhase", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index e4ea9b6fdb..5982f0ef69 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -1,3 +1,5 @@ +import * as nodeFs from "node:fs"; +import * as fs from "node:fs/promises"; import * as path from "node:path"; import { Agent, @@ -33,6 +35,7 @@ import { postmortem, prompt, Snowflake, + setProjectDir, } from "@gajae-code/utils"; import { createAppendOnlyContextManager, @@ -46,7 +49,7 @@ import { isBackgroundJobSupportEnabled, jobElapsedMs, } from "../async"; -import { loadCapability } from "../capability"; +import { loadCapability, reset as resetCapabilities } from "../capability"; import { type Rule, ruleCapability, setActiveRules } from "../capability/rule"; import type { SourceMeta } from "../capability/types"; import { resolveModelProfileName } from "../config/model-profile-contract"; @@ -72,6 +75,7 @@ import { resolveConfigValue } from "../config/resolve-config-value"; import { getEmbeddedDefaultGjcSkills } from "../defaults/gjc-defaults"; import { BUNDLED_GROK_BUILD_EXTENSION_ID, getBundledGrokBuildExtensionFactory } from "../defaults/gjc-grok-cli"; import { initializeWithSettings } from "../discovery"; +import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../discovery/helpers"; import { TtsrManager } from "../export/ttsr"; import type { CustomCommandsLoadResult, LoadedCustomCommand } from "../extensibility/custom-commands"; import type { CustomTool, CustomToolContext, CustomToolSessionEvent } from "../extensibility/custom-tools/types"; @@ -111,6 +115,7 @@ import type { HindsightSessionState } from "../hindsight/state"; import { normalizePluginHook } from "../hooks/normalize"; import { initializeLocalRoot, LocalProtocolHandler, type LocalProtocolOptions } from "../internal-urls"; import type { LspStartupServerInfo } from "../lsp"; +import { shutdownAll as shutdownAllLspClients } from "../lsp/client"; import btwUserPrompt from "../prompts/system/btw-user.md" with { type: "text" }; import asyncResultTemplate from "../prompts/tools/async-result.md" with { type: "text" }; import { AgentRegistry, MAIN_AGENT_ID } from "../registry/agent-registry"; @@ -133,7 +138,6 @@ import { NotificationSessionController } from "../sdk/bus/session-control"; import { shouldHostSdk } from "../sdk/host"; import { createSdkSessionRuntimeExtension, registerSdkOnlyNotificationCommand } from "../sdk/host/session-runtime"; import { createSdkWebSocketTransport } from "../sdk/host/websocket-transport"; - import type { SecretObfuscator } from "../secrets"; import { AgentSession, type ForkContextSeed } from "../session/agent-session"; import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "../session/auth-storage"; @@ -1408,7 +1412,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} authStorage.setSessionCredentialSelector(scopeId, provider, selector); }; const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir })); - const runtimeServices = createOptionalRuntimeServices(settings, options.runtimeServices, { cwd }); + // Cwd-derived runtime state must follow a rescope (`move_session`, `/move`), + // so services resolve the LIVE session cwd per activation instead of + // capturing the launch root. Before the manager exists the launch cwd is the + // only truth available, and it is also the manager's initial cwd. + let liveSessionManager: SessionManager | undefined; + const getLiveCwd = (): string => liveSessionManager?.getCwd() ?? cwd; + const runtimeServices = createOptionalRuntimeServices(settings, options.runtimeServices, { cwd: getLiveCwd }); modelRegistry.applyConfiguredModelBindings(settings); logger.time("initializeWithSettings", initializeWithSettings, settings); if (!options.modelRegistry) { @@ -1470,6 +1480,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} (await logger.time("sessionManager", async () => { return SessionManager.create(cwd, SessionManager.managedDestination(cwd, agentDir)); })); + liveSessionManager = sessionManager; const logicalSessionId = sessionManager.getSessionId(); // Fork-context seeds carry conversation content only, never provider identity: // a shared continuity id would make concurrent subagents present the same @@ -1911,7 +1922,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} contextFilesResultPromise, raceWithDeadline("buildWorkspaceTree", workspaceTreePromise), ]); - const contextFiles = contextFilesResult.contextFiles; + // Mutable: a rescope re-discovers cwd-derived project instructions so the + // model is never shown the launcher root's AGENTS.md alongside the new cwd. + let contextFiles = contextFilesResult.contextFiles; + let liveWorkspaceTree: WorkspaceTree | undefined = resolvedWorkspaceTree; const discoveredContextFileWarnings = contextFilesResult.warnings; const backgroundJobsEnabled = isBackgroundJobSupportEnabled(settings); @@ -2019,6 +2033,142 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (model) return formatModelString(model); return undefined; }; + let mcpManager: MCPManager | undefined = options.mcpManager; + let ownsMcpManager = false; + const cwdCapturingToolNames: string[] = []; + const notificationDebounceTimers = new Map(); + const wireMcpManagerCallbacks = (manager: MCPManager): void => { + manager.setOnPromptsChanged(serverName => { + const promptCommands = buildMCPPromptCommands(manager); + session.setMCPPromptCommands(promptCommands); + logger.debug("MCP prompt commands refreshed", { path: `mcp:${serverName}` }); + }); + manager.setOnResourcesChanged((serverName, uri) => { + logger.debug("MCP resources changed", { path: `mcp:${serverName}`, uri }); + if (!settings.get("mcp.notifications")) return; + const debounceMs = settings.get("mcp.notificationDebounceMs"); + const key = `${serverName}:${uri}`; + const existing = notificationDebounceTimers.get(key); + if (existing) clearTimeout(existing); + notificationDebounceTimers.set( + key, + setTimeout(() => { + notificationDebounceTimers.delete(key); + if (!settings.get("mcp.notifications")) return; + session.yieldQueue.enqueue("mcp-notification", { serverName, uri }); + }, debounceMs), + ); + }); + }; + const rebindCwdCapturingAuthority = async (to: string): Promise => { + if (!session) return; + if (options.mcpManager && !ownsMcpManager) { + throw new Error( + "Cannot rescope a session with caller-owned MCP authority; recreate the session at the target cwd.", + ); + } + await session.refreshGjcSubskillTools(); + const previousCwdCapturing = [...cwdCapturingToolNames]; + const nextCwdCapturing: string[] = []; + const nextCustomTools: CustomTool[] = []; + try { + const declarations = await getGjcPluginToolDeclarations(to); + const pluginToolResult = await loadAlwaysOnPluginTools({ + cwd: to, + reservedToolNames: session.getAllToolNames().filter(name => !previousCwdCapturing.includes(name)), + declarations, + }); + nextCustomTools.push(...pluginToolResult.tools); + nextCwdCapturing.push(...pluginToolResult.tools.map(tool => tool.name)); + } catch (error) { + logger.warn("Failed to reload always-on plugin tools after session rescope", { + error: error instanceof Error ? error.message : String(error), + }); + } + if (ownsMcpManager) { + const previousManager = mcpManager; + if (previousManager) await previousManager.disconnectAll().catch(() => {}); + let nextManager: MCPManager | undefined; + try { + const loaded = await loadAllMCPConfigs(to, { + enableProjectConfig: settings.has("mcp.enableProjectConfig") + ? settings.get("mcp.enableProjectConfig") + : true, + autoloadOnly: true, + nativeOnly: true, + }); + const { configs } = await buildPluginMcpConfigs({ cwd: to }); + const mergedConfigs = { ...loaded.configs, ...configs }; + if (Object.keys(mergedConfigs).length > 0) { + nextManager = new MCPManager(to, null, { sharedPoolIdleMs: settings.get("mcp.sharedPoolIdleMs") }); + nextManager.setAuthStorage(authStorage); + wireMcpManagerCallbacks(nextManager); + const result = await nextManager.connectServers(mergedConfigs, loaded.sources as never); + nextCustomTools.push(...(result.tools as CustomTool[])); + nextCwdCapturing.push(...result.tools.map(tool => tool.name)); + } + } catch (error) { + logger.warn("Failed to recreate MCP authority after session rescope", { + error: error instanceof Error ? error.message : String(error), + }); + } + mcpManager = nextManager; + ownsMcpManager = Boolean(nextManager); + await session.replaceOwnedMcpManager(nextManager); + await session.refreshMCPTools((nextManager?.getTools() ?? []) as CustomTool[]); + } + cwdCapturingToolNames.length = 0; + cwdCapturingToolNames.push(...nextCwdCapturing); + await session.replaceNamedCustomTools( + previousCwdCapturing.filter(name => !nextCustomTools.some(tool => tool.name === name)), + nextCustomTools, + ); + }; + + /** + * Re-discover the cwd-derived read-only state the model is shown after a + * committed rescope: project instructions, skills, and the workspace tree. + * Without this the volatile message pairs the NEW cwd with the launch + * root's AGENTS.md and tree, and subagents inherit the same mismatch. + */ + const applyRescopedReadState = async (to: string): Promise => { + try { + const rediscovered = await loadContextFilesResultInternal({ cwd: to }); + contextFiles = rediscovered.contextFiles; + } catch (error) { + logger.warn("Failed to re-discover context files after session rescope", { + error: safeErrorForLog(error), + }); + } + if (options.skills === undefined && settings.get("skills.enabled")) { + try { + const reloaded = await loadSkills({ + ...settings.getGroup("skills"), + cwd: to, + disabledExtensions: settings.get("disabledExtensions"), + }); + skills = withEmbeddedDefaultGjcSkills(reloaded.skills); + if (!options.parentTaskPrefix) setActiveSkills(skills); + await session?.replaceSkills(skills); + } catch (error) { + logger.warn("Failed to reload skills after session rescope", { error: safeErrorForLog(error) }); + } + } + // The launch-bound tree is retired immediately: a stale root-scoped tree is + // worse than none, and the next turn re-scans at the new cwd. + liveWorkspaceTree = undefined; + workspaceTreePromise = Promise.resolve({ + rootPath: to, + rendered: "", + truncated: false, + totalLines: 0, + agentsMdFiles: [], + }); + workspaceTreePromise.catch(() => {}); + session?.retireWorkspaceTreeForRescope(); + await session?.refreshBaseSystemPrompt(); + }; + const toolSession: ToolSession = { get cwd() { return sessionManager.getCwd(); @@ -2033,14 +2183,197 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return !requestedToolNames || requestedToolNames.includes("edit"); }, skipPythonPreflight: options.skipPythonPreflight, - contextFiles, - workspaceTree: resolvedWorkspaceTree, - skills, + // Getters, not snapshots: subagents launched after a rescope inherit the + // CURRENT cwd's context files, skills, and tree rather than the launch + // root's, which would otherwise pair the new cwd with retired instructions. + get contextFiles() { + return contextFiles; + }, + get workspaceTree() { + return liveWorkspaceTree; + }, + get skills() { + return skills; + }, eventBus, outputSchema: options.outputSchema, requireYieldTool: options.requireYieldTool, taskDepth: options.taskDepth ?? 0, currentAgentType: options.currentAgentType, + // Agent-invokable session rescope (#4629). Provided only where + // relocation is safe: canonical top-level sessions (the same + // isCanonicalSubSession predicate that gates SDK hosting — taskDepth 0, + // no parentTaskPrefix, no currentAgentType) without a restricted bash + // surface. Runs the same sequence as the text/ACP `/move` handler so + // tool path resolution, the bash default cwd, and plugin caches follow + // the move. Unlike the user-driven `/move`, the model-invoked accessor + // is bound to at most one successful move per session, rejects + // re-entrant calls, refuses to move while a workflow skill is active + // (its cwd-local state and guards stay pinned to the launch root), and + // only narrows: the canonical target must be a strict descendant of + // the canonical current cwd, so an injected or speculative call cannot + // widen the session's tool/write scope to a parent, a sibling project, + // or an arbitrary absolute path. + ...(!isCanonicalSubSession && + !options.bashRestrictionProfile && + (options.bashAllowedPrefixes ?? []).length === 0 && + !options.mcpManager && + options.workspaceTree === undefined + ? { + rescopeSessionCwd: (() => { + let moveConsumed = false; + return async (target: string): Promise<{ from: string; to: string }> => { + if (moveConsumed) { + throw new Error( + "This session has already been rescoped; only one agent-invoked move is allowed per session.", + ); + } + if (session?.getEffectiveActiveWorkflowSkillState()) { + throw new Error( + "A workflow skill is active in this session; finish or exit it before rescoping.", + ); + } + if (options.mcpManager && !ownsMcpManager) { + throw new Error( + "Cannot rescope a session with caller-owned MCP authority; recreate the session at the target cwd.", + ); + } + return sessionManager.runExclusiveCwdTransition(async () => { + if (moveConsumed) { + throw new Error( + "This session has already been rescoped; only one agent-invoked move is allowed per session.", + ); + } + const from = sessionManager.getCwd(); + const resolvedPath = path.resolve(from, target); + let canonicalFrom: string; + let canonicalTarget: string; + try { + canonicalFrom = await fs.realpath(from); + canonicalTarget = await fs.realpath(resolvedPath); + } catch { + throw new Error(`Directory does not exist or is not a directory: ${resolvedPath}`); + } + if (!(await fs.stat(canonicalTarget)).isDirectory()) { + throw new Error(`Directory does not exist or is not a directory: ${resolvedPath}`); + } + const relative = path.relative(canonicalFrom, canonicalTarget); + if (relative === "") { + throw new Error( + `Target ${canonicalTarget} is the current session directory; nothing to move.`, + ); + } + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error( + `Refusing to rescope outside the current session directory: ${canonicalTarget} is not within ${canonicalFrom}. move_session only narrows the session scope; ask the user to restart or /move for a broader relocation.`, + ); + } + let targetHandle: nodeFs.promises.FileHandle | undefined; + let expectedIdentity: { dev: bigint; ino: bigint }; + try { + targetHandle = await SessionManager.openNoFollowDirectory(canonicalTarget); + const opened = await targetHandle.stat({ bigint: true }); + if (!opened.isDirectory()) { + throw new Error(`Directory does not exist or is not a directory: ${resolvedPath}`); + } + expectedIdentity = { dev: opened.dev, ino: opened.ino }; + await fs.access(canonicalTarget, nodeFs.constants.R_OK | nodeFs.constants.X_OK); + } catch (error) { + await targetHandle?.close().catch(() => {}); + if (error instanceof Error && error.message.startsWith("Directory does not exist")) { + throw error; + } + throw new Error( + `Directory identity or access unavailable: ${canonicalTarget}${ + error instanceof Error ? ` (${error.message})` : "" + }`, + ); + } + // Process-cwd authority is an explicit claim, never inferred from + // `process.cwd() === from`: sibling sessions launched at the same + // root both satisfy that, so acting on it would chdir the process + // and clear process-global caches underneath the sibling. + const ownsProcessCwd = SessionManager.isProcessCwdOwner(sessionManager); + try { + // Every fallible step that the moved session depends on runs + // BEFORE the session-file commit, so a failure here leaves the + // session exactly where it was and the tool call is a clean + // rejection rather than a half-moved session. + if (ownsProcessCwd) { + setProjectDir(canonicalTarget); + try { + // `setProjectDir` chdirs a NAME. Confirm the process actually + // landed on the pinned directory, so a path replaced after + // the name checks cannot escape the validated descendant. + await SessionManager.assertProcessCwdIdentity(expectedIdentity); + } catch (error) { + setProjectDir(canonicalFrom); + throw error; + } + } + let rescopeFailure: unknown; + try { + if (ownsProcessCwd) { + resetCapabilities(); + await shutdownAllLspClients(); + const projectRegistry = await resolveActiveProjectRegistryPath(canonicalTarget); + clearPluginRootsAndCaches(projectRegistry ? [projectRegistry] : undefined); + } + // Plugin/MCP/Python authority must be rebound successfully + // before committing; swallowing a failure here is what leaves + // a moved session holding launch-root tool authority. + await rebindCwdCapturingAuthority(canonicalTarget); + } catch (error) { + rescopeFailure = error; + } + if (rescopeFailure !== undefined) { + // Restore the launch root's authority so the still-unmoved + // session keeps working tools instead of a torn-down set. + if (ownsProcessCwd) setProjectDir(canonicalFrom); + if (ownsProcessCwd) { + resetCapabilities(); + const restoreRegistry = await resolveActiveProjectRegistryPath(canonicalFrom).catch( + () => undefined, + ); + clearPluginRootsAndCaches(restoreRegistry ? [restoreRegistry] : undefined); + } + await rebindCwdCapturingAuthority(canonicalFrom).catch(restoreError => { + logger.warn( + "Failed to restore launch-root tool authority after a rejected rescope", + { + error: safeErrorForLog(restoreError), + }, + ); + }); + throw rescopeFailure; + } + await sessionManager.flush(); + // Commit last: `moveTo` re-validates the pinned identity through + // the still-open handle at the state-changing boundary. + await sessionManager.moveTo(canonicalTarget, { + expectedIdentity, + targetHandle, + }); + moveConsumed = true; + // Cwd-derived read-only state the prompt and subagents consume. + // Best-effort by design: the move is committed, and a failed + // re-discovery must not present a committed move as a failure. + await applyRescopedReadState(sessionManager.getCwd()); + try { + await session?.refreshSshTool({ activateIfAvailable: true }); + } catch { + // Non-fatal: the session has moved; the SSH tool refreshes + // on its next activation attempt. + } + return { from, to: sessionManager.getCwd() }; + } finally { + await targetHandle.close().catch(() => {}); + } + }); + }; + })(), + } + : {}), getSessionFile: () => sessionManager.getSessionFile() ?? null, getEvalKernelOwnerId: () => evalKernelOwnerId, assertEvalExecutionAllowed: () => session?.assertEvalExecutionAllowed(), @@ -2168,6 +2501,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} if (!options.parentTaskPrefix) { setActiveSkills(skills); setActiveRules([...rulebookRules, ...alwaysApplyRules]); + // Claim process-cwd authority for the FIRST top-level session launched at + // the process cwd. A later sibling launched at the same root does not get + // the claim, so its rescope leaves process-global state (chdir, + // capabilities, plugin caches, browser tab cwd) untouched. + if (!isCanonicalSubSession && path.resolve(process.cwd()) === path.resolve(sessionManager.getCwd())) { + SessionManager.claimProcessCwdOwnership(sessionManager); + } if (asyncJobManager) { // Register under the session endpoint so concurrent sessions' // owned work settles in the correct manager (review thread P1). @@ -2223,8 +2563,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // written by `gjc mcp add`; disabled by `--no-mcp`), and plugin-bundle // MCP servers (created below after `customTools` is populated). Existing // caller-supplied managers remain available for legacy in-process callers. - let mcpManager: MCPManager | undefined = options.mcpManager; - let ownsMcpManager = false; const customTools: CustomTool[] = []; const exactMcpToolNames: string[] = []; const pluginMcpToolNames: string[] = []; @@ -2314,7 +2652,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} reservedToolNames: [...getReservedSubskillToolNames(), ...customTools.map(tool => tool.name)], declarations: gjcToolDeclarations, }); - if (pluginToolResult.tools.length > 0) customTools.push(...pluginToolResult.tools); + if (pluginToolResult.tools.length > 0) { + customTools.push(...pluginToolResult.tools); + cwdCapturingToolNames.push(...pluginToolResult.tools.map(tool => tool.name)); + } for (const q of pluginToolResult.quarantine) { gjcFindings.add({ identity: q.identity, surfaceId: q.surfaceId, code: q.code, message: q.message }); logger.warn("Quarantined GJC plugin surface", { plugin: q.plugin, surface: q.surfaceId, code: q.code }); @@ -2344,6 +2685,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const resultTools = result.tools as CustomTool[]; exactMcpToolNames.push(...resultTools.map(tool => tool.name)); customTools.push(...resultTools); + cwdCapturingToolNames.push(...resultTools.map(tool => tool.name)); if (result.errors.size > 0 || result.tools.length === 0) { logger.warn("MCP tools could not be loaded."); } @@ -2438,6 +2780,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} mcpManager = owned; ownsMcpManager = true; customTools.push(...(result.tools as CustomTool[])); + cwdCapturingToolNames.push(...result.tools.map(tool => tool.name)); const connectedPluginNames = new Set( result.connectedServers.filter(name => pluginNames.has(name)), ); @@ -3129,7 +3472,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} const appendPrompt: string | undefined = memoryInstructions ?? undefined; let pluginSystemAppendices = ""; try { - pluginSystemAppendices = await renderAlwaysOnSystemAppendices({ cwd }); + pluginSystemAppendices = await renderAlwaysOnSystemAppendices({ cwd: getLiveCwd() }); } catch (error) { gjcProducersComplete = false; logger.warn("Failed to render GJC plugin system appendices", { error: safeErrorForLog(error) }); @@ -3143,7 +3486,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // `unavailable` rather than a stale generation. if (gjcProducersComplete) gjcRuntimeStore.publish(gjcFindings.snapshot(), gjcPassEpoch); const defaultPrompt = await buildSystemPromptInternal({ - cwd, + // Live cwd: the prompt is rebuilt after a rescope, and describing the + // retired launcher root there is what makes the model pick wrong paths. + cwd: getLiveCwd(), skills, contextFiles, tools: promptTools, @@ -3676,7 +4021,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} convertToLlm: convertToLlmFinal, rebuildSystemPrompt, getMcpServerInstructions: - explicitMcpConfigPath === undefined && mcpManager ? () => mcpManager.getServerInstructions() : undefined, + explicitMcpConfigPath === undefined ? () => mcpManager?.getServerInstructions() : undefined, workspaceTree: options.workspaceTree ?? (workspaceTreeMode === "eager" ? resolvedWorkspaceTree : undefined), workspaceTreeService: options.workspaceTree ? undefined : runtimeServices.workspaceTree, networkPrewarmService: runtimeServices.networkPrewarm, @@ -3868,35 +4213,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} void session.refreshMCPTools(tools); }); } - // Wire prompt refresh → rebuild MCP prompt slash commands - mcpManager.setOnPromptsChanged(serverName => { - const promptCommands = buildMCPPromptCommands(mcpManager); - session.setMCPPromptCommands(promptCommands); - logger.debug("MCP prompt commands refreshed", { path: `mcp:${serverName}` }); - }); - const notificationDebounceTimers = new Map(); const clearDebounceTimers = () => { for (const timer of notificationDebounceTimers.values()) clearTimeout(timer); notificationDebounceTimers.clear(); }; postmortem.register("mcp-notification-cleanup", clearDebounceTimers); - mcpManager.setOnResourcesChanged((serverName, uri) => { - logger.debug("MCP resources changed", { path: `mcp:${serverName}`, uri }); - if (!settings.get("mcp.notifications")) return; - const debounceMs = settings.get("mcp.notificationDebounceMs"); - const key = `${serverName}:${uri}`; - const existing = notificationDebounceTimers.get(key); - if (existing) clearTimeout(existing); - notificationDebounceTimers.set( - key, - setTimeout(() => { - notificationDebounceTimers.delete(key); - // Re-check: user may have disabled notifications during the debounce window - if (!settings.get("mcp.notifications")) return; - session.yieldQueue.enqueue("mcp-notification", { serverName, uri }); - }, debounceMs), - ); - }); + wireMcpManagerCallbacks(mcpManager); } // Constructor-time workflow-gate tool restoration is deferred by one diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 01ef8b4455..0004a09bf3 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -2377,7 +2377,7 @@ export class AgentSession { */ readonly #ownedAsyncJobManager: AsyncJobManager | undefined; readonly #disposeAsyncJobManager: boolean; - readonly #ownedMcpManager: MCPManager | undefined; + #ownedMcpManager: MCPManager | undefined; #startupTurnBarrier: Promise | undefined; #pendingPythonMessages: Array<{ message: PythonExecutionMessage; @@ -2482,6 +2482,8 @@ export class AgentSession { #workspaceTreeService: LazyService | undefined; #onWorkspaceTreeReady: ((tree: WorkspaceTree) => void | Promise) | undefined; #networkPrewarmService: LazyService | undefined; + /** Set by `applyRescopedCwdState`: forces the next turn to re-scan at the new cwd. */ + #pendingWorkspaceTreeRescope = false; /** Throttle cache for the per-turn volatile workspace-tree scan (see #buildVolatileProjectContextMessage). */ #cachedWorkspaceTree: WorkspaceTree | undefined; #cachedWorkspaceTreeAt = 0; @@ -3919,6 +3921,61 @@ export class AgentSession { ...(this.#activeSkillState.sessionId ? { session_id: this.#activeSkillState.sessionId } : {}), }; } + /** + * Live prompt marker or restored durable workflow — the effective state the + * cwd-local mutation guard must honor after resume. + */ + getEffectiveActiveWorkflowSkillState(): { skill: string; sessionId: string } | undefined { + const currentSessionId = this.sessionManager.getSessionId(); + const inMemory = this.#activeSkillState; + if ( + inMemory && + (!inMemory.sessionId || inMemory.sessionId === currentSessionId) && + isCanonicalGjcWorkflowSkill(inMemory.skill) + ) { + return { skill: inMemory.skill, sessionId: inMemory.sessionId ?? currentSessionId }; + } + if ( + this.#restoredWorkflowSkillState?.sessionId === currentSessionId && + isCanonicalGjcWorkflowSkill(this.#restoredWorkflowSkillState.skill) + ) { + return this.#restoredWorkflowSkillState; + } + return undefined; + } + + /** Replace the session-owned MCP manager after a cwd rescope. */ + async replaceOwnedMcpManager(next: MCPManager | undefined): Promise { + const previous = this.#ownedMcpManager; + if (previous && previous !== next) { + await previous.disconnectAll().catch(() => {}); + if (MCPManager.instance() === previous) MCPManager.setInstance(undefined); + } + this.#ownedMcpManager = next; + if (next && MCPManager.instance() === undefined) MCPManager.setInstance(next); + } + + /** Swap named custom/project tools after a cwd rescope. */ + async replaceNamedCustomTools(previousNames: readonly string[], nextTools: CustomTool[]): Promise { + const previous = new Set(previousNames); + const previousActive = this.getActiveToolNames(); + for (const name of previous) this.#toolRegistry.delete(name); + const getCustomToolContext = () => this.#getCustomToolContext(); + const added: string[] = []; + for (const customTool of nextTools) { + const wrapped = CustomToolAdapter.wrap(customTool, getCustomToolContext) as AgentTool; + const finalTool = ( + this.#extensionRunner ? new ExtensionToolWrapper(wrapped, this.#extensionRunner) : wrapped + ) as AgentTool; + this.#toolRegistry.set(finalTool.name, finalTool); + added.push(finalTool.name); + } + this.#invalidateDiscoveryCaches(); + await this.#applyActiveToolsByName([ + ...previousActive.filter(name => !previous.has(name)), + ...added.filter(name => !previous.has(name) || previousActive.includes(name)), + ]); + } /** Best-effort accessor for the active skill's `current_phase` field from * its persisted mode-state file. Used by the `skill` tool to enforce the @@ -3949,13 +4006,7 @@ export class AgentSession { /** Provider-facing ask metadata must expose only the active deep-interview phase. */ getDeepInterviewAskStage(): "topology" | "post-topology" | undefined { const currentSessionId = this.sessionManager.getSessionId(); - const inMemory = this.#activeSkillState; - const active = - inMemory && (!inMemory.sessionId || inMemory.sessionId === currentSessionId) - ? inMemory - : this.#restoredWorkflowSkillState?.sessionId === currentSessionId - ? this.#restoredWorkflowSkillState - : undefined; + const active = this.getEffectiveActiveWorkflowSkillState(); if (active?.skill !== "deep-interview") return undefined; try { assertNonEmptyGjcSessionId(currentSessionId, "AgentSession.getDeepInterviewAskStage"); @@ -7525,6 +7576,7 @@ export class AgentSession { } async #dispose(): Promise { + await this.sessionManager.joinCwdTransition(); const admissionClosed = this.#closeSessionAdmission(); this.#isDisposed = true; // Reject new direct Python starts as soon as disposal begins (synchronously, @@ -8353,6 +8405,35 @@ export class AgentSession { }, }) as T; } + #wrapToolForCwdTransitionFence(tool: T): T { + if (tool.name === "move_session") return tool; + return new Proxy(tool, { + get: (target, prop) => { + if (prop !== "execute") return Reflect.get(target, prop, target); + return async ( + toolCallId: string, + args: unknown, + signal: AbortSignal | undefined, + onUpdate: never, + ctx: never, + ) => { + return await this.sessionManager.runWithCwdReadLease(async () => { + const admittedGeneration = this.sessionManager.getCwdGeneration(); + const result = await target.execute(toolCallId, args as never, signal, onUpdate, ctx); + // The lease keeps writers out for the whole execution, so this can + // only trip if a caller bypassed the lease; surface it rather than + // returning a result computed against a retired cwd. + if (this.sessionManager.getCwdGeneration() !== admittedGeneration) { + throw new Error( + "Session working directory changed while this tool executed; retry against the new cwd.", + ); + } + return result; + }); + }; + }, + }) as T; + } /** /** Wrap a tool with the workflow mutation guard before permissions or execution. */ @@ -8400,16 +8481,18 @@ export class AgentSession { let wrappersByVersion = this.#guardedToolWrapperCache.get(tool); const cached = wrappersByVersion?.get(cacheKey); if (cached) return cached as T; - const wrapped = this.#wrapToolForWorkflowMutationGuard( - this.#wrapToolForAcpPermission( - guardToolForUltragoalAsk( - tool, - () => this.sessionManager.getCwd(), - () => ({ - activeSkillState: this.getActiveSkillState(), - sessionId: this.sessionManager.getSessionId(), - }), - () => this.getSessionAgentDir(), + const wrapped = this.#wrapToolForCwdTransitionFence( + this.#wrapToolForWorkflowMutationGuard( + this.#wrapToolForAcpPermission( + guardToolForUltragoalAsk( + tool, + () => this.sessionManager.getCwd(), + () => ({ + activeSkillState: this.getActiveSkillState(), + sessionId: this.sessionManager.getSessionId(), + }), + () => this.getSessionAgentDir(), + ), ), ), ); @@ -9920,18 +10003,26 @@ export class AgentSession { this.#cachedWorkspaceTreeAt = Date.now(); this.#initialWorkspaceTree = undefined; includeTree = this.#cachedWorkspaceTree; - } else if (Date.now() - this.#cachedWorkspaceTreeAt >= VOLATILE_TREE_TTL_MS) { + } else if ( + this.#pendingWorkspaceTreeRescope || + Date.now() - this.#cachedWorkspaceTreeAt >= VOLATILE_TREE_TTL_MS + ) { + // A rescope retires the cached tree regardless of TTL, and must re-scan + // rather than reuse the launch-root snapshot the service already holds. + const rescoped = this.#pendingWorkspaceTreeRescope; + this.#pendingWorkspaceTreeRescope = false; if (this.#workspaceTreeService) { - const firstWorkspaceTree = this.#cachedWorkspaceTreeAt === 0; + const firstWorkspaceTree = this.#cachedWorkspaceTreeAt === 0 && !rescoped; const runtime = await this.#workspaceTreeService.get("first-turn-barrier"); this.#cachedWorkspaceTree = firstWorkspaceTree ? runtime.snapshot : await runtime.refresh(); - publishStableWorkspaceTree = firstWorkspaceTree; + publishStableWorkspaceTree = firstWorkspaceTree || rescoped; } else { try { this.#cachedWorkspaceTree = await buildWorkspaceTree(cwd, { timeoutMs: 5000 }); } catch { this.#cachedWorkspaceTree = undefined; } + publishStableWorkspaceTree = rescoped; } this.#cachedWorkspaceTreeAt = Date.now(); includeTree = this.#cachedWorkspaceTree; @@ -11926,6 +12017,27 @@ export class AgentSession { return this.#skills; } + /** + * Install the skill set discovered at a newly rescoped cwd (`move_session`). + * Project-scoped skills belong to the directory they were discovered in, so + * they must not survive a move out of it. + */ + async replaceSkills(skills: Skill[]): Promise { + this.#skills = skills; + await this.refreshBaseSystemPrompt(); + } + + /** + * Retire the cached workspace tree after a rescope so the next turn re-scans + * at the new cwd instead of re-presenting the abandoned launcher root. + */ + retireWorkspaceTreeForRescope(): void { + this.#initialWorkspaceTree = undefined; + this.#cachedWorkspaceTree = undefined; + this.#cachedWorkspaceTreeAt = 0; + this.#pendingWorkspaceTreeRescope = true; + } + /** Skill loading warnings captured by SDK */ get skillWarnings(): readonly SkillWarning[] { return this.#skillWarnings; diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index f54d26dfd5..2c88a34e33 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import * as crypto from "node:crypto"; import * as fs from "node:fs"; @@ -21,6 +22,11 @@ import type * as native from "@gajae-code/natives"; function nativeSessionManager(): typeof import("@gajae-code/natives") { return require("@gajae-code/natives") as typeof import("@gajae-code/natives"); } +const cwdTransitionAls = new AsyncLocalStorage(); +const CWD_NOFOLLOW_OPEN_FLAGS = + fs.constants.O_RDONLY | + (typeof fs.constants.O_DIRECTORY === "number" ? fs.constants.O_DIRECTORY : 0) | + (process.platform === "win32" ? 0 : (fs.constants.O_NOFOLLOW ?? 0)); import { getTerminalId } from "@gajae-code/tui"; import { @@ -7180,6 +7186,17 @@ export class SessionManager { /** Defense-in-depth (#4443): one-shot warn for adjacent private thinking blocks in persisted assistant transcripts. */ #warnedAdjacentThinkingPersist = false; #closeRetryPending = false; + /** Serializes model, SDK, and ACP cwd transitions; dispose joins this tail. */ + #cwdTransitionTail: Promise = Promise.resolve(); + #cwdTransitionOwner: symbol | undefined; + #cwdGeneration = 0; + /** Number of tool executions currently holding a shared read lease on `cwd`. */ + #cwdReaderCount = 0; + /** Resolved when the last outstanding read lease is released. */ + #cwdReadersDrained: (() => void) | undefined; + #cwdReadersIdle: Promise = Promise.resolve(); + /** Set while a writer is queued or running so new readers do not starve it. */ + #cwdWriterPending = 0; /** Depth of the non-yielding same-session persistence fence (reentrancy counter). */ #persistenceFenceDepth = 0; /** Publication fence counter carried by the mutable `.spill.commit` marker. */ @@ -10449,13 +10466,199 @@ export class SessionManager { } } + async #assertCwdTargetIdentity( + resolvedCwd: string, + options: { + expectedIdentity?: { dev: bigint; ino: bigint }; + targetHandle?: { stat: (opts: { bigint: true }) => Promise }; + }, + ): Promise { + let opened: fs.BigIntStats | undefined; + if (options.targetHandle) { + opened = await options.targetHandle.stat({ bigint: true }); + if (!opened.isDirectory()) { + throw new Error( + `Refusing to move through a replaced path: ${resolvedCwd} is no longer the validated directory.`, + ); + } + } + let observed: fs.BigIntStats; + try { + observed = await fs.promises.lstat(resolvedCwd, { bigint: true }); + } catch { + throw new Error(`Directory identity unavailable at state-changing boundary: ${resolvedCwd}`); + } + if (observed.isSymbolicLink() || !observed.isDirectory()) { + throw new Error( + `Refusing to move through a replaced path: ${resolvedCwd} is no longer the validated directory.`, + ); + } + if (opened && (observed.dev !== opened.dev || observed.ino !== opened.ino)) { + throw new Error(`Refusing to move: target identity changed at ${resolvedCwd}.`); + } + const expected = options.expectedIdentity; + const pinned = opened ?? observed; + if (expected && (pinned.dev !== expected.dev || pinned.ino !== expected.ino)) { + throw new Error(`Refusing to move: target identity changed at ${resolvedCwd}.`); + } + } + + /** + * Serialize every cwd transition (model, TUI, SDK/ACP). Re-entry is allowed + * only for the async context that already owns the lock — unrelated callers + * queue on the tail instead of skipping it. + */ + async runExclusiveCwdTransition(fn: () => Promise): Promise { + const owner = this.#cwdTransitionOwner; + if (owner !== undefined && cwdTransitionAls.getStore() === owner) { + return fn(); + } + const previous = this.#cwdTransitionTail; + const { promise, resolve } = Promise.withResolvers(); + this.#cwdTransitionTail = previous.then( + () => promise, + () => promise, + ); + // Announce the writer BEFORE awaiting the queue so readers arriving during + // the wait queue behind it rather than starving it indefinitely. + this.#cwdWriterPending += 1; + const token = Symbol("cwd-transition"); + try { + await previous.catch(() => {}); + // A reader that entered before this writer was announced still holds the + // old cwd; the transition may not commit until every such lease is + // released, otherwise an in-flight tool resolves paths across the move. + while (this.#cwdReaderCount > 0) await this.#cwdReadersIdle; + this.#cwdTransitionOwner = token; + return await cwdTransitionAls.run(token, fn); + } finally { + if (this.#cwdTransitionOwner === token) this.#cwdTransitionOwner = undefined; + this.#cwdWriterPending -= 1; + resolve(); + } + } + + /** + * Run `fn` under a shared read lease on `cwd`. + * + * Tools that resolve relative paths against the session cwd must hold this + * lease across their WHOLE execution, not merely re-check a generation before + * they start: the check-then-yield shape lets a move commit inside the tool's + * first `await`, so a command admitted for root A would execute in root B. + * Writers wait for outstanding leases to drain, so the cwd observed at lease + * acquisition stays authoritative until the lease is released. + */ + async runWithCwdReadLease(fn: () => Promise): Promise { + const owner = this.#cwdTransitionOwner; + // The writer's own async context already holds exclusive access; taking a + // read lease there would wait on itself. + if (owner !== undefined && cwdTransitionAls.getStore() === owner) return fn(); + while (this.#cwdWriterPending > 0) await this.#cwdTransitionTail.catch(() => {}); + if (this.#cwdReaderCount === 0) { + const { promise, resolve } = Promise.withResolvers(); + this.#cwdReadersIdle = promise; + this.#cwdReadersDrained = resolve; + } + this.#cwdReaderCount += 1; + try { + return await fn(); + } finally { + this.#cwdReaderCount -= 1; + if (this.#cwdReaderCount === 0) { + const drained = this.#cwdReadersDrained; + this.#cwdReadersDrained = undefined; + drained?.(); + } + } + } + + /** Wait for any in-flight exclusive cwd transition to settle. */ + async joinCwdTransition(): Promise { + await this.#cwdTransitionTail; + } + getCwdGeneration(): number { + return this.#cwdGeneration; + } + + #ownsCwdTransition(): boolean { + const owner = this.#cwdTransitionOwner; + return owner !== undefined && cwdTransitionAls.getStore() === owner; + } + static async openNoFollowDirectory(dir: string): Promise { + return fs.promises.open(dir, CWD_NOFOLLOW_OPEN_FLAGS); + } + + /** + * Owner of this process's cwd, or undefined when no session has claimed it. + * + * `process.chdir` and the caches keyed off it are process-global, so only one + * session may drive them. Ownership is an explicit claim rather than an + * inference from `process.cwd() === session.cwd`: two sessions launched at the + * same root both satisfy that comparison, and letting either one act on it + * lets a move in one session chdir the process under its sibling. + */ + static #processCwdOwner: WeakRef | undefined; + + /** + * Claim process-cwd authority for `manager` when it is unowned or the prior + * owner has been collected. Returns whether `manager` holds the claim. + */ + static claimProcessCwdOwnership(manager: SessionManager): boolean { + const current = SessionManager.#processCwdOwner?.deref(); + if (current === manager) return true; + if (current !== undefined) return false; + SessionManager.#processCwdOwner = new WeakRef(manager); + return true; + } + + static isProcessCwdOwner(manager: SessionManager): boolean { + return SessionManager.#processCwdOwner?.deref() === manager; + } + + static releaseProcessCwdOwnership(manager: SessionManager): void { + if (SessionManager.#processCwdOwner?.deref() === manager) SessionManager.#processCwdOwner = undefined; + } + + /** + * Verify that `process.cwd()` is the directory pinned by `expectedIdentity`. + * + * `process.chdir` resolves a NAME, so a path replaced after the last + * name-based comparison lands the process outside the validated directory — + * the exact confinement `move_session` exists to enforce. Node exposes no + * `fchdir`, so the handle cannot be the chdir authority directly; comparing + * the resulting cwd's identity to the pinned handle closes the same gap. + */ + static async assertProcessCwdIdentity(expectedIdentity: { dev: bigint; ino: bigint }): Promise { + const observed = await fs.promises.stat(process.cwd(), { bigint: true }); + if (observed.dev !== expectedIdentity.dev || observed.ino !== expectedIdentity.ino) { + throw new Error( + `Refusing to rescope: process cwd ${process.cwd()} is not the validated target directory (identity changed).`, + ); + } + } + /** * Move the session to a new working directory. * Moves session files and artifacts on disk, updates all internal references, * and rewrites the session header with the new cwd. + * + * All callers (model `move_session`, TUI `/move`, SDK/ACP `session.cwd.move`) + * share this exclusive transition so concurrent moves cannot interleave. */ - async moveTo(newCwd: string): Promise { + async moveTo( + newCwd: string, + options?: { + expectedIdentity?: { dev: bigint; ino: bigint }; + targetHandle?: { stat: (opts: { bigint: true }) => Promise }; + }, + ): Promise { + if (!this.#ownsCwdTransition()) { + return this.runExclusiveCwdTransition(() => this.moveTo(newCwd, options)); + } const resolvedCwd = path.resolve(newCwd); + if (options?.expectedIdentity || options?.targetHandle) { + await this.#assertCwdTargetIdentity(resolvedCwd, options); + } if (resolvedCwd === this.cwd) return; const previousCwd = this.cwd; const previousSessionDir = this.sessionDir; @@ -10700,6 +10903,10 @@ export class SessionManager { // Update cwd and sessionDir after physical publication succeeds. Metadata failures restore the source // authority but deliberately retain any destination publication evidence rather than deleting it. + if (options?.expectedIdentity || options?.targetHandle) { + await this.#assertCwdTargetIdentity(resolvedCwd, options); + } + this.#cwdGeneration += 1; this.cwd = resolvedCwd; this.sessionDir = newSessionDir; this.destination = nextDestination; @@ -15216,6 +15423,8 @@ export class SessionManager { /** Close the persistent writer after flushing all pending data. */ async close(): Promise { + await this.joinCwdTransition(); + SessionManager.releaseProcessCwdOwnership(this); // Drain any uncommitted prepared successors before releasing resources so // dispose/shutdown retains exact cleanup authority (#3138). try { diff --git a/packages/coding-agent/src/tools/descriptors.test.ts b/packages/coding-agent/src/tools/descriptors.test.ts index ca3fe90b2d..c219bb494f 100644 --- a/packages/coding-agent/src/tools/descriptors.test.ts +++ b/packages/coding-agent/src/tools/descriptors.test.ts @@ -56,6 +56,7 @@ function makeSession(overrides: Record = {}): any { requireYieldTool: false, enableLsp: true, taskDepth: 0, + rescopeSessionCwd: async () => ({ from: process.cwd(), to: process.cwd() }), getSessionFile: () => null, getSessionSpawns: () => null, }; @@ -125,6 +126,7 @@ describe("tool descriptor compatibility gate", () => { "write", "skill", "goal", + "move_session", ]; expect(Object.keys(BUILTIN_TOOLS)).toEqual(expectedBuiltin); expect(Object.keys(BUILTIN_TOOL_DESCRIPTORS)).toEqual(expectedBuiltin); diff --git a/packages/coding-agent/src/tools/descriptors.ts b/packages/coding-agent/src/tools/descriptors.ts index 1d0f57e8f2..7f63c6d303 100644 --- a/packages/coding-agent/src/tools/descriptors.ts +++ b/packages/coding-agent/src/tools/descriptors.ts @@ -244,6 +244,7 @@ function defaultAvailabilityContext(session: ToolSession): ToolAvailabilityConte function availableFor(name: string, session: ToolSession, context = defaultAvailabilityContext(session)): boolean { if (name === "goal") return context.goalEnabled === true; + if (name === "move_session") return typeof session.rescopeSessionCwd === "function"; if (context.goalStateToolNames?.includes(name)) return context.goalEnabled === true; if (name === "lsp") return (context.enableLsp ?? true) && Boolean(session.settings.get("lsp.enabled")); if (name === "eval") return context.allowEval ?? true; @@ -296,6 +297,9 @@ function cached(key: string, load: () => Promise): Promise { } return promise; } +export function evictCachedTool(key: string): void { + moduleCache.delete(key); +} const loaders: Record = { read: session => cached("read", () => import("./read")).then(module => new module.ReadTool(session)), @@ -328,6 +332,7 @@ const loaders: Record = { cached("python", () => import("./python")).then(module => module.createSessionPythonTool({ cwd: session.cwd, + getCwd: () => session.cwd, getSessionId: () => session.getSessionId?.() ?? null, registerSessionCleanup: (cleanup: () => Promise | void) => { session.registerSessionCleanup?.(cleanup); @@ -360,6 +365,8 @@ const loaders: Record = { skill: session => cached("skill", () => import("./skill")).then(module => module.SkillTool.createIf(session)), goal: session => cached("goal", () => import("../goals/tools/goal-tool")).then(module => new module.GoalTool(session)), + move_session: session => + cached("move_session", () => import("./move-session")).then(module => new module.MoveSessionTool(session)), yield: session => cached("yield", () => import("./yield")).then(module => new module.YieldTool(session)), report_finding: _session => cached("review", () => import("./review")).then(module => module.reportFindingTool), resolve: session => cached("resolve", () => import("./resolve")).then(module => new module.ResolveTool(session)), @@ -445,6 +452,7 @@ const names: Array<[name: string, label: string, summary: string | undefined, lo ["write", "Write", "Write content to a file", "discoverable"], ["skill", "Skill", "Chain into another available skill", "essential"], ["goal", "Goal", undefined, "essential"], + ["move_session", "Move Session", undefined, "essential"], ]; const descriptorRawArgumentValidations: Readonly> = { diff --git a/packages/coding-agent/src/tools/index.ts b/packages/coding-agent/src/tools/index.ts index c6b9409828..98da017957 100644 --- a/packages/coding-agent/src/tools/index.ts +++ b/packages/coding-agent/src/tools/index.ts @@ -332,6 +332,18 @@ export interface ToolSession { goalToolAllowedOps?: readonly ("create" | "get" | "complete" | "resume" | "drop" | "pause")[]; /** Goal runtime for the active agent session. */ getGoalRuntime?: () => GoalRuntime | undefined; + /** + * Agent-invokable session rescope (issue #4629): move the whole session to + * an existing directory, running the same sequence as the `/move` handler + * (flush → moveTo → setProjectDir → plugin/capability cache reset). Absent + * in contexts where relocation must not happen: subagent sessions + * (taskDepth > 0) and read-only/restricted bash profiles. Bound to one + * successful move per session, rejects re-entrant calls, and only narrows: + * the canonical (realpath) target must be a strict descendant of the + * canonical current cwd. Throws when the target does not exist, is not a + * directory, or escapes the current scope. + */ + rescopeSessionCwd?: (path: string) => Promise<{ from: string; to: string }>; /** Bridge to the connected client (e.g. ACP editor host). Tools should route fs/terminal/permission requests through this when available. */ getClientBridge?: () => ClientBridge | undefined; /** Get compact conversation context for subagents (excludes tool results, system prompts) */ diff --git a/packages/coding-agent/src/tools/move-session.ts b/packages/coding-agent/src/tools/move-session.ts new file mode 100644 index 0000000000..13899cd7f7 --- /dev/null +++ b/packages/coding-agent/src/tools/move-session.ts @@ -0,0 +1,107 @@ +import type { AgentTool, AgentToolResult } from "@gajae-code/agent-core"; +import type { Component } from "@gajae-code/tui"; +import { Text } from "@gajae-code/tui"; +import { prompt, sanitizeText } from "@gajae-code/utils"; +import * as z from "zod/v4"; +import type { RenderResultOptions } from "../extensibility/custom-tools/types"; +import type { Theme } from "../modes/theme/theme"; +import moveSessionDescription from "../prompts/tools/move-session.md" with { type: "text" }; +import type { ToolSession } from "./index"; +import { Ellipsis, replaceTabs, shortenPath, truncateToWidth } from "./render-utils"; +import { ToolError } from "./tool-errors"; + +const moveSessionSchema = z.object({ + path: z.string().describe("target directory: absolute, or relative to the current session cwd"), +}); + +export type MoveSessionToolInput = z.infer; + +export interface MoveSessionToolDetails { + from: string; + to: string; +} + +export class MoveSessionTool implements AgentTool { + readonly name = "move_session"; + readonly label = "Move Session"; + readonly loadMode = "essential" as const; + readonly description = prompt.render(moveSessionDescription); + readonly parameters = moveSessionSchema; + readonly strict = true; + readonly intent = "omit" as const; + // The move chdirs the process and resets global caches; it must not + // interleave with other tool executions across flush→moveTo→chdir. + readonly concurrency = "exclusive" as const; + // The move commits session-file relocation; aborting mid-sequence would + // leave a half-moved session with no rollback path. + readonly nonAbortable = true; + readonly #session: ToolSession; + + constructor(session: ToolSession) { + this.#session = session; + } + + async execute(_toolCallId: string, params: MoveSessionToolInput): Promise> { + const rescope = this.#session.rescopeSessionCwd; + if (!rescope) { + throw new ToolError( + "This session cannot rescope its working directory; only top-level unrestrained sessions can move.", + ); + } + if (typeof params.path !== "string" || params.path.trim() === "") { + throw new ToolError("path is required and must be a directory path."); + } + let moved: { from: string; to: string }; + try { + moved = await rescope(params.path); + } catch (error) { + throw new ToolError(error instanceof Error ? error.message : String(error)); + } + return { + content: [{ type: "text", text: `Session moved to ${moved.to} (from ${moved.from}).` }], + details: { from: moved.from, to: moved.to }, + }; + } +} + +interface MoveSessionRenderArgs { + from: string; + to: string; +} + +const MOVE_SESSION_PREVIEW_WIDTH = 120; + +export const moveSessionToolRenderer = { + renderCall: (args: unknown): Component => { + const target = + typeof args === "object" && args !== null && typeof (args as { path?: unknown }).path === "string" + ? (args as { path: string }).path + : ""; + const text = truncateToWidth( + replaceTabs(sanitizeText(`move_session ${target}`)), + MOVE_SESSION_PREVIEW_WIDTH, + Ellipsis.Omit, + ); + return new Text(text, 1, 1); + }, + renderResult: ( + result: { details?: unknown; isError?: boolean }, + _options: RenderResultOptions & { renderContext?: Record }, + theme: Theme, + ): Component => { + const details = (result.details ?? {}) as Partial; + const from = typeof details.from === "string" ? details.from : ""; + const to = typeof details.to === "string" ? details.to : ""; + const body = result.isError + ? "move_session failed" + : `Session moved: ${shortenPath(sanitizeText(from))} → ${shortenPath(sanitizeText(to))}`; + return new Text( + theme.fg( + result.isError ? "error" : "accent", + truncateToWidth(replaceTabs(sanitizeText(body)), MOVE_SESSION_PREVIEW_WIDTH, Ellipsis.Omit), + ), + 1, + 1, + ); + }, +}; diff --git a/packages/coding-agent/src/tools/renderers.ts b/packages/coding-agent/src/tools/renderers.ts index b987be2bf9..6cbd797403 100644 --- a/packages/coding-agent/src/tools/renderers.ts +++ b/packages/coding-agent/src/tools/renderers.ts @@ -23,6 +23,7 @@ import { evalToolRenderer } from "./eval"; import { findToolRenderer } from "./find"; import { githubToolRenderer } from "./gh-renderer"; import { jobToolRenderer } from "./job"; +import { moveSessionToolRenderer } from "./move-session"; import { readToolRenderer } from "./read"; import { recipeToolRenderer } from "./recipe/render"; import { resolveToolRenderer } from "./resolve"; @@ -72,6 +73,7 @@ export const toolRenderers: Record = { todo_write: todoWriteToolRenderer as ToolRenderer, github: githubToolRenderer as ToolRenderer, goal: goalToolRenderer as ToolRenderer, + move_session: moveSessionToolRenderer as ToolRenderer, web_search: webSearchToolRenderer as ToolRenderer, write: writeToolRenderer as ToolRenderer, }; diff --git a/packages/coding-agent/src/tools/tool-catalog.generated.ts b/packages/coding-agent/src/tools/tool-catalog.generated.ts index 0daacc8481..3d0d24bc17 100644 --- a/packages/coding-agent/src/tools/tool-catalog.generated.ts +++ b/packages/coding-agent/src/tools/tool-catalog.generated.ts @@ -2828,7 +2828,7 @@ export const TOOL_CATALOG: Readonly> = { "skill": { "name": "skill", "label": "Skill", - "description": "Invoke another available skill in the current turn.\n\n\n- A SKILL document instructs you to chain into another skill on completion (e.g. ralplan → ultragoal)\n- You finished one skill's workflow and the next step requires another skill's full prompt context\n\n\n\n- `name` is the skill name as it appears in `/skill:` (e.g. `ralplan`, `ultragoal`, `autoresearch`, `deep-interview`)\n- `args` is the free-form argument string the skill would receive after `/skill:` on the command line\n- The tool loads the callee's SKILL.md into the current turn and handles native workflow caller→callee state handoff when the caller is one of the built-in GJC workflows.\n- The chain is refused while a native workflow caller is still active. If your current skill is one of `deep-interview`, `ralplan`, `ultragoal`, or `autoresearch` and has not yet reached a terminal phase, prepare it first with `gjc state write --input '{\"current_phase\":\"handoff\"}' --json`; no other handoff command is needed. Runtime project/user skills do not use `gjc state `.\n- Call once per chain step. To chain `A → B → C`, A calls `skill(B)`; B's next agent turn calls `skill(C)`.\n\n\n\n- Do NOT use this tool to \"remind yourself\" of a skill you're already running. The current SKILL.md is already in your context.\n- Do NOT chain into the same skill recursively. If a skill's flow needs another iteration, follow its in-document instructions.\n- `name` MUST be one concrete skill name, NOT a glob or wildcard. Passing `*`, `?`, or a pattern like `git-*` is rejected immediately — the `--skills '*'` launch filter is unrelated to this tool's `name`.\n- The chained skill's planning/execution-boundary rules still apply. Chaining does not grant execution approval.\n\n\n\n# Hand off from ralplan to ultragoal after an approved plan\n{\"name\": \"ultragoal\", \"args\": \"track execution of .gjc/plans/ralplan//pending-approval.md\"}\n\n# Trigger deep-interview with no arguments\n{\"name\": \"deep-interview\"}\n", + "description": "Invoke another available skill in the current turn.\n\n\n- A SKILL document instructs you to chain into another skill on completion (e.g. ralplan → ultragoal)\n- You finished one skill's workflow and the next step requires another skill's full prompt context\n\n\n\n- `name` is the skill name as it appears in `/skill:` (e.g. `ralplan`, `ultragoal`, `autoresearch`, `deep-interview`)\n- `args` is the free-form argument string the skill would receive after `/skill:` on the command line\n- The tool loads the callee's SKILL.md into the current turn and handles native workflow caller→callee state handoff when the caller is one of the built-in GJC workflows.\n- The chain is refused while a native workflow caller is still mid-flight. `autoresearch` chains from any of its phases (`intake`/`research`/`verdict`) — a research mission is always handoff-ready. `deep-interview` chains once its final spec is persisted (phase `handoff`), and `ralplan` chains from `final` or `handoff`. Only a mid-flight `ralplan` or `ultragoal` needs preparation first: `gjc state write --input '{\"current_phase\":\"handoff\"}' --json`; no other handoff command is needed. Runtime project/user skills do not use `gjc state `.\n- Call once per chain step. To chain `A → B → C`, A calls `skill(B)`; B's next agent turn calls `skill(C)`.\n\n\n\n- Do NOT use this tool to \"remind yourself\" of a skill you're already running. The current SKILL.md is already in your context.\n- Do NOT chain into the same skill recursively. If a skill's flow needs another iteration, follow its in-document instructions.\n- `name` MUST be one concrete skill name, NOT a glob or wildcard. Passing `*`, `?`, or a pattern like `git-*` is rejected immediately — the `--skills '*'` launch filter is unrelated to this tool's `name`.\n- The chained skill's planning/execution-boundary rules still apply. Chaining does not grant execution approval.\n\n\n\n# Hand off from ralplan to ultragoal after an approved plan\n{\"name\": \"ultragoal\", \"args\": \"track execution of .gjc/plans/ralplan//pending-approval.md\"}\n\n# Trigger deep-interview with no arguments\n{\"name\": \"deep-interview\"}\n", "parameters": { "type": "object", "properties": { @@ -2885,6 +2885,30 @@ export const TOOL_CATALOG: Readonly> = { "loadMode": "essential", "intent": "omit" }, + "move_session": { + "name": "move_session", + "label": "Move Session", + "description": "Rescope the session to a narrower working directory.\n\nUse this only when the session's working directory is a broad launcher root (for example a\nmulti-repo workspace like `~/Projects`) and the task has clearly converged on one subdirectory\nor repository: after this call, every later turn resolves relative paths and the bash default\ncwd from the new directory, and project-scoped plugins/capabilities reload for it.\n\n- `path` must be an existing directory; relative paths resolve against the current session cwd.\n The canonical target must be strictly inside the current session directory — moves to a\n parent, a sibling project, or an unrelated absolute path are refused.\n- A session can be moved this way at most once, and never while another move is running; a\n rejected call does not consume the move. Use it once the target repo is identified — not\n speculatively — because the session file and caches move with the session.\n- This tool is unavailable in subagent sessions and restricted profiles; ask the top-level\n session to rescope instead.", + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "target directory: absolute, or relative to the current session cwd" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + "strict": true, + "deferrable": false, + "loadMode": "essential", + "nonAbortable": true, + "concurrency": "exclusive", + "intent": "omit" + }, "yield": { "name": "yield", "label": "Submit Result", diff --git a/packages/coding-agent/test/move-session-tool.test.ts b/packages/coding-agent/test/move-session-tool.test.ts new file mode 100644 index 0000000000..406d0ffc66 --- /dev/null +++ b/packages/coding-agent/test/move-session-tool.test.ts @@ -0,0 +1,911 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { getBundledModel } from "@gajae-code/ai"; +import { AsyncJobManager } from "@gajae-code/coding-agent/async"; +import { Settings } from "@gajae-code/coding-agent/config/settings"; +import { createAgentSession } from "@gajae-code/coding-agent/sdk"; +import { SKILL_PROMPT_MESSAGE_TYPE } from "@gajae-code/coding-agent/session/messages"; +import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; +import { Snowflake } from "@gajae-code/utils"; +import { syncSkillActiveState } from "../src/skill-state/active-state"; +import { moveSessionToolRenderer } from "../src/tools/move-session"; + +function textContent(result: { content?: Array<{ type: string; text?: string }> }): string { + return ( + result.content + ?.filter( + (block): block is { type: "text"; text: string } => block.type === "text" && typeof block.text === "string", + ) + .map(block => block.text) + .join("\n") ?? "" + ); +} + +describe("move_session tool (agent-invokable session rescope)", () => { + const tempDirs: string[] = []; + // The accessor's setProjectDir() chdirs the process into the moved + // directory; tests must restore the original cwd before their temp roots + // are deleted, or every later shell init in this process fails with a + // dead getcwd (matches the real /move semantics: the process follows). + const processCwdAtStart = process.cwd(); + + afterEach(() => { + if (process.cwd() !== processCwdAtStart) { + process.chdir(processCwdAtStart); + } + for (const tempDir of tempDirs.splice(0)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + async function makeSession(cwd: string, sessionManager: SessionManager, overrides: Record = {}) { + return createAgentSession({ + cwd, + agentDir: path.dirname(cwd), + sessionManager, + settings: Settings.isolated({ + "async.enabled": false, + "bash.autoBackground.enabled": false, + "bashInterceptor.enabled": false, + }), + model: getBundledModel("openai", "gpt-4o-mini"), + disableExtensionDiscovery: true, + skills: [], + contextFiles: [], + promptTemplates: [], + slashCommands: [], + enableMCP: false, + enableLsp: false, + ...overrides, + }); + } + + it("exposes move_session in a top-level session and moves tool cwd", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const cwdB = path.join(tempDir, "root", "repo-b"); + fs.mkdirSync(cwdA, { recursive: true }); + fs.mkdirSync(cwdB, { recursive: true }); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session", "bash"] }); + try { + expect(session.getToolByName("move_session")).toBeDefined(); + expect(sessionManager.getCwd()).toBe(cwdA); + + const moveTool = session.getToolByName("move_session")!; + const result = await moveTool.execute("move-1", { path: cwdB }); + + expect(textContent(result)).toContain(cwdB); + expect(sessionManager.getCwd()).toBe(cwdB); + + // The bash tool's default cwd follows the move, like /move. + const bashTool = session.getToolByName("bash")!; + const pwd = await bashTool.execute("pwd-after-move-session", { command: "pwd" }); + expect(textContent(pwd)).toContain(cwdB); + } finally { + await session.dispose(); + } + }, 20_000); + it("lets a sequential fenced bash call follow a completed move", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const cwdB = path.join(cwdA, "repo-b"); + fs.mkdirSync(cwdB, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session", "bash"] }); + try { + const moveTool = session.getToolByName("move_session")!; + await moveTool.execute("move-then-bash", { path: "repo-b" }); + const bashTool = session.getToolForExecution("bash")!; + const pwd = await bashTool.execute("pwd-after-fenced-move", { command: "pwd" }); + expect(textContent(pwd)).toContain("repo-b"); + } finally { + await session.dispose(); + } + }); + + it("refuses an unreadable target without moving", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const locked = path.join(cwdA, "locked"); + fs.mkdirSync(locked, { recursive: true }); + fs.chmodSync(locked, 0); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const moveTool = session.getToolByName("move_session")!; + await expect(moveTool.execute("move-unreadable", { path: "locked" })).rejects.toThrow( + /access unavailable|permission|EACCES/i, + ); + expect(sessionManager.getCwd()).toBe(cwdA); + } finally { + fs.chmodSync(locked, 0o755); + await session.dispose(); + } + }); + + it("resolves a relative target against the current session cwd", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const moveTool = session.getToolByName("move_session")!; + const result = await moveTool.execute("move-2", { path: "repo-b" }); + expect(sessionManager.getCwd()).toBe(repoB); + const details = (result as { details?: { from?: string; to?: string } }).details ?? {}; + expect(details.from).toBe(cwdA); + expect(details.to).toBe(repoB); + } finally { + await session.dispose(); + } + }); + + it("rejects a missing directory instead of moving", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + fs.mkdirSync(cwdA, { recursive: true }); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const moveTool = session.getToolByName("move_session")!; + const missing = path.join(tempDir, "does-not-exist"); + let error: unknown; + try { + await moveTool.execute("move-3", { path: missing }); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(String((error as Error)?.message ?? error)).toContain(missing); + expect(sessionManager.getCwd()).toBe(cwdA); + } finally { + await session.dispose(); + } + }); + + it("does not expose move_session in subagent sessions (taskDepth > 0)", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + fs.mkdirSync(cwdA, { recursive: true }); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { + toolNames: ["move_session"], + taskDepth: 1, + currentAgentType: "executor", + }); + try { + expect(session.getToolByName("move_session")).toBeUndefined(); + } finally { + await session.dispose(); + } + }); + + it("does not expose move_session in canonical sub-sessions identified by parentTaskPrefix or currentAgentType alone", async () => { + for (const overrides of [{ parentTaskPrefix: "0-Worker" }, { currentAgentType: "executor" }] as Array< + Record + >) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + fs.mkdirSync(cwdA, { recursive: true }); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"], ...overrides }); + try { + expect( + session.getToolByName("move_session"), + `sub-session with ${Object.keys(overrides)[0]} must not expose move_session`, + ).toBeUndefined(); + } finally { + await session.dispose(); + } + } + }); + + it("refuses to move while a workflow skill is active", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const activated = Promise.withResolvers(); + const unsubscribe = session.subscribe(event => { + if ( + event.type === "message_start" && + event.message.role === "custom" && + event.message.customType === SKILL_PROMPT_MESSAGE_TYPE + ) + activated.resolve(); + }); + session.agent.emitExternalEvent({ + type: "message_start", + message: { + role: "custom", + customType: SKILL_PROMPT_MESSAGE_TYPE, + content: "# Deep Interview", + display: true, + details: { name: "deep-interview" }, + attribution: "agent", + timestamp: Date.now(), + }, + }); + await activated.promise; + unsubscribe(); + expect(session.getActiveSkillState()).toMatchObject({ skill: "deep-interview" }); + + const moveTool = session.getToolByName("move_session")!; + let error: unknown; + try { + await moveTool.execute("move-during-workflow", { path: "repo-b" }); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(String((error as Error)?.message ?? error)).toContain("workflow skill is active"); + expect(sessionManager.getCwd()).toBe(cwdA); + } finally { + await session.dispose(); + } + }); + + it("does not expose move_session under a read-only bash restriction profile", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + fs.mkdirSync(cwdA, { recursive: true }); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { + toolNames: ["move_session"], + bashRestrictionProfile: "read-only", + }); + try { + expect(session.getToolByName("move_session")).toBeUndefined(); + } finally { + await session.dispose(); + } + }); + it("does not expose move_session when caller-owned MCP or a frozen workspace tree is bound", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + fs.mkdirSync(cwdA, { recursive: true }); + const frozenTree = { cwd: cwdA, entries: [], agentsMdFiles: [] }; + const withTree = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const treeSession = await makeSession(cwdA, withTree, { + toolNames: ["move_session"], + workspaceTree: frozenTree, + }); + try { + expect(treeSession.session.getToolByName("move_session")).toBeUndefined(); + } finally { + await treeSession.session.dispose(); + } + const withMcp = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const mcpSession = await makeSession(cwdA, withMcp, { + toolNames: ["move_session"], + mcpManager: { connectServers() {} }, + }); + try { + expect(mcpSession.session.getToolByName("move_session")).toBeUndefined(); + } finally { + await mcpSession.session.dispose(); + } + }); + + it("refuses to rescope outside the current session directory", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const outside = path.join(tempDir, "sibling"); + fs.mkdirSync(cwdA, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const moveTool = session.getToolByName("move_session")!; + for (const target of [outside, "..", path.dirname(cwdA), "/"]) { + let error: unknown; + try { + await moveTool.execute(`move-outside-${target}`, { path: target }); + } catch (err) { + error = err; + } + expect(error, `target ${target} must be refused`).toBeDefined(); + expect(String((error as Error)?.message ?? error)).toContain("only narrows"); + expect(sessionManager.getCwd()).toBe(cwdA); + } + // A refused move does not consume the one-move bound. + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const result = await moveTool.execute("move-after-refusals", { path: "repo-b" }); + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + expect(textContent(result)).toContain("repo-b"); + } finally { + await session.dispose(); + } + }); + + it("rejects moving to the current directory itself", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + fs.mkdirSync(cwdA, { recursive: true }); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const moveTool = session.getToolByName("move_session")!; + let error: unknown; + try { + await moveTool.execute("move-self", { path: "." }); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(String((error as Error)?.message ?? error)).toContain("nothing to move"); + expect(sessionManager.getCwd()).toBe(cwdA); + } finally { + await session.dispose(); + } + }); + + it("allows only one successful move per session", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + const deeper = path.join(cwdA, "repo-b", "pkg"); + fs.mkdirSync(deeper, { recursive: true }); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const moveTool = session.getToolByName("move_session")!; + await moveTool.execute("move-first", { path: "repo-b" }); + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + + let error: unknown; + try { + await moveTool.execute("move-second", { path: "pkg" }); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(String((error as Error)?.message ?? error)).toContain("only one agent-invoked move"); + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + } finally { + await session.dispose(); + } + }); + + it("canonicalizes a symlinked target to its realpath", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const realRepo = path.join(cwdA, "real-repo"); + const link = path.join(cwdA, "link-repo"); + fs.mkdirSync(realRepo, { recursive: true }); + fs.symlinkSync(realRepo, link); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const moveTool = session.getToolByName("move_session")!; + const result = await moveTool.execute("move-symlink", { path: "link-repo" }); + const canonical = fs.realpathSync(realRepo); + expect(sessionManager.getCwd()).toBe(canonical); + const details = (result as { details?: { to?: string } }).details ?? {}; + expect(details.to).toBe(canonical); + } finally { + await session.dispose(); + } + }); + + it("accepts a child literally named with leading dots (not a parent escape)", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const dotted = path.join(cwdA, "..dots"); + fs.mkdirSync(dotted, { recursive: true }); + + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const moveTool = session.getToolByName("move_session")!; + const result = await moveTool.execute("move-dotted-child", { path: "..dots" }); + expect(sessionManager.getCwd()).toBe(fs.realpathSync(dotted)); + expect(textContent(result)).toContain("..dots"); + } finally { + await session.dispose(); + } + }); + it("does not expose move_session under bashAllowedPrefixes", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + fs.mkdirSync(cwdA, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { + toolNames: ["move_session"], + bashAllowedPrefixes: ["/usr/bin"], + }); + try { + expect(session.getToolByName("move_session")).toBeUndefined(); + } finally { + await session.dispose(); + } + }); + + it("refuses to move when a restored workflow is active without a live prompt marker", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + await syncSkillActiveState({ + cwd: cwdA, + sessionId: sessionManager.getSessionId(), + skill: "deep-interview", + phase: "interview", + active: true, + }); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + expect(session.getActiveSkillState()).toBeUndefined(); + expect(session.getEffectiveActiveWorkflowSkillState()).toMatchObject({ skill: "deep-interview" }); + const moveTool = session.getToolByName("move_session")!; + let error: unknown; + try { + await moveTool.execute("move-restored-workflow", { path: "repo-b" }); + } catch (err) { + error = err; + } + expect(error).toBeDefined(); + expect(String((error as Error)?.message ?? error)).toContain("workflow skill is active"); + expect(sessionManager.getCwd()).toBe(cwdA); + } finally { + await session.dispose(); + } + }); + + it("queues an unrelated cwd transition instead of skipping the lock", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + fs.mkdirSync(cwdA, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const hold = Promise.withResolvers(); + const firstEntered = Promise.withResolvers(); + let secondEntered = false; + const first = sessionManager.runExclusiveCwdTransition(async () => { + firstEntered.resolve(); + await hold.promise; + }); + await firstEntered.promise; + const second = sessionManager.runExclusiveCwdTransition(async () => { + secondEntered = true; + }); + await Bun.sleep(40); + expect(secondEntered).toBe(false); + hold.resolve(); + await Promise.all([first, second]); + expect(secondEntered).toBe(true); + await sessionManager.close(); + }); + + it("serializes overlapping model and SessionManager moves", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + const repoC = path.join(cwdA, "repo-c"); + fs.mkdirSync(repoB, { recursive: true }); + fs.mkdirSync(repoC, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const hold = Promise.withResolvers(); + const firstEntered = Promise.withResolvers(); + const first = sessionManager.runExclusiveCwdTransition(async () => { + firstEntered.resolve(); + await hold.promise; + await sessionManager.moveTo(repoB); + }); + await firstEntered.promise; + let acpDone = false; + const acp = sessionManager.moveTo(repoC).then(() => { + acpDone = true; + }); + await Bun.sleep(40); + expect(acpDone).toBe(false); + hold.resolve(); + await first; + await acp; + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoC)); + } finally { + await session.dispose(); + } + }); + it("does not steal process cwd from a sibling session launched at the same root", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + // Two sessions sharing ONE root: `process.cwd() === session.cwd` holds for + // both, so an inferred ownership check would let the second session chdir + // the process out from under the first. + const managerA = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const managerB = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const ownsA = SessionManager.claimProcessCwdOwnership(managerA); + expect(ownsA).toBe(true); + // The sibling cannot take the claim while the first owner is alive. + expect(SessionManager.claimProcessCwdOwnership(managerB)).toBe(false); + const { session } = await makeSession(cwdA, managerB, { toolNames: ["move_session"] }); + const processBefore = process.cwd(); + try { + const moveTool = session.getToolByName("move_session")!; + await moveTool.execute("move-no-steal", { path: "repo-b" }); + // The non-owner's session moved, but the shared process cwd is untouched. + expect(managerB.getCwd()).toBe(fs.realpathSync(repoB)); + expect(process.cwd()).toBe(processBefore); + expect(managerA.getCwd()).toBe(cwdA); + } finally { + await session.dispose(); + await managerA.close(); + } + }); + + it("holds a cwd read lease across a tool's whole execution, not just its admission", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + fs.writeFileSync(path.join(cwdA, "marker-root"), "root"); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session", "bash"] }); + try { + const bashTool = session.getToolForExecution("bash")!; + // Admit the command FIRST, then start the move while it is mid-flight: + // this is the window in which a check-then-yield fence would let a + // root-A command execute in root B. + const bashRun = bashTool.execute("pwd-mid-move", { command: "sleep 0.3; pwd; ls marker-root" }); + await Bun.sleep(60); + const moveStarted = sessionManager.runExclusiveCwdTransition(async () => { + await sessionManager.moveTo(repoB); + }); + const bashResult = await bashRun; + const output = textContent(bashResult as { content?: Array<{ type: string; text?: string }> }); + // The command ran entirely in root A: it saw root A's cwd and its marker. + expect(output).toContain(fs.realpathSync(cwdA)); + expect(output).toContain("marker-root"); + expect(output).not.toContain("No such file"); + await moveStarted; + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + } finally { + await session.dispose(); + } + }); + + it("fences an async bash job to the cwd it was admitted for", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { + toolNames: ["move_session", "bash"], + settings: Settings.isolated({ + "async.enabled": true, + "bash.autoBackground.enabled": false, + "bashInterceptor.enabled": false, + }), + }); + try { + const bashTool = session.getToolForExecution("bash")!; + // The async job is admitted (and its cwd captured) while the session is + // still at root A; it then outlives the tool call. + const started = await bashTool.execute("async-pwd", { + command: "sleep 0.4; pwd", + async: true, + }); + const jobId = (started as { details?: { async?: { jobId?: string } } }).details?.async?.jobId; + expect(typeof jobId).toBe("string"); + // Move the session while the admitted job is still running. + const moveTool = session.getToolByName("move_session")!; + await moveTool.execute("move-during-async", { path: "repo-b" }); + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + const manager = AsyncJobManager.instance(); + expect(manager).toBeDefined(); + let output = ""; + for (let attempt = 0; attempt < 80; attempt++) { + const job = manager!.getJob(jobId!); + if (job && (job.status === "completed" || job.status === "failed")) { + output = job.resultText ?? manager!.readOutputSince(jobId!, 0)?.text ?? ""; + break; + } + await Bun.sleep(50); + } + expect(output.length).toBeGreaterThan(0); + // The job ran in the cwd it was ADMITTED for, not the post-move cwd: + // a job admitted against root A must never silently execute in root B. + expect(output).toContain(fs.realpathSync(cwdA)); + expect(output.trim().endsWith(fs.realpathSync(repoB))).toBe(false); + } finally { + await session.dispose(); + } + }, 20_000); + + it("makes a writer wait for an outstanding read lease instead of committing under it", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const releaseReader = Promise.withResolvers(); + const readerEntered = Promise.withResolvers(); + let cwdSeenAtRelease = ""; + const reader = sessionManager.runWithCwdReadLease(async () => { + readerEntered.resolve(); + await releaseReader.promise; + cwdSeenAtRelease = sessionManager.getCwd(); + }); + await readerEntered.promise; + let moveCommitted = false; + const writer = sessionManager.runExclusiveCwdTransition(async () => { + await sessionManager.moveTo(repoB); + moveCommitted = true; + }); + await Bun.sleep(50); + // Writer is queued behind the live lease, so cwd is still root A. + expect(moveCommitted).toBe(false); + expect(sessionManager.getCwd()).toBe(cwdA); + releaseReader.resolve(); + await reader; + await writer; + expect(cwdSeenAtRelease).toBe(cwdA); + expect(moveCommitted).toBe(true); + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + await sessionManager.close(); + }); + + it("does not starve a queued writer behind a stream of new readers", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const releaseFirst = Promise.withResolvers(); + const firstEntered = Promise.withResolvers(); + const firstReader = sessionManager.runWithCwdReadLease(async () => { + firstEntered.resolve(); + await releaseFirst.promise; + }); + await firstEntered.promise; + const writer = sessionManager.runExclusiveCwdTransition(() => sessionManager.moveTo(repoB)); + await Bun.sleep(20); + // Readers arriving after the writer was announced must queue behind it. + const lateReaderCwds: string[] = []; + const lateReaders = [0, 1, 2].map(() => + sessionManager.runWithCwdReadLease(async () => { + lateReaderCwds.push(sessionManager.getCwd()); + }), + ); + await Bun.sleep(30); + expect(lateReaderCwds).toEqual([]); + releaseFirst.resolve(); + await firstReader; + await writer; + await Promise.all(lateReaders); + // Every late reader observed the POST-move cwd, proving the writer went first. + expect(lateReaderCwds).toHaveLength(3); + for (const seen of lateReaderCwds) expect(seen).toBe(fs.realpathSync(repoB)); + await sessionManager.close(); + }); + + it("keeps a committed move when abort and dispose race it", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + const moveTool = session.getToolByName("move_session")!; + const moving = moveTool.execute("move-abort-dispose", { path: "repo-b" }); + session.agent.abort(); + const disposed = session.dispose(); + await expect(moving).resolves.toBeDefined(); + await disposed; + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + }); + + it("rejects without moving when authority rebinding fails, and keeps launch-root tools", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + // refreshGjcSubskillTools is the first fallible step of authority rebinding, + // which the accessor runs BEFORE committing the session-file move. + let calls = 0; + const original = session.refreshGjcSubskillTools.bind(session); + session.refreshGjcSubskillTools = async () => { + calls += 1; + // Fail the move-to-target rebind; allow the launch-root restore. + if (calls === 1) throw new Error("subskill rebind exploded"); + return original(); + }; + const moveTool = session.getToolByName("move_session")!; + await expect(moveTool.execute("move-rebind-fail", { path: "repo-b" })).rejects.toThrow( + /subskill rebind exploded/, + ); + // No half-moved session: cwd, process cwd, and generation are unchanged. + expect(sessionManager.getCwd()).toBe(cwdA); + expect(sessionManager.getCwdGeneration()).toBe(0); + // The launch-root authority was restored, not left torn down. + expect(calls).toBe(2); + // The single-use budget is not consumed by a rejected move: a later + // successful call must still be admitted. + const retry = await moveTool.execute("move-rebind-retry", { path: "repo-b" }); + expect(textContent(retry)).toContain("repo-b"); + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + } finally { + await session.dispose(); + } + }); + + it("re-roots project context files and the workspace tree at the new cwd", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + fs.writeFileSync(path.join(cwdA, "AGENTS.md"), "launcher-root-instructions"); + fs.writeFileSync(path.join(repoB, "AGENTS.md"), "repo-b-instructions"); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + // contextFiles/workspaceTree are NOT injected here: the session must + // discover them, and re-discover them after the move. + const { session } = await makeSession(cwdA, sessionManager, { + toolNames: ["move_session"], + contextFiles: undefined, + }); + try { + const before = session.systemPrompt.join("\n"); + expect(before).toContain("launcher-root-instructions"); + // repo-b is not on the launch root's ancestor walk, so its instructions + // are invisible until the session is actually rescoped into it. + expect(before).not.toContain("repo-b-instructions"); + const moveTool = session.getToolByName("move_session")!; + await moveTool.execute("move-context-reroot", { path: "repo-b" }); + await session.refreshBaseSystemPrompt(); + const after = session.systemPrompt.join("\n"); + // The model is now shown the target repo's own project instructions, and + // the prompt describes the new cwd rather than the retired launcher root. + expect(after).toContain("repo-b-instructions"); + expect(after).toContain(fs.realpathSync(repoB)); + // The ancestor AGENTS.md legitimately still applies after narrowing. + expect(after).toContain("launcher-root-instructions"); + } finally { + await session.dispose(); + } + }); + + it("does not fail the committed move when SSH refresh throws", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + session.refreshSshTool = async () => { + throw new Error("ssh refresh exploded"); + }; + const moveTool = session.getToolByName("move_session")!; + const result = await moveTool.execute("move-ssh-fail", { path: "repo-b" }); + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + expect(textContent(result)).toContain("repo-b"); + } finally { + await session.dispose(); + } + }); + + it("refuses a move when the no-follow target is replaced after the handle is opened", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + const outside = path.join(tempDir, "outside"); + fs.mkdirSync(cwdA, { recursive: true }); + fs.mkdirSync(repoB, { recursive: true }); + fs.mkdirSync(outside, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const handle = await SessionManager.openNoFollowDirectory(repoB); + try { + const opened = await handle.stat({ bigint: true }); + fs.rmdirSync(repoB); + fs.symlinkSync(outside, repoB); + await expect( + sessionManager.moveTo(repoB, { + expectedIdentity: { dev: opened.dev, ino: opened.ino }, + targetHandle: handle, + }), + ).rejects.toThrow(/replaced path|identity changed/); + expect(sessionManager.getCwd()).toBe(cwdA); + } finally { + await handle.close().catch(() => {}); + await sessionManager.close(); + } + }); + + it("refuses to publish a process cwd whose identity is not the validated target", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const validated = path.join(tempDir, "validated"); + const other = path.join(tempDir, "other"); + fs.mkdirSync(validated, { recursive: true }); + fs.mkdirSync(other, { recursive: true }); + const validatedIdentity = fs.statSync(validated, { bigint: true }); + const restore = process.cwd(); + try { + // The process landed somewhere OTHER than the pinned directory, which is + // what a post-validation path replacement produces for a name-based chdir. + process.chdir(other); + await expect( + SessionManager.assertProcessCwdIdentity({ dev: validatedIdentity.dev, ino: validatedIdentity.ino }), + ).rejects.toThrow(/not the validated target directory/); + // The same assertion passes when the process really is in the pinned dir. + process.chdir(validated); + await SessionManager.assertProcessCwdIdentity({ dev: validatedIdentity.dev, ino: validatedIdentity.ino }); + } finally { + process.chdir(restore); + } + }); + + it("sanitizes control characters in the renderer preview and error output", () => { + const dirty = "repo-\tname\x1b[31mred"; + const preview = moveSessionToolRenderer.renderCall({ path: dirty }).render(200).join("\n"); + expect(preview).not.toContain("\t"); + expect(preview).not.toContain("\x1b"); + expect(preview).toContain("move_session"); + const failed = moveSessionToolRenderer + .renderResult({ isError: true, details: { from: dirty, to: dirty } }, { expanded: false, isPartial: false }, { + fg: (_k: string, text: string) => text, + } as never) + .render(200) + .join("\n"); + expect(failed).toContain("move_session failed"); + expect(failed).not.toContain("\x1b"); + }); +}); diff --git a/packages/coding-agent/test/tool-discovery/initial-tools.test.ts b/packages/coding-agent/test/tool-discovery/initial-tools.test.ts index 81ead8301d..026e302733 100644 --- a/packages/coding-agent/test/tool-discovery/initial-tools.test.ts +++ b/packages/coding-agent/test/tool-discovery/initial-tools.test.ts @@ -9,6 +9,7 @@ import { createAgentSession } from "../../src/sdk/session"; import type { ToolSession } from "../../src/tools/index"; import { BUILTIN_CAPABILITY_CATALOG, + BUILTIN_TOOL_DESCRIPTORS, BUILTIN_TOOLS, computeEssentialBuiltinNames, createTools, @@ -62,6 +63,7 @@ const goalRuntime = new GoalRuntime({ const toolSession: ToolSession = { cwd: "/tmp/test", + rescopeSessionCwd: async (target: string) => ({ from: "/tmp/test", to: target }), hasUI: false, getSessionFile: () => null, getSessionSpawns: () => null, @@ -116,6 +118,14 @@ describe("BUILTIN_TOOLS public factory map", () => { const missing = Object.keys(BUILTIN_TOOLS).filter(name => metadata.get(name)?.loadMode === undefined); expect(missing).toEqual([]); }); + it("declares loadMode on every BUILTIN_TOOLS descriptor independently of session availability", () => { + const missing = Object.keys(BUILTIN_TOOLS).filter(name => { + const descriptor = BUILTIN_TOOL_DESCRIPTORS[name]; + return descriptor?.metadata.loadMode !== "essential" && descriptor?.metadata.loadMode !== "discoverable"; + }); + expect(missing).toEqual([]); + expect(BUILTIN_TOOL_DESCRIPTORS.move_session?.metadata.loadMode).toBe("essential"); + }); it("does not expose memory helpers as public built-in tools", async () => { expect(Object.keys(BUILTIN_TOOLS)).not.toEqual(expect.arrayContaining(["memory", "recall", "retain", "reflect"])); diff --git a/packages/coding-agent/test/tools/tool-catalog.test.ts b/packages/coding-agent/test/tools/tool-catalog.test.ts index 6d7f520b68..da2c8e6c92 100644 --- a/packages/coding-agent/test/tools/tool-catalog.test.ts +++ b/packages/coding-agent/test/tools/tool-catalog.test.ts @@ -12,6 +12,12 @@ describe("generated tool catalog", () => { const regenerated = await generateToolCatalogData(); expect(regenerated).toEqual(TOOL_CATALOG); }); + test("move_session is generated with essential loadMode and exclusive non-abortable metadata", () => { + expect(TOOL_CATALOG.move_session?.loadMode).toBe("essential"); + expect(TOOL_CATALOG.move_session?.deferrable).toBe(false); + expect(TOOL_CATALOG.move_session?.nonAbortable).toBe(true); + expect(TOOL_CATALOG.move_session?.concurrency).toBe("exclusive"); + }); test("ambient agent fixtures cannot contaminate generated task metadata", async () => { const project = await fs.mkdtemp(path.join(os.tmpdir(), "gjc-tool-catalog-project-")); diff --git a/packages/natives/test/windows-avx2-probe.windows.test.ts b/packages/natives/test/windows-avx2-probe.windows.test.ts index 46aa777919..b3dc8df70e 100644 --- a/packages/natives/test/windows-avx2-probe.windows.test.ts +++ b/packages/natives/test/windows-avx2-probe.windows.test.ts @@ -62,5 +62,6 @@ describe("windows AVX2 probe live execution (#4652)", () => { // makes PowerShell unavailable or unresponsive. expect(elapsedMs).toBeLessThan(WINDOWS_AVX2_PROBE_BUDGET_MS); }, + 15_000, ); }); diff --git a/packages/utils/src/dirs.ts b/packages/utils/src/dirs.ts index 2e6dd2c93e..16fc8f86a0 100644 --- a/packages/utils/src/dirs.ts +++ b/packages/utils/src/dirs.ts @@ -145,8 +145,9 @@ export function getProjectDir(): string { /** Set the project directory. */ export function setProjectDir(dir: string): void { - projectDir = standardizeMacOSPath(path.resolve(dir)); - process.chdir(projectDir); + const resolved = standardizeMacOSPath(path.resolve(dir)); + process.chdir(resolved); + projectDir = standardizeMacOSPath(process.cwd()); } /** diff --git a/scripts/ci-dev-affected.ts b/scripts/ci-dev-affected.ts index 219211ef04..28b0956553 100755 --- a/scripts/ci-dev-affected.ts +++ b/scripts/ci-dev-affected.ts @@ -79,6 +79,8 @@ const BEHAVIORAL_OWNER_TESTS: Readonly> = { "crates/pi-natives/src/path_identity.rs": ["packages/natives/test/path-identity-posix.test.ts"], "packages/coding-agent/src/main.ts": ["packages/coding-agent/test/startup-update-contract.test.ts"], "scripts/clean-core.ts": ["scripts/clean.test.ts"], + "packages/coding-agent/src/tools/tool-catalog.generated.ts": ["packages/coding-agent/test/tools/tool-catalog.test.ts"], + "packages/coding-agent/scripts/generate-tool-catalog.ts": ["packages/coding-agent/test/tools/tool-catalog.test.ts"], }; export interface PackageManifest { From 7f293af577062274d07c1f29ab0e5206a3d28eef Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 18:56:59 +0000 Subject: [PATCH 2/7] fix(session): refresh SDK seam inventory for current dev Current dev renamed and added AgentSession rescope helpers, so the generated operation inventory rejected the valid ported implementation. Keep the internal helpers explicitly excluded from the public SDK surface and regenerate the checked artifact. Lore-id: pr4688-current-dev-sdk-seams Constraint: do not expose internal move_session helpers as public SDK operations Confidence: high Scope-risk: narrow Reversibility: revertible Tested: bun test packages/coding-agent/test/sdk-operation-inventory.test.ts Not-tested: sdk-downgrade rollback fixture under Bun 1.4.0 --- .../generate-sdk-operation-inventory.ts | 6 ++-- .../operation-inventory.generated.json | 33 ++++++++++++------- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts b/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts index 9f2c46745c..12dd0a0235 100644 --- a/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts +++ b/packages/coding-agent/scripts/generate-sdk-operation-inventory.ts @@ -79,8 +79,10 @@ const LOCKED_EXCLUSIONS: Readonly> = { "internal cwd-rebinding helper for owned MCP authority after move_session, not a user-facing SDK control seam", "agent_session:replaceNamedCustomTools": "internal cwd-rebinding helper for named custom tools after move_session, not a user-facing SDK control seam", - "agent_session:recreatePythonTool": - "internal cwd-rebinding helper that recreates the python tool after move_session, not a user-facing SDK control seam", + "agent_session:replaceSkills": + "internal cwd-rebinding helper for session skills after move_session, not a user-facing SDK control seam", + "agent_session:retireWorkspaceTreeForRescope": + "internal cwd-rebinding helper that retires the cached workspace tree after move_session, not a user-facing SDK control seam", "agent_session:getDeepInterviewAskStage": "internal AskTool schema-selection accessor, not a user-facing SDK control seam", "agent_session:peekQueueInvoker": "internal accessor/plumbing, not a user-facing control seam", diff --git a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json index d0b97bb145..43a2157e90 100644 --- a/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json +++ b/packages/coding-agent/src/sdk/protocol/operation-inventory.generated.json @@ -2479,17 +2479,6 @@ "testIds": "not_applicable" } }, - { - "sourceId": "agent_session:recreatePythonTool", - "sourceFile": "packages/coding-agent/src/session/agent-session.ts", - "sourceKind": "agent_session", - "decision": "exclude", - "rationale": "internal cwd-rebinding helper that recreates the python tool after move_session, not a user-facing SDK control seam", - "exclusionMetadata": { - "adapterMappings": "not_applicable", - "testIds": "not_applicable" - } - }, { "sourceId": "agent_session:getActiveSkillPhase", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", @@ -3737,6 +3726,28 @@ "testIds": "not_applicable" } }, + { + "sourceId": "agent_session:replaceSkills", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal cwd-rebinding helper for session skills after move_session, not a user-facing SDK control seam", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, + { + "sourceId": "agent_session:retireWorkspaceTreeForRescope", + "sourceFile": "packages/coding-agent/src/session/agent-session.ts", + "sourceKind": "agent_session", + "decision": "exclude", + "rationale": "internal cwd-rebinding helper that retires the cached workspace tree after move_session, not a user-facing SDK control seam", + "exclusionMetadata": { + "adapterMappings": "not_applicable", + "testIds": "not_applicable" + } + }, { "sourceId": "agent_session:getTodoPhases", "sourceFile": "packages/coding-agent/src/session/agent-session.ts", From f950b66b750513ecedd4d82be0da062fcd0cde8f Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 19:05:44 +0000 Subject: [PATCH 3/7] fix(session): make move_session rescope atomic A failed flush or durable move could leave process cwd and project authority at the target while SessionManager stayed at the launch root. The rescope now restores launch authority on pre-commit failure, treats post-publication metadata errors as committed, rechecks workflow admission under the transition lock, excludes exact MCP-config sessions, and rebuilds conventional/plugin MCP authority with the session agent directory and tool classification after a move. Lore-id: pr4688-rescope-atomicity Constraint: rejected rescope must not leave process, authority, and durable session roots split Constraint: exact MCP config sessions must not silently swap authority Constraint: conventional MCP discovery must follow the target agent directory Confidence: high Scope-risk: wide Reversibility: revertible Tested: bun test packages/coding-agent/test/move-session-tool.test.ts; bun --cwd=packages/coding-agent run check Not-tested: live external MCP server callback delivery and Windows exact-config fixtures Directive: do not consume the one-move budget when flush or durable publication fails --- packages/coding-agent/src/sdk/session.ts | 126 +++++++++++++----- .../test/move-session-tool.test.ts | 50 +++++++ 2 files changed, 146 insertions(+), 30 deletions(-) diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index 5982f0ef69..eface58145 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -2085,27 +2085,46 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} error: error instanceof Error ? error.message : String(error), }); } - if (ownsMcpManager) { + if (!options.mcpManager && explicitMcpConfigPath === undefined) { const previousManager = mcpManager; if (previousManager) await previousManager.disconnectAll().catch(() => {}); + pluginMcpToolNames.length = 0; + conventionalMcpToolNames.length = 0; let nextManager: MCPManager | undefined; try { const loaded = await loadAllMCPConfigs(to, { + agentDir, enableProjectConfig: settings.has("mcp.enableProjectConfig") ? settings.get("mcp.enableProjectConfig") : true, autoloadOnly: true, nativeOnly: true, }); - const { configs } = await buildPluginMcpConfigs({ cwd: to }); - const mergedConfigs = { ...loaded.configs, ...configs }; + const { configs: pluginConfigs } = await buildPluginMcpConfigs({ cwd: to }); + const pluginNames = new Set(Object.keys(pluginConfigs)); + const mergedConfigs = { ...loaded.configs, ...pluginConfigs }; + const mergedSources = { + ...loaded.sources, + ...Object.fromEntries( + Object.keys(pluginConfigs).map(name => [ + name, + { provider: "gjc-plugins", providerName: "GJC plugin bundle", level: "project" as const }, + ]), + ), + }; if (Object.keys(mergedConfigs).length > 0) { nextManager = new MCPManager(to, null, { sharedPoolIdleMs: settings.get("mcp.sharedPoolIdleMs") }); nextManager.setAuthStorage(authStorage); wireMcpManagerCallbacks(nextManager); - const result = await nextManager.connectServers(mergedConfigs, loaded.sources as never); + const result = await nextManager.connectServers(mergedConfigs, mergedSources as never); nextCustomTools.push(...(result.tools as CustomTool[])); nextCwdCapturing.push(...result.tools.map(tool => tool.name)); + for (const tool of result.tools) { + const serverName = tool.mcpServerName; + if (serverName === undefined) continue; + if (pluginNames.has(serverName)) pluginMcpToolNames.push(tool.name); + else conventionalMcpToolNames.push(tool.name); + } } } catch (error) { logger.warn("Failed to recreate MCP authority after session rescope", { @@ -2218,6 +2237,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} !options.bashRestrictionProfile && (options.bashAllowedPrefixes ?? []).length === 0 && !options.mcpManager && + explicitMcpConfigPath === undefined && options.workspaceTree === undefined ? { rescopeSessionCwd: (() => { @@ -2244,6 +2264,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} "This session has already been rescoped; only one agent-invoked move is allowed per session.", ); } + if (session?.getEffectiveActiveWorkflowSkillState()) { + throw new Error( + "A workflow skill became active while waiting for the cwd transition; finish or exit it before rescoping.", + ); + } const from = sessionManager.getCwd(); const resolvedPath = path.resolve(from, target); let canonicalFrom: string; @@ -2294,6 +2319,43 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // root both satisfy that, so acting on it would chdir the process // and clear process-global caches underneath the sibling. const ownsProcessCwd = SessionManager.isProcessCwdOwner(sessionManager); + const restoreLaunchRoot = async (failure: unknown): Promise => { + const restoreErrors: Error[] = []; + if (ownsProcessCwd) { + try { + setProjectDir(canonicalFrom); + if (path.resolve(process.cwd()) !== path.resolve(canonicalFrom)) { + throw new Error("Process cwd did not restore to the launch root."); + } + } catch (error) { + restoreErrors.push(error instanceof Error ? error : new Error(String(error))); + } + try { + resetCapabilities(); + const restoreRegistry = await resolveActiveProjectRegistryPath(canonicalFrom).catch( + () => undefined, + ); + clearPluginRootsAndCaches(restoreRegistry ? [restoreRegistry] : undefined); + } catch (error) { + restoreErrors.push(error instanceof Error ? error : new Error(String(error))); + } + } + try { + await rebindCwdCapturingAuthority(canonicalFrom); + } catch (error) { + restoreErrors.push(error instanceof Error ? error : new Error(String(error))); + } + if (restoreErrors.length > 0) { + throw new AggregateError( + restoreErrors, + "Failed to restore launch-root rescope authority.", + { + cause: failure, + }, + ); + } + throw failure; + }; try { // Every fallible step that the moved session depends on runs // BEFORE the session-file commit, so a failure here leaves the @@ -2315,7 +2377,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} try { if (ownsProcessCwd) { resetCapabilities(); - await shutdownAllLspClients(); const projectRegistry = await resolveActiveProjectRegistryPath(canonicalTarget); clearPluginRootsAndCaches(projectRegistry ? [projectRegistry] : undefined); } @@ -2327,34 +2388,39 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} rescopeFailure = error; } if (rescopeFailure !== undefined) { - // Restore the launch root's authority so the still-unmoved - // session keeps working tools instead of a torn-down set. - if (ownsProcessCwd) setProjectDir(canonicalFrom); - if (ownsProcessCwd) { - resetCapabilities(); - const restoreRegistry = await resolveActiveProjectRegistryPath(canonicalFrom).catch( - () => undefined, - ); - clearPluginRootsAndCaches(restoreRegistry ? [restoreRegistry] : undefined); - } - await rebindCwdCapturingAuthority(canonicalFrom).catch(restoreError => { - logger.warn( - "Failed to restore launch-root tool authority after a rejected rescope", - { - error: safeErrorForLog(restoreError), - }, - ); + await restoreLaunchRoot(rescopeFailure); + } + try { + await sessionManager.flush(); + // Commit last: `moveTo` re-validates the pinned identity through + // the still-open handle at the state-changing boundary. + await sessionManager.moveTo(canonicalTarget, { + expectedIdentity, + targetHandle, + }); + } catch (error) { + const committedCwd = sessionManager.getCwd(); + const stayedAtLaunchRoot = path.resolve(committedCwd) === path.resolve(from); + if (stayedAtLaunchRoot) await restoreLaunchRoot(error); + // SessionManager can publish the durable move before a later metadata + // write fails. Treat that state as committed rather than reporting a + // rejection after the session has moved. + moveConsumed = true; + logger.warn("Session rescope committed before finalization failed", { + error: safeErrorForLog(error), + cwd: committedCwd, }); - throw rescopeFailure; } - await sessionManager.flush(); - // Commit last: `moveTo` re-validates the pinned identity through - // the still-open handle at the state-changing boundary. - await sessionManager.moveTo(canonicalTarget, { - expectedIdentity, - targetHandle, - }); moveConsumed = true; + if (ownsProcessCwd) { + try { + await shutdownAllLspClients(); + } catch (error) { + logger.warn("Failed to reset launch-root LSP clients after session rescope", { + error: safeErrorForLog(error), + }); + } + } // Cwd-derived read-only state the prompt and subagents consume. // Best-effort by design: the move is committed, and a failed // re-discovery must not present a committed move as a failure. diff --git a/packages/coding-agent/test/move-session-tool.test.ts b/packages/coding-agent/test/move-session-tool.test.ts index 406d0ffc66..41626f045c 100644 --- a/packages/coding-agent/test/move-session-tool.test.ts +++ b/packages/coding-agent/test/move-session-tool.test.ts @@ -311,6 +311,23 @@ describe("move_session tool (agent-invokable session rescope)", () => { await mcpSession.session.dispose(); } }); + it("does not expose move_session for an exact MCP config session", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + fs.mkdirSync(cwdA, { recursive: true }); + const mcpConfigPath = path.join(tempDir, "mcp.json"); + fs.writeFileSync(mcpConfigPath, "{}\n"); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { + mcpConfigPath, + }); + try { + expect(session.getToolByName("move_session")).toBeUndefined(); + } finally { + await session.dispose(); + } + }); it("refuses to rescope outside the current session directory", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); @@ -784,6 +801,39 @@ describe("move_session tool (agent-invokable session rescope)", () => { } }); + it("restores process cwd and retries when flush fails before the durable move", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const restoreProcessCwd = process.cwd(); + process.chdir(cwdA); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const originalFlush = sessionManager.flush.bind(sessionManager); + let failOnce = true; + sessionManager.flush = async () => { + if (failOnce) { + failOnce = false; + throw new Error("flush exploded"); + } + return originalFlush(); + }; + const moveTool = session.getToolByName("move_session")!; + await expect(moveTool.execute("move-flush-fail", { path: "repo-b" })).rejects.toThrow(/flush exploded/); + expect(sessionManager.getCwd()).toBe(cwdA); + expect(process.cwd()).toBe(cwdA); + const retry = await moveTool.execute("move-flush-retry", { path: "repo-b" }); + expect(textContent(retry)).toContain("repo-b"); + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + } finally { + await session.dispose(); + process.chdir(restoreProcessCwd); + } + }); + it("re-roots project context files and the workspace tree at the new cwd", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); tempDirs.push(tempDir); From 044f6bc204c8f0dd981c6ac01895ddb01d06532f Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 19:22:08 +0000 Subject: [PATCH 4/7] test(session): pin committed move finalization Exercise the post-publication metadata failure branch so move_session returns the committed destination instead of reporting a false rejection or allowing a second move. Lore-id: pr4688-post-publication-proof Constraint: never report a durable move as rejected after publication Confidence: high Scope-risk: narrow Reversibility: revertible Tested: bun test packages/coding-agent/test/move-session-tool.test.ts --- .../test/move-session-tool.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/coding-agent/test/move-session-tool.test.ts b/packages/coding-agent/test/move-session-tool.test.ts index 41626f045c..118c060297 100644 --- a/packages/coding-agent/test/move-session-tool.test.ts +++ b/packages/coding-agent/test/move-session-tool.test.ts @@ -834,6 +834,32 @@ describe("move_session tool (agent-invokable session rescope)", () => { } }); + it("reports a committed move when final move metadata fails after publication", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + const originalMoveTo = sessionManager.moveTo.bind(sessionManager); + sessionManager.moveTo = async (newCwd, options) => { + await originalMoveTo(newCwd, options); + throw new Error("post-publication metadata exploded"); + }; + const moveTool = session.getToolByName("move_session")!; + const result = await moveTool.execute("move-post-publication-fail", { path: "repo-b" }); + expect(textContent(result)).toContain("repo-b"); + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + await expect(moveTool.execute("move-post-publication-retry", { path: "repo-b" })).rejects.toThrow( + /already been rescoped/, + ); + } finally { + await session.dispose(); + } + }); + it("re-roots project context files and the workspace tree at the new cwd", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); tempDirs.push(tempDir); From 35535e63669a523630e88a75c3a9407e4ea6b19b Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 20:42:04 +0000 Subject: [PATCH 5/7] fix(session): close exact-head rescope lifecycle wedges Nested eval tool dispatch could deadlock behind a queued cwd writer, construction failures could retain process-cwd ownership, and post-commit prompt or SSH refresh errors could reject an already committed move. Make read leases re-entrant per async owner, release ownership on every startup failure path, and turn post-commit refresh into diagnostic-only work with regressions for each seam. Lore-id: pr4688-fdff-lifecycle-wedges Constraint: nested tool dispatch must not deadlock a fair cwd writer Constraint: failed construction must never poison later process-cwd ownership Constraint: post-commit refresh cannot contradict durable move state Confidence: high Scope-risk: wide Reversibility: revertible Tested: bun test packages/coding-agent/test/move-session-tool.test.ts; bun --cwd=packages/coding-agent run check; bun test packages/coding-agent/test/sdk-move-cwd.test.ts packages/coding-agent/test/session-manager-resident-cache.test.ts packages/coding-agent/test/notifications-live-stream.test.ts; bun test packages/coding-agent/test/agent-session-python-cleanup.test.ts Not-tested: Windows nested eval lease scheduling --- packages/coding-agent/src/sdk/session.ts | 27 ++++-- .../src/session/session-manager.ts | 30 +++++-- .../test/move-session-tool.test.ts | 88 +++++++++++++++++++ 3 files changed, 131 insertions(+), 14 deletions(-) diff --git a/packages/coding-agent/src/sdk/session.ts b/packages/coding-agent/src/sdk/session.ts index eface58145..3403e9bc3e 100644 --- a/packages/coding-agent/src/sdk/session.ts +++ b/packages/coding-agent/src/sdk/session.ts @@ -1351,7 +1351,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} let agent: Agent; let session!: AgentSession; + let sessionManager!: SessionManager; let hasSession = false; + let processCwdClaimed = false; let hasRegistered = false; let asyncJobManager: AsyncJobManager | undefined; let asyncJobManagerAdmitted = false; @@ -1475,7 +1477,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} } applyConfiguredSearchTimeout(settings); - const sessionManager = + sessionManager = options.sessionManager ?? (await logger.time("sessionManager", async () => { return SessionManager.create(cwd, SessionManager.managedDestination(cwd, agentDir)); @@ -2184,8 +2186,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} agentsMdFiles: [], }); workspaceTreePromise.catch(() => {}); - session?.retireWorkspaceTreeForRescope(); - await session?.refreshBaseSystemPrompt(); + try { + session?.retireWorkspaceTreeForRescope(); + await session?.refreshBaseSystemPrompt(); + } catch (error) { + logger.warn("Committed session rescope could not refresh the post-move prompt", { + error: safeErrorForLog(error), + cwd: to, + }); + } }; const toolSession: ToolSession = { @@ -2427,9 +2436,13 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} await applyRescopedReadState(sessionManager.getCwd()); try { await session?.refreshSshTool({ activateIfAvailable: true }); - } catch { + } catch (error) { // Non-fatal: the session has moved; the SSH tool refreshes // on its next activation attempt. + logger.warn("Committed session rescope could not refresh the SSH tool", { + error: safeErrorForLog(error), + cwd: sessionManager.getCwd(), + }); } return { from, to: sessionManager.getCwd() }; } finally { @@ -2572,7 +2585,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // the claim, so its rescope leaves process-global state (chdir, // capabilities, plugin caches, browser tab cwd) untouched. if (!isCanonicalSubSession && path.resolve(process.cwd()) === path.resolve(sessionManager.getCwd())) { - SessionManager.claimProcessCwdOwnership(sessionManager); + processCwdClaimed = SessionManager.claimProcessCwdOwnership(sessionManager); } if (asyncJobManager) { // Register under the session endpoint so concurrent sessions' @@ -4398,6 +4411,10 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} cleanupDiagnostic: safeCleanupDiagnosticForLog(cleanupDiagnostic), }); } finally { + if (processCwdClaimed) { + SessionManager.releaseProcessCwdOwnership(sessionManager); + processCwdClaimed = false; + } releaseLocalProtocolOverride(); try { closeOwnedAuthStorage(); diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 2c88a34e33..fc574b1730 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -23,6 +23,8 @@ function nativeSessionManager(): typeof import("@gajae-code/natives") { return require("@gajae-code/natives") as typeof import("@gajae-code/natives"); } const cwdTransitionAls = new AsyncLocalStorage(); +type CwdReadLeaseContext = { active: boolean }; +const cwdReadLeaseAls = new AsyncLocalStorage(); const CWD_NOFOLLOW_OPEN_FLAGS = fs.constants.O_RDONLY | (typeof fs.constants.O_DIRECTORY === "number" ? fs.constants.O_DIRECTORY : 0) | @@ -10549,6 +10551,12 @@ export class SessionManager { * acquisition stays authoritative until the lease is released. */ async runWithCwdReadLease(fn: () => Promise): Promise { + const activeReadLease = cwdReadLeaseAls.getStore(); + // Nested tool dispatch (for example eval -> tool bridge) inherits the + // outer lease's async context. Re-entering that lease must not wait behind + // a writer that is already queued: the writer is waiting for the outer + // lease, and waiting here would deadlock the session permanently. + if (activeReadLease?.active) return fn(); const owner = this.#cwdTransitionOwner; // The writer's own async context already holds exclusive access; taking a // read lease there would wait on itself. @@ -10560,16 +10568,20 @@ export class SessionManager { this.#cwdReadersDrained = resolve; } this.#cwdReaderCount += 1; - try { - return await fn(); - } finally { - this.#cwdReaderCount -= 1; - if (this.#cwdReaderCount === 0) { - const drained = this.#cwdReadersDrained; - this.#cwdReadersDrained = undefined; - drained?.(); + const readLease: CwdReadLeaseContext = { active: true }; + return cwdReadLeaseAls.run(readLease, async () => { + try { + return await fn(); + } finally { + readLease.active = false; + this.#cwdReaderCount -= 1; + if (this.#cwdReaderCount === 0) { + const drained = this.#cwdReadersDrained; + this.#cwdReadersDrained = undefined; + drained?.(); + } } - } + }); } /** Wait for any in-flight exclusive cwd transition to settle. */ diff --git a/packages/coding-agent/test/move-session-tool.test.ts b/packages/coding-agent/test/move-session-tool.test.ts index 118c060297..b4a01c2c38 100644 --- a/packages/coding-agent/test/move-session-tool.test.ts +++ b/packages/coding-agent/test/move-session-tool.test.ts @@ -595,6 +595,70 @@ describe("move_session tool (agent-invokable session rescope)", () => { } }); + it("re-enters nested read leases while a writer is queued and releases after abort", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const outerEntered = Promise.withResolvers(); + const allowNested = Promise.withResolvers(); + const nestedEntered = Promise.withResolvers(); + const outer = sessionManager.runWithCwdReadLease(async () => { + outerEntered.resolve(); + await allowNested.promise; + await expect( + sessionManager.runWithCwdReadLease(async () => { + nestedEntered.resolve(); + throw new Error("nested tool aborted"); + }), + ).rejects.toThrow("nested tool aborted"); + }); + await outerEntered.promise; + const writer = sessionManager.runExclusiveCwdTransition(() => sessionManager.moveTo(repoB)); + await Bun.sleep(20); + allowNested.resolve(); + await nestedEntered.promise; + await outer; + await writer; + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + await sessionManager.close(); + }); + + it("releases process-cwd ownership when construction fails before AgentSession", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = process.cwd(); + const failedManager = SessionManager.inMemory(cwdA); + try { + await expect( + makeSession(cwdA, failedManager, { + agentDir: tempDir, + extensions: [ + () => { + throw new Error("construction failed"); + }, + ], + }), + ).rejects.toThrow("construction failed"); + expect(SessionManager.isProcessCwdOwner(failedManager)).toBe(false); + const replacementManager = SessionManager.inMemory(cwdA); + const { session } = await makeSession(cwdA, replacementManager, { + agentDir: tempDir, + toolNames: ["move_session"], + }); + try { + expect(SessionManager.isProcessCwdOwner(replacementManager)).toBe(true); + } finally { + await session.dispose(); + } + await replacementManager.close(); + } finally { + await failedManager.close(); + } + }); + it("holds a cwd read lease across a tool's whole execution, not just its admission", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); tempDirs.push(tempDir); @@ -860,6 +924,30 @@ describe("move_session tool (agent-invokable session rescope)", () => { } }); + it("keeps a committed move successful when post-move prompt refresh throws", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + const repoB = path.join(cwdA, "repo-b"); + fs.mkdirSync(repoB, { recursive: true }); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"] }); + try { + session.refreshBaseSystemPrompt = async () => { + throw new Error("post-move prompt refresh exploded"); + }; + const moveTool = session.getToolByName("move_session")!; + const result = await moveTool.execute("move-refresh-fail", { path: "repo-b" }); + expect(textContent(result)).toContain("repo-b"); + expect(sessionManager.getCwd()).toBe(fs.realpathSync(repoB)); + await expect(moveTool.execute("move-refresh-retry", { path: "repo-b" })).rejects.toThrow( + /already been rescoped/, + ); + } finally { + await session.dispose(); + } + }); + it("re-roots project context files and the workspace tree at the new cwd", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); tempDirs.push(tempDir); From 2d721d413673a74494915d42b5ca14a591c1b9d8 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 21:06:54 +0000 Subject: [PATCH 6/7] fix(session): scope nested cwd leases to their manager Async lease re-entry must not let a nested call on a different session bypass that manager's queued writer. Bind the reentrant context to the owning SessionManager while preserving same-session eval dispatch. Lore-id: pr4688-lease-owner-scope Constraint: reentrant read leases are session-local Confidence: high Scope-risk: narrow Reversibility: revertible Tested: bun test packages/coding-agent/test/move-session-tool.test.ts --- packages/coding-agent/src/session/session-manager.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index fc574b1730..fa5bd6625d 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -23,7 +23,7 @@ function nativeSessionManager(): typeof import("@gajae-code/natives") { return require("@gajae-code/natives") as typeof import("@gajae-code/natives"); } const cwdTransitionAls = new AsyncLocalStorage(); -type CwdReadLeaseContext = { active: boolean }; +type CwdReadLeaseContext = { active: boolean; owner: symbol }; const cwdReadLeaseAls = new AsyncLocalStorage(); const CWD_NOFOLLOW_OPEN_FLAGS = fs.constants.O_RDONLY | @@ -7191,6 +7191,7 @@ export class SessionManager { /** Serializes model, SDK, and ACP cwd transitions; dispose joins this tail. */ #cwdTransitionTail: Promise = Promise.resolve(); #cwdTransitionOwner: symbol | undefined; + #cwdReadLeaseOwner = Symbol("cwd-read-lease-owner"); #cwdGeneration = 0; /** Number of tool executions currently holding a shared read lease on `cwd`. */ #cwdReaderCount = 0; @@ -10556,7 +10557,7 @@ export class SessionManager { // outer lease's async context. Re-entering that lease must not wait behind // a writer that is already queued: the writer is waiting for the outer // lease, and waiting here would deadlock the session permanently. - if (activeReadLease?.active) return fn(); + if (activeReadLease?.active && activeReadLease.owner === this.#cwdReadLeaseOwner) return fn(); const owner = this.#cwdTransitionOwner; // The writer's own async context already holds exclusive access; taking a // read lease there would wait on itself. @@ -10568,7 +10569,7 @@ export class SessionManager { this.#cwdReadersDrained = resolve; } this.#cwdReaderCount += 1; - const readLease: CwdReadLeaseContext = { active: true }; + const readLease: CwdReadLeaseContext = { active: true, owner: this.#cwdReadLeaseOwner }; return cwdReadLeaseAls.run(readLease, async () => { try { return await fn(); From bca63caedea9d134d861c75a65099d5fe65587e4 Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Fri, 21 Aug 2026 21:20:16 +0000 Subject: [PATCH 7/7] test(session): allow slow rescope admission checks Current dev's expanded startup surface can push two admission-only tests beyond Bun's five-second default when the file runs concurrently. Keep their assertions unchanged and give only those tests a bounded 15-second budget. Lore-id: pr4688-rebased-test-budget Constraint: timeout changes must not mask a functional failure Confidence: high Scope-risk: narrow Reversibility: revertible Tested: bun test packages/coding-agent/test/move-session-tool.test.ts --- .../test/move-session-tool.test.ts | 100 ++++++++++-------- 1 file changed, 54 insertions(+), 46 deletions(-) diff --git a/packages/coding-agent/test/move-session-tool.test.ts b/packages/coding-agent/test/move-session-tool.test.ts index b4a01c2c38..155be07a7f 100644 --- a/packages/coding-agent/test/move-session-tool.test.ts +++ b/packages/coding-agent/test/move-session-tool.test.ts @@ -195,27 +195,31 @@ describe("move_session tool (agent-invokable session rescope)", () => { } }); - it("does not expose move_session in canonical sub-sessions identified by parentTaskPrefix or currentAgentType alone", async () => { - for (const overrides of [{ parentTaskPrefix: "0-Worker" }, { currentAgentType: "executor" }] as Array< - Record - >) { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); - tempDirs.push(tempDir); - const cwdA = path.join(tempDir, "root"); - fs.mkdirSync(cwdA, { recursive: true }); + it( + "does not expose move_session in canonical sub-sessions identified by parentTaskPrefix or currentAgentType alone", + async () => { + for (const overrides of [{ parentTaskPrefix: "0-Worker" }, { currentAgentType: "executor" }] as Array< + Record + >) { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + fs.mkdirSync(cwdA, { recursive: true }); - const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); - const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"], ...overrides }); - try { - expect( - session.getToolByName("move_session"), - `sub-session with ${Object.keys(overrides)[0]} must not expose move_session`, - ).toBeUndefined(); - } finally { - await session.dispose(); + const sessionManager = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const { session } = await makeSession(cwdA, sessionManager, { toolNames: ["move_session"], ...overrides }); + try { + expect( + session.getToolByName("move_session"), + `sub-session with ${Object.keys(overrides)[0]} must not expose move_session`, + ).toBeUndefined(); + } finally { + await session.dispose(); + } } - } - }); + }, + { timeout: 15_000 }, + ); it("refuses to move while a workflow skill is active", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); @@ -284,33 +288,37 @@ describe("move_session tool (agent-invokable session rescope)", () => { await session.dispose(); } }); - it("does not expose move_session when caller-owned MCP or a frozen workspace tree is bound", async () => { - const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); - tempDirs.push(tempDir); - const cwdA = path.join(tempDir, "root"); - fs.mkdirSync(cwdA, { recursive: true }); - const frozenTree = { cwd: cwdA, entries: [], agentsMdFiles: [] }; - const withTree = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); - const treeSession = await makeSession(cwdA, withTree, { - toolNames: ["move_session"], - workspaceTree: frozenTree, - }); - try { - expect(treeSession.session.getToolByName("move_session")).toBeUndefined(); - } finally { - await treeSession.session.dispose(); - } - const withMcp = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); - const mcpSession = await makeSession(cwdA, withMcp, { - toolNames: ["move_session"], - mcpManager: { connectServers() {} }, - }); - try { - expect(mcpSession.session.getToolByName("move_session")).toBeUndefined(); - } finally { - await mcpSession.session.dispose(); - } - }); + it( + "does not expose move_session when caller-owned MCP or a frozen workspace tree is bound", + async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); + tempDirs.push(tempDir); + const cwdA = path.join(tempDir, "root"); + fs.mkdirSync(cwdA, { recursive: true }); + const frozenTree = { cwd: cwdA, entries: [], agentsMdFiles: [] }; + const withTree = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const treeSession = await makeSession(cwdA, withTree, { + toolNames: ["move_session"], + workspaceTree: frozenTree, + }); + try { + expect(treeSession.session.getToolByName("move_session")).toBeUndefined(); + } finally { + await treeSession.session.dispose(); + } + const withMcp = SessionManager.create(cwdA, SessionManager.managedDestination(cwdA, tempDir)); + const mcpSession = await makeSession(cwdA, withMcp, { + toolNames: ["move_session"], + mcpManager: { connectServers() {} }, + }); + try { + expect(mcpSession.session.getToolByName("move_session")).toBeUndefined(); + } finally { + await mcpSession.session.dispose(); + } + }, + { timeout: 15_000 }, + ); it("does not expose move_session for an exact MCP config session", async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), `gjc-move-session-${Snowflake.next()}-`)); tempDirs.push(tempDir);