Skip to content

feat(mcp): open a child pane in another project, still parented on the caller (0228 C S2) - #546

Draft
nt-nerdtechnic wants to merge 19 commits into
mainfrom
feat/0228-c1-cross-workspace-spawn
Draft

nt-nerdtechnic wants to merge 19 commits into
mainfrom
feat/0228-c1-cross-workspace-spawn

Conversation

@nt-nerdtechnic

@nt-nerdtechnic nt-nerdtechnic commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Slice S2 of DESIGN-C: cli_open_agent from a pane can open a child in another project. The caller stays its parent and chooses the window.

Stacked on #534 (S1) and #535 (S3): both are merged into this branch (merges, not rebases). Merge order: #534 → #535 → this PR; this diff then shrinks to S2 only. #535 is merged in for agent_window_actions, so the cross-workspace action record uses C2's module instead of a second copy. S2's own commits: e8c9136fb and d07921abc (review #546).

Review #546 fixes (d07921abc)

  • MED-1, relative paths: a pane's workspace_path must be absolute (or start with ~). Otherwise workspace-path-not-absolute, and nothing is prepared.
  • MED-2, case variants:
    • The own-workspace check and the holder lookup compare folder identity (S1's _same_directory, dev + inode).
    • A target is sent under the spelling a window already holds (_known_spelling, from the announced held workspaces and the registry), so the window's own string match finds it.
    • "new" also refuses when the registry shows another window holding the project.
    • Main: findMainWindowForWorkspace now keys on workspaceIdentityKey (realpathSync.native, new src/main/workspace-identity.ts). normalizeWorkspacePath itself is unchanged, because it also produces the plugin and Plans root strings, and changing their spelling is outside this slice.
    • Tests: case variants on both sides; they skip on a case-sensitive volume.
  • MED-3, forged "Opened by":
    • The label and the cross-project stand-in report now follow an explicit spawnedCrossWorkspace flag. Only the cross-workspace spawn sets it, and it is persisted as PaneRecord.report_cross (default false).
    • A name merely containing / proves nothing.
    • cli_open_agent refuses names containing / (invalid-name), and so does the spawn gate (SPAWN blocks).
    • A user rename keeps the title as typed, but the messaging handle gets - in place of /. Existing panes are untouched.
  • Design §5, disclosure:
    • Every cross-workspace spawn, refused ones included, is written to agent_window_actions: who, target project, window choice, what was prepared, and undo steps (cli_reclaim_agent, plus workspace_close when the project was adopted).
    • The target window keeps its existing spawn toast naming parent and child, and now also records an agent.window-action diagnostic.
  • Design §5, ui.workspace.adopt: ui_invoke answers ui_dedicated_tool; it is reached only through cli_open_agent, which checks the folder and records the action.
  • LOW-1, not changed: reports are still addressed by name. If the parent has closed and another pane later takes its name in that folder, the stand-in report reaches the newcomer. This is the same as same-window behaviour today. Addressing by pane id needs a wire change and is left as a follow-up.
  • Red → green: 9 backend and 4 renderer/main tests were red before this commit; all green now. test_an_ordinary_spawn_writes_no_record was green both before and after.

What changed

  • Backend (cli_open_agent, mcp_server/server.py):
    • A pane caller's workspace_path naming another project is now honoured. Naming its own workspace (any spelling) or omitting it takes the old path unchanged.
    • New optional window: "current" (default) or "new". existing + window_id is left for C2 to add after this merges, as agreed with the coordinator.
    • New errors:
      • workspace-not-found
      • invalid-window
      • workspace-in-other-window, with holding_window_id (decision (a): refuse and name the window; never duplicate, never redirect)
      • window-cannot-hold
      • window_not_ready now also carries prepared
    • The answer gains workspace_path, prepared (already-open | adopted | new-window), parent and window_id, on the cross-workspace path only.
    • The spawn event gains cross_workspace: true and parent_address.
  • Renderer (App.vue):
    • New ui.workspace.adopt: adopts the project into this window's sidebar without switching; refuses a project another window holds (asks main) and refuses in a detached group window.
    • ui.workspace.open passes background.
    • New cross-workspace branch in handleMcpSpawnRequest: the window holding the target creates the pane, parented on the requester, which may be in another window.
    • The kickoff arms the report to the qualified parent address, and settleSpawnReport sends the stand-in report to a parent in another workspace.
  • Main / preload: workspace:holder IPC (title of another window holding a folder); openMainWindow(..., background) → showInactive().
  • (e) label: the sidebar row shows "Opened by <folder>/<pane>" (pane.opened-by in en-US, ja-JP, zh-TW) when the parent is in another project.
  • Docs: an "Into another project" section in docs/en-US/inter-cli-messaging.md.

Red → green

  • Backend: test_mcp_cross_workspace_spawn.py. 8 of its first 10 tests were red before the change; the other 2 are the RULE ZERO tests and are green on main too. 11/11 green now, including a later test that a group is checked against the target project.
  • Renderer: App.crossWorkspaceSpawn.test.ts. 8 of 9 were red with the App.vue change reverted; the one that passed checks that a window not holding the project stays silent. 9/9 green now.

Existing tests whose expectations changed (each tied to an approved design decision)

Test Old expectation New expectation Approved basis
test_plan_mcp_cli_send.py::test_tools_are_registered_without_a_ctx_argument cli_open_agent properties set plus window DESIGN-C §3.1 adds the window parameter; the design was approved 10-21 01:00
test_plan_mcp_open_agent_group.py::test_pane_group_is_validated_in_its_own_workspace pane passes workspace_path="/beta", which is ignored pane passes its own "/alpha", so the old request goes out unchanged. The test still pins "a pane's group is validated in its own workspace" DESIGN-C §3.1 and §7: a pane's workspace_path naming another project is no longer ignored. §7 lists this as the one intended behaviour change. The cross-project case is covered in the new file
test_plan_mcp_open_agent_group.py::test_invalid_or_foreign_group_never_dispatches [rg-missing, rg-beta, " ", " rg-alpha "] (4 cases) same, with "/beta" ignored same groups against the pane's own "/alpha", still refused with unknown-run-group same as above. With "/beta" honoured, a non-existent folder would now be refused as workspace-not-found before the group check, which would hide what the test is about

The golden fixture mcp_core_tools_main.json is updated for cli_open_agent: its description and the new window property.

Local verification (targeted only), after the review fixes

  • Stacked tree, backend: -k "test_mcp_cross_workspace_spawn or test_mcp_self_scope or test_mcp_window_tools or open_agent or resume_session or test_share_mcp_tools or workspace_open or test_plan_mcp_whoami or cli_send or test_plan_mcp_ui_invoke or reclaim or pipeline or subdir_workspace or test_projects_pane" -n 2 → 747 passed, 1 skipped, fake-security 0.
  • Renderer and main (cross spawn, spawn gate, workspace-identity, adopted-workspaces, the App spawn suites, uiWindowTools, useUiActionBus, locales) → 212/212.
  • pnpm typecheck → 0 errors. ruff → clean.

Local verification before the review (first push)

  • On the stacked tree: safe-pytest.sh <wt> -k "test_mcp_cross_workspace_spawn or test_mcp_self_scope or open_agent or resume_session or test_share_mcp_tools or workspace_open or test_plan_mcp_whoami or cli_send or test_plan_mcp_ui_invoke or reclaim or pipeline or subdir_workspace" -n 2 → 621 passed, 1 skipped (S4 TODO), fake-security calls 0.
  • pnpm vitest run on the cross-workspace, mcpSpawnGroup, spawnKickoff, resumeSession, spawnAdvisories, uiWorkspaceSwitch and useUiActionBus test files, --maxWorkers 1 → 144/144.
  • Locale tests (locales.test.ts, glossary.test.ts) → 16/16.
  • pnpm typecheck → 0 errors. ruff → clean.

RULE ZERO

  • Unchanged:
    • A pane caller omitting workspace_path, or naming its own workspace, sends a byte-identical agent_spawn.request and gets the same answer keys. Locked by test_naming_your_own_workspace_sends_the_same_request_as_omitting_it and test_an_ordinary_spawn_carries_none_of_the_new_keys.
    • Host and external callers: their path is not touched; test_host_uses_the_requested_workspace still passes.
    • The same-workspace spawn branch in the renderer: the existing App spawn suites pass unchanged.
    • ui.workspace.open without background makes the old call.
  • Shapes only gain fields.

Protection check

  • Credential, account-switching and quota paths: diff vs origin/main is empty.
  • No function deleted.
  • Signature changes (all additive, with defaults):
    • cli_open_agent(..., window="")
    • createRequestedPane(parent: Pick<ActivePane, 'id' | 'workspacePath' | 'runGroupId'>, …): it reads only those three fields
    • kickoffRequestedPane(…, opts: { resume?, crossParent? })
    • main createWindow(params, opts?: { bounds?, background? })
    • main openMainWindow(workspacePath = '', background = false)
  • Added: _SPAWN_WINDOW_CHOICES, _cross_workspace_path, _caller_address, _holding_window_id, _in_other_window, _prepare_cross_workspace, the ui.workspace.adopt command, the workspace:holder IPC, ActivePaneView.openedBy.
  • No new limits.

Known follow-ups (not in this slice)

  • window: "existing" + window_id: C2, after this merges.
  • If the parent pane has closed by the time a cross-workspace child's stand-in report goes out, the report travels the cross-workspace route and the child gets an ordinary delivery-failure notice. A same-workspace child in that situation sends nothing at all, so this differs; it is visible, not silent.

🤖 Generated with Claude Code

nt-nerdtechnic and others added 9 commits October 21, 2026 01:27
ui_invoke("ui.pane.close", {paneId: <own>}) closed the caller's own pane:
only cli_close_agent refused self. Add one self-scope check derived from
the caller's credential alone (pane id from its token, workspace from the
registry, windows mirroring that workspace's panes) and apply it to every
close route ui_invoke reaches: ui.pane.close -> self-close,
ui.workspace.close -> self-workspace-close, ui.window.close ->
self-window-close. A ui.pane.reclaim batch drops the caller's own pane and
reports it refused (reason "self"), reclaiming the rest.

The window repeats the same rule in useUiActionBus using caller_pane_id and
the new caller_workspace_path field, which the backend fills from the
registry, never from args.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…IGN-C S3)

