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))
|
checks.append(server_check(project, port, server_known))
|
||||||
links = harbor.connections()
|
links = harbor.connections()
|
||||||
servers = running_mcp_servers()
|
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:
|
for spec in project.agents:
|
||||||
if spec["kind"] == "human":
|
if spec["kind"] == "human":
|
||||||
continue
|
continue
|
||||||
host = spec.get("host")
|
host = spec.get("host")
|
||||||
|
as_agent = installed.get(host, spec["name"])
|
||||||
if host in HOST_CHECKS:
|
if host in HOST_CHECKS:
|
||||||
checks += HOST_CHECKS[host](spec["name"])
|
checks += HOST_CHECKS[host](as_agent)
|
||||||
elif host == "kimi":
|
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"], [])}
|
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()))
|
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)
|
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"):
|
if project_spec or os.environ.get("HARBOR_PROJECT"):
|
||||||
raise
|
raise
|
||||||
return 0 # not a Harbor project: stay out of the way
|
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:
|
if not agent or agent not in project.ai_names:
|
||||||
return 0
|
return 0
|
||||||
harbor = Harbor(project, busy_ms=1500, write_attempts=2)
|
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()
|
p = projects.resolve()
|
||||||
except projects.ProjectError:
|
except projects.ProjectError:
|
||||||
p = None
|
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)})")
|
raise InstallError(f"{agent} is not an agent of project {p.name} ({', '.join(p.ai_names)})")
|
||||||
cmd = launcher(python)
|
cmd = launcher(python)
|
||||||
plan = Plan(host, dry_run)
|
plan = Plan(host, dry_run)
|
||||||
|
|||||||
+24
-1
@@ -67,7 +67,9 @@ def harbor():
|
|||||||
|
|
||||||
def me():
|
def me():
|
||||||
h = harbor()
|
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:
|
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 "
|
raise HarborError(f"HARBOR_AGENT must be one of {', '.join(h.project.ai_names)} (set it in this server's MCP "
|
||||||
"config)")
|
"config)")
|
||||||
@@ -121,6 +123,27 @@ def run(fn):
|
|||||||
raise ToolError(str(error)) from None
|
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
|
@mcp.tool
|
||||||
def brief(mark_read: bool = False) -> str:
|
def brief(mark_read: bool = False) -> str:
|
||||||
"""What you need at the start of a session: stop flags, unread messages (urgent first), acknowledgements you
|
"""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 agent
|
||||||
return None
|
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):
|
def threshold(self, key, default):
|
||||||
return int(self.attention.get(key, default))
|
return int(self.attention.get(key, default))
|
||||||
|
|
||||||
|
|||||||
@@ -340,6 +340,18 @@ class Projects(unittest.TestCase):
|
|||||||
with self.assertRaises(projects.ProjectError):
|
with self.assertRaises(projects.ProjectError):
|
||||||
projects.project_from_dict(bad, home=self.dir)
|
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):
|
def test_resolution_order_and_identities(self):
|
||||||
made = make_home(self.dir, names=("first", "second"))
|
made = make_home(self.dir, names=("first", "second"))
|
||||||
self.assertEqual(projects.resolve(home=self.dir, cwd=self.dir).name, "first") # home default
|
self.assertEqual(projects.resolve(home=self.dir, cwd=self.dir).name, "first") # home default
|
||||||
|
|||||||
Reference in new issue
Block a user