forked from Keelrleek/Harbor
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
This commit is contained in:
1 parent
d755ab8a76
commit
e283e4cc54
6 files changed
+63
-5
No files matched your search
+13
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+24
-1
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user