Agents could address panes in other workspaces but had no view of Navide's
windows. A window is now named by its page-load id (ui.invoke.ready
window_id):

- window_list asks every main window to describe itself
  (ui.window.describe, addressed to that window alone, in parallel); a
  window that misses its budget is listed with answered false instead of
  holding the others up. is_yours marks the caller's own windows for
  display only; no refusal is decided on it (the self-scope guard is S1's).
- window_focus asks one window to switch to a project it already holds
  (through ui.workspace.switch, so its refusals are the answer) and to
  bring itself to the front. Main's new window:focusSelf focuses the
  sender window and takes no window argument.
- cli_list_targets / cli_whoami rows carry window_id when a main window
  with an id mirrors the pane; absent otherwise, so other rows are
  unchanged.
- agent_window_actions records every window action with the calling pane
  (memory + rotating jsonl in app data); the focused window shows which
  pane did it. No count, size or rate limit is added.
- window_ is reserved as a plugin tool prefix and the two tools join the
  pinned core tool names (Python, TypeScript, fixture, contract doc).

Window helpers (_window_session, _window_request, _describe_windows) stay
in server.py for the cross-workspace spawn (S2) to use.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…folders not strings

HIGH-1: workbench.action.closeWindow is reachable through ui_invoke and runs
window.close() in the window that answers, which for a pane caller is its
own. Refused in the backend whenever the request lands in one of the
caller's windows, and again in the window (hosts the caller or holds its
workspace).

