From e283e4cc540ca5f8abbe46daa9930cacec471bcd Mon Sep 17 00:00:00 2001 From: KeelrLeek <156356974+KeelrLeek@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:06:01 -0500 Subject: [PATCH] Several sessions of one app: a session's working folder names its agent when that agent runs in the same app (MCP server and hooks), an i_am tool binds a session explicitly, the doctor checks second sessions against their app's install, and an agent that left the roster can still be uninstalled --- harbor/doctor.py | 15 +++++++++++++-- harbor/hooks.py | 2 +- harbor/install.py | 2 +- harbor/mcp_server.py | 25 ++++++++++++++++++++++++- harbor/projects.py | 12 ++++++++++++ tests/test_core.py | 12 ++++++++++++ 6 files changed, 63 insertions(+), 5 deletions(-) diff --git a/harbor/doctor.py b/harbor/doctor.py index 3b493a5..30001e0 100644 --- a/harbor/doctor.py +++ b/harbor/doctor.py @@ -208,14 +208,25 @@ def run(project, harbor, port=None, server_known=False): checks.append(server_check(project, port, server_known)) links = harbor.connections() servers = running_mcp_servers() + installed = {} # app -> the agent its Harbor install is for (the first one) + for spec in project.agents: + if spec["kind"] != "human" and spec.get("host"): + installed.setdefault(spec["host"], spec["name"]) for spec in project.agents: if spec["kind"] == "human": continue host = spec.get("host") + as_agent = installed.get(host, spec["name"]) if host in HOST_CHECKS: - checks += HOST_CHECKS[host](spec["name"]) + checks += HOST_CHECKS[host](as_agent) elif host == "kimi": - checks += kimi_checks(spec["name"], project) + checks += kimi_checks(as_agent, project) + if as_agent != spec["name"]: # a second session of the same app: its folder names it + ok = bool(spec.get("workdir")) and project.identity_for(spec["workdir"]) == spec["name"] + checks.append(_check(spec["name"], "session identity", "ok" if ok else "fail", + f"shares the {host} install with {as_agent}; its folder {spec.get('workdir') or '?'} " + + ("names it in [identities]" if ok else "is not in [identities]"), + "" if ok else "map its worktree folder to it in [identities], or have it call i_am")) seen = {(r["channel"], r["host"]): r for r in links.get(spec["name"], [])} live = ", ".join(f"{ch}{('/' + h) if h else ''} {r['minutes']} min ago" for (ch, h), r in sorted(seen.items())) running = servers.get(spec["name"], 0) diff --git a/harbor/hooks.py b/harbor/hooks.py index a6b7c6e..e2bccf3 100644 --- a/harbor/hooks.py +++ b/harbor/hooks.py @@ -213,7 +213,7 @@ def run(event, project_spec, agent, host="generic"): if project_spec or os.environ.get("HARBOR_PROJECT"): raise return 0 # not a Harbor project: stay out of the way - agent = agent or os.environ.get("HARBOR_AGENT") or project.identity_for(cwd) + agent = project.session_identity(agent or os.environ.get("HARBOR_AGENT"), cwd) # the session's own folder if not agent or agent not in project.ai_names: return 0 harbor = Harbor(project, busy_ms=1500, write_attempts=2) diff --git a/harbor/install.py b/harbor/install.py index e7cbc14..10e3848 100644 --- a/harbor/install.py +++ b/harbor/install.py @@ -380,7 +380,7 @@ def install(host, agent=None, project=None, dry_run=False, remove=False, python= p = projects.resolve() except projects.ProjectError: p = None - if p and agent not in p.ai_names: + if p and agent not in p.ai_names and not remove: # an agent that left the roster can still be uninstalled raise InstallError(f"{agent} is not an agent of project {p.name} ({', '.join(p.ai_names)})") cmd = launcher(python) plan = Plan(host, dry_run) diff --git a/harbor/mcp_server.py b/harbor/mcp_server.py index 428058c..b75a59b 100644 --- a/harbor/mcp_server.py +++ b/harbor/mcp_server.py @@ -67,7 +67,9 @@ def harbor(): def me(): h = harbor() - agent = os.environ.get("HARBOR_AGENT") or h.project.identity_for(os.getcwd()) or "" + # a session bound with i_am, else the agent its working folder names (when it runs in the same app as the + # installed one: Codex and Codex2 share the Codex app), else the installed one + agent = _state.get("bound") or h.project.session_identity(os.environ.get("HARBOR_AGENT"), os.getcwd()) or "" if agent not in h.project.ai_names: raise HarborError(f"HARBOR_AGENT must be one of {', '.join(h.project.ai_names)} (set it in this server's MCP " "config)") @@ -121,6 +123,27 @@ def run(fn): raise ToolError(str(error)) from None +@mcp.tool +def i_am(agent: str) -> dict: + """Say which of the project's agents this session is, when one app runs several (Codex and Codex2). Call it once + at the start of the session if your instructions or your working folder name you; only an agent of the same + app is accepted, and a server already bound to another agent refuses.""" + def do(h): + pinned = os.environ.get("HARBOR_AGENT") or "" + if agent not in h.project.ai_names: + raise HarborError(f"{agent} is not one of {', '.join(h.project.ai_names)}") + if pinned and h.project.host_of(agent) != h.project.host_of(pinned): + raise HarborError(f"{agent} runs in another app ({h.project.host_of(agent) or 'unknown'}), not this one") + bound = _state.get("bound") + if bound and bound != agent: + raise HarborError(f"this Harbor server is already {bound}: the app shares one server between sessions, " + "so give each session its own app profile instead") + _state["bound"] = agent + return {"you_are": agent, "working_folder": os.getcwd(), + "folder_says": h.project.identity_for(os.getcwd())} + return run(do) + + @mcp.tool def brief(mark_read: bool = False) -> str: """What you need at the start of a session: stop flags, unread messages (urgent first), acknowledgements you diff --git a/harbor/projects.py b/harbor/projects.py index 16d9b0c..cfe9bbc 100644 --- a/harbor/projects.py +++ b/harbor/projects.py @@ -121,6 +121,18 @@ class Project: return agent return None + def host_of(self, agent): + return next((a["host"] for a in self.agents if a["name"] == agent), "") + + def session_identity(self, pinned, cwd): + """Who a session is when one app runs several of the project's agents (Codex and Codex2): the agent its + working folder names in [identities], if that agent runs in the same app as the installed (pinned) one; + otherwise the pinned agent. Without a pinned agent, the folder alone decides.""" + found = self.identity_for(cwd) if cwd else None + if found and found in self.ai_names and (not pinned or self.host_of(found) == self.host_of(pinned)): + return found + return pinned or found + def threshold(self, key, default): return int(self.attention.get(key, default)) diff --git a/tests/test_core.py b/tests/test_core.py index aadbd5f..5cb758b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -340,6 +340,18 @@ class Projects(unittest.TestCase): with self.assertRaises(projects.ProjectError): projects.project_from_dict(bad, home=self.dir) + def test_session_identity_for_two_sessions_of_one_app(self): + made = projects.project_from_dict({ + "name": "pair", "identities": {"wt-codex": "Codex", "wt-codex2": "Codex2", "wt-deepseek": "DeepSeek"}, + "agents": [{"name": "Codex", "host": "codex"}, {"name": "Codex2", "host": "codex"}, + {"name": "DeepSeek", "host": "deepseek"}, {"name": "User", "kind": "human"}]}, home=self.dir) + two = self.dir / "wt-codex2" / "src" + two.mkdir(parents=True) + self.assertEqual(made.session_identity("Codex", two), "Codex2") # same app: the folder decides + self.assertEqual(made.session_identity("Codex", self.dir), "Codex") # no folder match: installed one + self.assertEqual(made.session_identity("DeepSeek", two), "DeepSeek") # another app's folder: ignored + self.assertEqual(made.session_identity(None, two), "Codex2") # nothing pinned: the folder + def test_resolution_order_and_identities(self): made = make_home(self.dir, names=("first", "second")) self.assertEqual(projects.resolve(home=self.dir, cwd=self.dir).name, "first") # home default