MED-1: workbench.action.closeActivePane and rebuildFocusedPane act on the
focused pane; the window refuses them while that pane is the caller.

MED-2: own workspace is compared by folder identity (st_dev, st_ino), so a
case variant on a case-insensitive volume is still the caller's own; the
window compares case-folded.

MED-3: the S4 contract for an empty ui.workspace.close path is recorded as a
TODO comment and a skipped test.

LOW: a pipeline slot cannot abort, reset, restart or restart_from its own
run (window side); a reclaim batch keeps the caller's refused row when the
window answers with an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… only via their tools

- A main window holding the caller's workspace with none of its panes in it
  (they all sit in detached group windows) is now part of the caller's own
  windows in the backend too. Windows announce the workspaces they hold with
  ui.invoke.ready (new optional `workspaces` field, re-announced on change;
  [] from a detached group window).
- ui_invoke answers ui.window.close / ui.workspace.close with
  ui_dedicated_tool: only window_close / workspace_close record the action
  and tell the user which pane did it (review #541 MED-1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ndow; closeWindow via window_close

- ui.pipeline.reset ends every pane in the window that runs it (onKillAll),
  manual panes included and without a run. Refused for a pane caller when
  it lands in one of the caller's windows: in pipeline_reset and ui_invoke
  (backend, via _lands_in_own_window) and in the window whenever it hosts
  the caller.
- workbench.action.closeWindow joins the dedicated close actions: ui_invoke
  answers ui_dedicated_tool and points at window_close, so closing anyone's
  window leaves a record.
- _lands_in_own_window also reads the workspaces windows announce;
  _same_directory falls back to a case-folded compare on macOS/Windows for a
  folder that cannot be stat'ed.

test_pipeline_window_requests_address_the_panes_own_workspace now expects
the reset from a pane of that window to be refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ented on the caller

A pane caller's workspace_path used to be ignored. Now, naming another
project opens the child there (cwd, CLAUDE.md, memory, Plans, git) with the
caller as its parent: it reports to the caller's <folder>/<pane> address and
the caller reclaims it by the returned address. Naming its own workspace, or
omitting it, sends exactly the old request.

- window "current" (default): the caller's window adopts the project in the
  background (ui.workspace.adopt, no switch); "new": a window of its own,
  shown without taking focus (ui.workspace.open background).
- One project, one window: a project another window holds is refused with
  workspace-in-other-window and holding_window_id; a missing folder is
  workspace-not-found; window_not_ready says what was already prepared.
- The window holding the target creates the pane (cross_workspace spawn
  branch), arms the report to the qualified parent address, and the
  stand-in report reaches a parent in another workspace.
- The child's sidebar row says "Opened by <folder>/<pane>".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
nt-nerdtechnic and others added 10 commits October 21, 2026 02:43
…ss-parent flag, action record

- MED-1: a pane's workspace_path must be absolute (or start with "~"):
  workspace-path-not-absolute.
- MED-2: own-workspace and holder checks compare folders (dev, inode); a
  target is sent under the spelling a window already holds; "new" also
  checks the registry. Main's window lookup uses realpathSync.native
  (workspace-identity.ts), so a case variant cannot open one checkout in two
  windows.
- MED-3: "Opened by" and the cross-project stand-in report follow an
  explicit flag set only by the cross-workspace spawn (persisted as
  PaneRecord.report_cross), never the shape of a name. Names holding "/" are
  refused by cli_open_agent (invalid-name) and the spawn gate; a user rename
  keeps its title but its messaging handle gets "-" for "/".
- Design section 5: a cross-workspace spawn (and a refused one) is written to the
  window action record with who, where, what was prepared and how to undo;
  the target window records a diagnostic next to its existing spawn toast.
  ui.workspace.adopt is reachable only through cli_open_agent
  (ui_dedicated_tool).

Merges #535 (S3) for agent_window_actions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…n sys.platform

CI's test_osplat ratchet (TestNoScatteredPlatformBranches) failed on
macOS and Windows: _same_directory read sys.platform in mcp_server/server.py.
The decision now sits behind a new osplat seam, paths_ignore_case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…copies (S3)

CI found two places the new window tools had to be recorded:

- test_plugin_agent_tools_proxy: window_list / window_focus join the
  tools added after the pinned digest, and the digest is re-pinned because
  the cli_list_targets / cli_whoami descriptions now document window_id
  (with their old descriptions the list still hashes to the old pin).
- The Registry ships byte-identical copies of agent_tools_contract.py
  (registry/ and registry/plugin_contract/); both now carry the window_
  prefix and core names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ge-load id

Same definition as DESIGN-C S3 (#535) uses, so the two slices share one
function instead of defining it twice (test_module_level_duplicate_defs).
Every caller here already skips a window without an id.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…indows

- #534 and #535 each added app.main_window_sessions(); with both merged
  test_module_level_duplicate_defs failed. #534 now carries the window_id
  filter #535 used; the second copy is dropped here (C2 drops it from #535
  when #534 lands).
- test_plugin_agent_tools_proxy: MAIN_TOOLS_LIST_SHA256 re-pinned at this
  tip for cli_open_agent's description and `window` parameter (DESIGN-C
  S2). With main's cli_open_agent entry the digest is the previous pin.
- test_a_tilde_path_is_expanded_before_it_is_used: expanduser reads
  USERPROFILE on Windows, so the test sets it next to HOME.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant