zob-harness 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.pi/capabilities/zob-public-runtime-capabilities.json +43 -1
  2. package/.pi/extensions/zob-harness/src/core/constants.ts +5 -4
  3. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/registry.ts +363 -22
  4. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/types.ts +40 -0
  5. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer.ts +6 -2
  6. package/.pi/extensions/zob-harness/src/domains/coms/zagents.ts +304 -0
  7. package/.pi/extensions/zob-harness/src/runtime/commands.ts +1078 -10
  8. package/.pi/extensions/zob-harness/src/runtime/events.ts +163 -15
  9. package/.pi/extensions/zob-harness/src/runtime/schemas.ts +37 -0
  10. package/.pi/extensions/zob-harness/src/runtime/state.ts +22 -0
  11. package/.pi/extensions/zob-harness/src/runtime/tools-zagent.ts +975 -0
  12. package/.pi/extensions/zob-harness/src/runtime/widget.ts +35 -2
  13. package/.pi/extensions/zob-harness/src/runtime/zobHarness.ts +3 -0
  14. package/.pi/skills/zob-factory/SKILL.md +4 -2
  15. package/.pi/skills/zob-zagent-creator/SKILL.md +106 -14
  16. package/.pi/zagents/harness-architect.json +67 -0
  17. package/.pi/zagents/harness-chief.json +73 -0
  18. package/.pi/zagents/harness-coms-steward.json +64 -0
  19. package/.pi/zagents/harness-factory-engineer.json +61 -0
  20. package/.pi/zagents/harness-implementer.json +69 -0
  21. package/.pi/zagents/harness-interlocutor.json +61 -0
  22. package/.pi/zagents/harness-oracle.json +70 -0
  23. package/.pi/zagents/hot-add-readonly-test-scout.json +102 -0
  24. package/.pi/zagents/prompts/harness-architect.md +82 -0
  25. package/.pi/zagents/prompts/harness-chief.md +92 -0
  26. package/.pi/zagents/prompts/harness-coms-steward.md +80 -0
  27. package/.pi/zagents/prompts/harness-factory-engineer.md +79 -0
  28. package/.pi/zagents/prompts/harness-implementer.md +87 -0
  29. package/.pi/zagents/prompts/harness-interlocutor.md +92 -0
  30. package/.pi/zagents/prompts/harness-oracle.md +83 -0
  31. package/.pi/zagents/prompts/hot-add-readonly-test-scout.md +51 -0
  32. package/.pi/zteams/agent-factory-pacman-multiplayer.tmux.sh +54 -0
  33. package/.pi/zteams/zob-harness-devs.json +249 -0
  34. package/.pi/zteams/zob-harness-devs.modes.json +120 -0
  35. package/.pi/zteams/zob-harness-devs.tmux.sh +270 -0
  36. package/package.json +7 -1
  37. package/scripts/zagent-static-smoke.mjs +300 -1
  38. package/scripts/zpeer-local-e2e-smoke.mjs +9 -7
  39. package/scripts/zpeer-static-smoke.mjs +5 -0
  40. package/scripts/zteam-hot-add/smoke.mjs +77 -0
  41. package/scripts/zteam-lifecycle-smoke.mjs +154 -0
  42. package/scripts/zteam-tools/smoke.mjs +180 -0
@@ -1404,6 +1404,48 @@
1404
1404
  ],
1405
1405
  "noShipNotes": "Factory-mode only; requires manifest/checkpoint/sentinel validation."
1406
1406
  },
1407
+ {
1408
+ "name": "zob_zteam_hot_add",
1409
+ "family": "zteam/zagent",
1410
+ "modes": [
1411
+ "plan",
1412
+ "implement",
1413
+ "orchestrator",
1414
+ "factory"
1415
+ ],
1416
+ "skillRefs": [
1417
+ ".pi/skills/zob-zagent-creator/SKILL.md",
1418
+ ".pi/skills/zob-coms-v2-live/SKILL.md",
1419
+ ".pi/skills/zob-harness/SKILL.md"
1420
+ ],
1421
+ "docRefs": [
1422
+ ".pi/extensions/zob-harness/src/runtime/tools-zagent.ts",
1423
+ ".pi/extensions/zob-harness/src/runtime/schemas.ts",
1424
+ ".pi/extensions/zob-harness/src/domains/coms/zagents.ts"
1425
+ ],
1426
+ "noShipNotes": "Agent-executable governed ZTeam/ZAgent hot-add. Defaults to plan-only/no-spawn with action=plan and spawnCount=0; action=apply still writes only promptRef/prompt, ZAgent manifest, and ZTeam membership after exact apply_confirmation=<team_id> and never launches. action=launch is explicit and requires exact launch_confirmation_phrase: LAUNCH ZTEAM <team_id> ZAGENT <zagent_id> IN TMUX <session_name>; it requires existing ZTeam/ZAgent manifests, existing membership, existing tmux session, absent target window, safe ids/window/session, and bounded cwd. Launch creates only one scoped tmux new-window in the existing session and send-keys a single ZOB_ZTEAM_ID/ZOB_ZAGENT_ID pi command for the target ZAgent; it never creates sessions, kills/closes/reloads windows, or touches unrelated agents. It polls local ZPeer lease/registry presence with bounded timeout and returns liveProofBlocked unless presence is online. Durable ledgers remain local-only/hash-only/body-free; raw request, generated prompt body, launch command, output, and diffs are not persisted."
1427
+ },
1428
+ {
1429
+ "name": "zob_zteam_remove",
1430
+ "family": "zteam/zagent",
1431
+ "modes": [
1432
+ "plan",
1433
+ "implement",
1434
+ "orchestrator",
1435
+ "factory"
1436
+ ],
1437
+ "skillRefs": [
1438
+ ".pi/skills/zob-zagent-creator/SKILL.md",
1439
+ ".pi/skills/zob-coms-v2-live/SKILL.md",
1440
+ ".pi/skills/zob-harness/SKILL.md"
1441
+ ],
1442
+ "docRefs": [
1443
+ ".pi/extensions/zob-harness/src/runtime/tools-zagent.ts",
1444
+ ".pi/extensions/zob-harness/src/runtime/schemas.ts",
1445
+ ".pi/extensions/zob-harness/src/domains/coms/zagents.ts"
1446
+ ],
1447
+ "noShipNotes": "Agent-executable governed ZTeam/ZAgent remove/delete/close planner. Defaults to plan-only/no-spawn with action=plan, spawnCount=0, closeCount=0, and bodyStored=false/promptBodiesStored=false/outputBodiesStored=false in durable records. action=apply preserves existing file-delete semantics and requires exact confirmation phrase: REMOVE ZTEAM <team_id> ZAGENT <zagent_id> SCOPE <membership|manifest|prompt|manifest_and_prompt>. action=close_tmux is explicit and requires exact close_confirmation_phrase: CLOSE ZTEAM <team_id> ZAGENT <zagent_id> TMUX WINDOW <session_name>; it validates safe team/agent/session/window ids, bounded cwd paths, existing tmux session, and existing target window. It sends only scoped tmux send-keys C-u /quit C-m to the selected window, waits bounded for local ZPeer presence offline/none and/or target window disappearance, and if force_close_window=true may use targeted tmux kill-window only for that selected window. It never creates sessions, kills sessions, uses broad process termination, reloads, closes all, or touches unrelated windows/agents. Durable ledgers remain local-only/hash-only/body-free; raw tmux commands, raw output, diffs, prompts, and secrets are not persisted."
1448
+ },
1407
1449
  {
1408
1450
  "name": "zob_zcommit_run",
1409
1451
  "family": "git/commit",
@@ -1626,7 +1668,7 @@
1626
1668
  "AGENTS.md",
1627
1669
  ".pi/skills/zob-zagent-creator/SKILL.md"
1628
1670
  ],
1629
- "noShipNotes": "Project-local ZTeam bundle UX for .pi/zteams/ only. ZTeam members are full-session Pi/ZPeer identities tied to live coordination, not delegate subagents; launch-plan is plan/metadata only and must not spawn processes. Persisted command metadata must remain hash/body-free."
1671
+ "noShipNotes": "Project-local ZTeam bundle UX for .pi/zteams/ only. ZTeam members are full-session Pi/ZPeer identities tied to live coordination, not delegate subagents; launch-plan and hot-add default to plan/metadata only and must not spawn processes. Hot-add supports explicit team ids or current-context inference from ZOB_ZTEAM_ID, active ZAgent, ZPeer team/active room, or repo conventions. Hot-add apply requires --apply --confirm <team-id>; optional tmux-window launch planning requires --tmux-window --launch-confirm <team-id>; raw natural-language ask is hashed only in durable records; generated ZAgents include promptRef/prompt, explicit tools/paths/gates; live presence checks use local lease/registry evidence and tmux windows are not presence proof; persisted command metadata must remain hash/body-free. Reset sends Pi /new and reload sends Pi /reload to existing scoped tmux agent windows without close/start; quit calls only the scoped launcher close for the current or explicit team."
1630
1672
  },
1631
1673
  {
1632
1674
  "name": "rules_status",
@@ -53,6 +53,7 @@ export const ZOB_WORKSPACE_CLAIM_TOOLS = ["zob_workspace_claim", "zob_workspace_
53
53
  export const ZOB_WORKER_POOL_TOOLS = ["zob_worker_pool_plan", "zob_worker_pool_status", "zob_worker_pool_owner_request", "zob_worker_pool_owner_decision"] as const;
54
54
  export const ZOB_MERGE_QUEUE_TOOLS = ["zob_merge_candidate_submit", "zob_merge_queue_decide", "zob_merge_queue_list"] as const;
55
55
  export const ZOB_ZCOMMIT_TOOLS = ["zob_zcommit_run"] as const;
56
+ export const ZOB_ZAGENT_TOOLS = ["zob_zteam_hot_add", "zob_zteam_remove"] as const;
56
57
  export const ZOB_DELEGATION_READ_TOOLS = ["zob_delegation_catalog", "get_delegation_run", "await_delegation_run"] as const;
57
58
  export const ZOB_MISSION_CONTROL_READ_TOOLS = ["zob_coms_readiness", "zob_mission_control_snapshot"] as const;
58
59
  export const ZOB_MISSION_CONTROL_PROPOSAL_TOOLS = ["zob_mission_control_propose_command"] as const;
@@ -68,11 +69,11 @@ export const ZOB_AUTONOMOUS_FACTORY_TOOLS = ["zob_autonomous_dry_run", "zob_auto
68
69
 
69
70
  export const MODE_TOOLS: Record<ModeName, string[]> = {
70
71
  explore: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zob_goal_room_list", "zob_workspace_claims_list", "zob_worker_pool_status", "zob_merge_queue_list", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS],
71
- plan: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
72
- implement: ["read", "bash", "edit", "write", "grep", "find", "ls", "delegate_agent", "delegate_task", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
72
+ plan: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
73
+ implement: ["read", "bash", "edit", "write", "grep", "find", "ls", "delegate_agent", "delegate_task", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
73
74
  oracle: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zob_goal_room_list", "zob_workspace_claims_list", "zob_worker_pool_status", "zob_merge_queue_list", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS],
74
- orchestrator: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS],
75
- factory: ["read", "bash", "edit", "write", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "factory_run", "factory_quarantine_review", "factory_quarantine_activate", "factory_quarantine_verify_activation", "chain_run", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_AUTONOMOUS_FACTORY_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
75
+ orchestrator: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS],
76
+ factory: ["read", "bash", "edit", "write", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "factory_run", "factory_quarantine_review", "factory_quarantine_activate", "factory_quarantine_verify_activation", "chain_run", ...ZOB_RUNTIME_GOAL_TOOLS, ...ZOB_DELEGATION_READ_TOOLS, ...ZOB_ZCOMMIT_TOOLS, ...ZOB_ZAGENT_TOOLS, ...ZOB_AUTONOMOUS_READ_TOOLS, ...ZOB_AUTONOMOUS_FACTORY_TOOLS, ...ZOB_COMS_TOOLS, ...ZOB_GOAL_ROOM_TOOLS, ...ZOB_GOVERNED_REQUEST_TOOLS, ...ZOB_WORKSPACE_CLAIM_TOOLS, ...ZOB_WORKER_POOL_TOOLS, ...ZOB_MERGE_QUEUE_TOOLS, ...ZOB_MISSION_CONTROL_READ_TOOLS, ...ZOB_MISSION_CONTROL_PROPOSAL_TOOLS, ...ZOB_CONTEXT_READ_TOOLS, ...ZOB_CONTEXT_PROPOSAL_TOOLS, ...ZOB_COMPUTE_READ_TOOLS, ...ZOB_COMPUTE_REPORT_TOOLS, ...ZOB_PROJECT_DNA_READ_TOOLS, ...ZOB_PROJECT_DNA_PROPOSAL_TOOLS],
76
77
  // Vanilla is handled specially by applyMode: all currently available Pi tools are enabled.
77
78
  vanilla: [],
78
79
  };
@@ -5,13 +5,16 @@ import { dirname, join } from "node:path";
5
5
  import { loadTeamDefinition, validateTeamDefinition } from "../../topology/teams.js";
6
6
  import { isRecord } from "../../../core/utils/records.js";
7
7
  import { safeFileStem } from "../../../core/utils/paths.js";
8
+ import { sha256 } from "../../../core/utils/hashing.js";
8
9
  import { buildCurrentZobLivePeerCard, buildZobComsProjectId } from "./identity.js";
10
+ import { pingZobLocalEndpoint } from "./local-transport.js";
9
11
  import { readZobComsV2Policy, zobComsRegistryEnabled } from "./policy.js";
10
- import type { ZobLivePeerCard, ZobLivePeerStatus, ZobLiveRegistrySnapshot } from "./types.js";
12
+ import type { ZobLivePeerCard, ZobLivePeerStatus, ZobLiveRegistrySnapshot, ZobLiveTeamAgentLease } from "./types.js";
11
13
 
12
14
  const FORBIDDEN_PERSISTED_KEYS = new Set(["body", "task", "prompt", "output", "content", "message", "rationale", "text", "diff", "patch"]);
13
15
  const DEFAULT_OFFLINE_PEER_RETENTION_MS = 24 * 60 * 60 * 1000;
14
16
  const MIN_OFFLINE_PEER_RETENTION_MS = 5 * 60 * 1000;
17
+ const MIN_LEASE_TTL_MS = 30_000;
15
18
 
16
19
  function registryRoot(): { path: string; kind: "user_runtime" | "env_override" } {
17
20
  const override = process.env.ZOB_COMS_REGISTRY_ROOT;
@@ -31,6 +34,12 @@ function projectAgentsDir(repoRoot: string): { dir: string; projectId: string; k
31
34
  return { dir: join(root.path, "projects", projectId, "agents"), projectId, kind: root.kind };
32
35
  }
33
36
 
37
+ function projectLeasesDir(repoRoot: string): { dir: string; projectId: string; kind: "user_runtime" | "env_override" } {
38
+ const root = registryRoot();
39
+ const projectId = buildZobComsProjectId(repoRoot);
40
+ return { dir: join(root.path, "projects", projectId, "leases"), projectId, kind: root.kind };
41
+ }
42
+
34
43
  function peerPath(repoRoot: string, peer: Pick<ZobLivePeerCard, "roleId" | "sessionHash">): string {
35
44
  const { dir } = projectAgentsDir(repoRoot);
36
45
  return join(dir, `${safeFileStem(`${peer.roleId}-${peer.sessionHash.slice(0, 12)}`)}.json`);
@@ -40,6 +49,22 @@ function peerPathForProjectId(projectId: string, peer: Pick<ZobLivePeerCard, "ro
40
49
  return join(registryRoot().path, "projects", safeFileStem(projectId), "agents", `${safeFileStem(`${peer.roleId}-${peer.sessionHash.slice(0, 12)}`)}.json`);
41
50
  }
42
51
 
52
+ function stableLeaseStem(input: Pick<ZobLivePeerCard, "team" | "roleId"> | Pick<ZobLiveTeamAgentLease, "teamId" | "agentId">): string {
53
+ const teamId = "team" in input ? input.team : input.teamId;
54
+ const agentId = "roleId" in input ? input.roleId : input.agentId;
55
+ return safeFileStem(`${teamId}-${agentId}`);
56
+ }
57
+
58
+ function leasePath(repoRoot: string, peer: Pick<ZobLivePeerCard, "team" | "roleId">): string {
59
+ const { dir } = projectLeasesDir(repoRoot);
60
+ return join(dir, `${stableLeaseStem(peer)}.json`);
61
+ }
62
+
63
+ function leasePathForTeamAgent(repoRoot: string, teamId: string, agentId: string): string {
64
+ const { dir } = projectLeasesDir(repoRoot);
65
+ return join(dir, `${safeFileStem(`${teamId}-${agentId}`)}.json`);
66
+ }
67
+
43
68
  function readPeerCardsFromAgentsDir(dir: string, nowMs: number, teamName?: string): ZobLivePeerCard[] {
44
69
  if (!existsSync(dir)) return [];
45
70
  return readdirSync(dir)
@@ -56,6 +81,22 @@ function readPeerCardsFromAgentsDir(dir: string, nowMs: number, teamName?: strin
56
81
  .map((peer) => ({ ...peer, status: derivePeerStatus(peer, nowMs) }));
57
82
  }
58
83
 
84
+ function readLeasesFromDir(dir: string, nowMs: number, teamName?: string): ZobLivePeerCard[] {
85
+ if (!existsSync(dir)) return [];
86
+ return readdirSync(dir)
87
+ .filter((entry) => entry.endsWith(".json"))
88
+ .map((entry) => {
89
+ try {
90
+ return parseTeamAgentLease(JSON.parse(readFileSync(join(dir, entry), "utf8")) as unknown);
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ })
95
+ .filter((lease): lease is ZobLiveTeamAgentLease => Boolean(lease))
96
+ .filter((lease) => !teamName || lease.teamId === teamName)
97
+ .map((lease) => leaseToPeerCard(lease, nowMs));
98
+ }
99
+
59
100
  function boundedOfflinePeerRetentionMs(value: number | undefined): number {
60
101
  const env = Number.parseInt(process.env.ZOB_COMS_OFFLINE_PEER_RETENTION_MS ?? "", 10);
61
102
  const raw = typeof value === "number" && Number.isFinite(value) ? value : Number.isFinite(env) ? env : DEFAULT_OFFLINE_PEER_RETENTION_MS;
@@ -69,6 +110,12 @@ function offlinePeerExpired(peer: ZobLivePeerCard, nowMs: number, retentionMs: n
69
110
  return nowMs - heartbeatMs >= Math.max(peer.offlineAfterMs, retentionMs);
70
111
  }
71
112
 
113
+ function leaseExpired(lease: ZobLiveTeamAgentLease, nowMs: number, retentionMs = 0): boolean {
114
+ const expiresMs = Date.parse(lease.expiresAt);
115
+ if (!Number.isFinite(expiresMs)) return true;
116
+ return nowMs >= expiresMs + Math.max(0, retentionMs);
117
+ }
118
+
72
119
  function allProjectAgentsDirs(): string[] {
73
120
  const root = registryRoot();
74
121
  const projectsDir = join(root.path, "projects");
@@ -78,6 +125,15 @@ function allProjectAgentsDirs(): string[] {
78
125
  .map((entry) => join(projectsDir, entry.name, "agents"));
79
126
  }
80
127
 
128
+ function allProjectLeasesDirs(): string[] {
129
+ const root = registryRoot();
130
+ const projectsDir = join(root.path, "projects");
131
+ if (!existsSync(projectsDir)) return [];
132
+ return readdirSync(projectsDir, { withFileTypes: true })
133
+ .filter((entry) => entry.isDirectory())
134
+ .map((entry) => join(projectsDir, entry.name, "leases"));
135
+ }
136
+
81
137
  function buildSnapshot(projectId: string, kind: "user_runtime" | "env_override", peers: ZobLivePeerCard[], teamName?: string): ZobLiveRegistrySnapshot {
82
138
  const counts: Record<ZobLivePeerStatus, number> = { online: 0, stale: 0, offline: 0 };
83
139
  for (const peer of peers) counts[peer.status] += 1;
@@ -100,6 +156,15 @@ function parsePeerCard(value: unknown): ZobLivePeerCard | undefined {
100
156
  return value as unknown as ZobLivePeerCard;
101
157
  }
102
158
 
159
+ function parseTeamAgentLease(value: unknown): ZobLiveTeamAgentLease | undefined {
160
+ if (!isRecord(value) || value.schema !== "zob.live-team-agent-lease.v1") return undefined;
161
+ if (hasForbiddenPersistedKey(value) || value.bodyStored !== false || value.localOnly !== true || value.networkEnabled !== false) return undefined;
162
+ if (value.stableLease !== true || value.exclusiveBy !== "teamId+agentId") return undefined;
163
+ if (typeof value.teamId !== "string" || typeof value.agentId !== "string" || typeof value.sessionHash !== "string") return undefined;
164
+ if (typeof value.leaseOwnerId !== "string" || typeof value.endpoint !== "string" || typeof value.endpointHash !== "string") return undefined;
165
+ return value as unknown as ZobLiveTeamAgentLease;
166
+ }
167
+
103
168
  function derivePeerStatus(peer: ZobLivePeerCard, nowMs: number): ZobLivePeerStatus {
104
169
  if (peer.status === "offline") return "offline";
105
170
  const heartbeatMs = Date.parse(peer.heartbeatAt);
@@ -109,32 +174,206 @@ function derivePeerStatus(peer: ZobLivePeerCard, nowMs: number): ZobLivePeerStat
109
174
  return "online";
110
175
  }
111
176
 
177
+ function deriveLeaseStatus(lease: ZobLiveTeamAgentLease, nowMs: number): ZobLivePeerStatus {
178
+ if (lease.status === "offline" || leaseExpired(lease, nowMs)) return "offline";
179
+ const heartbeatMs = Date.parse(lease.heartbeatAt);
180
+ if (!Number.isFinite(heartbeatMs)) return "stale";
181
+ if (nowMs - heartbeatMs >= lease.offlineAfterMs) return "offline";
182
+ if (nowMs - heartbeatMs >= lease.staleAfterMs) return "stale";
183
+ return "online";
184
+ }
185
+
186
+ function leaseOwnerId(peer: Pick<ZobLivePeerCard, "team" | "roleId" | "sessionHash" | "endpointHash">): string {
187
+ return `${peer.team}:${peer.roleId}:${peer.sessionHash}:${peer.endpointHash}`;
188
+ }
189
+
190
+ function sameLeaseOwner(lease: ZobLiveTeamAgentLease, peer: Pick<ZobLivePeerCard, "team" | "roleId" | "sessionHash" | "endpointHash">): boolean {
191
+ return lease.teamId === peer.team
192
+ && lease.agentId === peer.roleId
193
+ && lease.sessionHash === peer.sessionHash
194
+ && lease.endpointHash === peer.endpointHash
195
+ && lease.leaseOwnerId === leaseOwnerId(peer);
196
+ }
197
+
198
+ function leaseExpiresAt(peer: Pick<ZobLivePeerCard, "offlineAfterMs" | "staleAfterMs">, nowMs: number): string {
199
+ const ttlMs = Math.max(MIN_LEASE_TTL_MS, Math.floor(Math.max(peer.offlineAfterMs, peer.staleAfterMs * 2)));
200
+ return new Date(nowMs + ttlMs).toISOString();
201
+ }
202
+
203
+ function buildTeamAgentLease(repoRoot: string, peer: ZobLivePeerCard, input: { nowMs?: number } = {}): ZobLiveTeamAgentLease {
204
+ const nowMs = input.nowMs ?? Date.now();
205
+ const now = new Date(nowMs).toISOString();
206
+ const projectId = buildZobComsProjectId(repoRoot);
207
+ const ownerId = leaseOwnerId(peer);
208
+ return {
209
+ schema: "zob.live-team-agent-lease.v1",
210
+ projectId,
211
+ teamId: peer.team,
212
+ agentId: peer.roleId,
213
+ roleId: peer.roleId,
214
+ roleType: peer.roleType,
215
+ leadId: peer.leadId,
216
+ agent: peer.agent,
217
+ sessionId: peer.sessionId,
218
+ sessionHash: peer.sessionHash,
219
+ leaseOwnerId: ownerId,
220
+ leaseOwnerHash: sha256(ownerId),
221
+ transport: peer.transport,
222
+ endpoint: peer.endpoint,
223
+ endpointHash: peer.endpointHash,
224
+ cwdHash: peer.cwdHash,
225
+ pid: peer.pid,
226
+ startedAt: peer.startedAt,
227
+ heartbeatAt: now,
228
+ leasedAt: now,
229
+ renewedAt: now,
230
+ expiresAt: leaseExpiresAt(peer, nowMs),
231
+ contextUsedPct: peer.contextUsedPct,
232
+ queueDepth: peer.queueDepth,
233
+ status: peer.status === "offline" ? "offline" : "online",
234
+ zpeerRoomId: peer.zpeerRoomId,
235
+ zpeerAlias: peer.zpeerAlias,
236
+ zpeerActiveRoomId: peer.zpeerActiveRoomId,
237
+ zpeerMemberships: peer.zpeerMemberships,
238
+ zpeerLocalOnly: peer.zpeerLocalOnly,
239
+ staleAfterMs: peer.staleAfterMs,
240
+ offlineAfterMs: peer.offlineAfterMs,
241
+ stableLease: true,
242
+ exclusiveBy: "teamId+agentId",
243
+ localOnly: true,
244
+ networkEnabled: false,
245
+ bodyStored: false,
246
+ };
247
+ }
248
+
249
+ function renewTeamAgentLease(repoRoot: string, existing: ZobLiveTeamAgentLease | undefined, peer: ZobLivePeerCard, input: { nowMs?: number } = {}): ZobLiveTeamAgentLease {
250
+ const nowMs = input.nowMs ?? Date.now();
251
+ const now = new Date(nowMs).toISOString();
252
+ const next = buildTeamAgentLease(repoRoot, peer, { nowMs });
253
+ return existing && sameLeaseOwner(existing, peer)
254
+ ? { ...next, leasedAt: existing.leasedAt, renewedAt: now }
255
+ : next;
256
+ }
257
+
258
+ function leaseToPeerCard(lease: ZobLiveTeamAgentLease, nowMs: number): ZobLivePeerCard {
259
+ return {
260
+ schema: "zob.live-peer-card.v1",
261
+ projectId: lease.projectId,
262
+ team: lease.teamId,
263
+ roleId: lease.roleId,
264
+ roleType: lease.roleType,
265
+ leadId: lease.leadId,
266
+ agent: lease.agent,
267
+ sessionId: lease.sessionId,
268
+ sessionHash: lease.sessionHash,
269
+ transport: lease.transport,
270
+ endpoint: lease.endpoint,
271
+ endpointHash: lease.endpointHash,
272
+ cwdHash: lease.cwdHash,
273
+ pid: lease.pid,
274
+ startedAt: lease.startedAt,
275
+ heartbeatAt: lease.heartbeatAt,
276
+ contextUsedPct: lease.contextUsedPct,
277
+ queueDepth: lease.queueDepth,
278
+ status: deriveLeaseStatus(lease, nowMs),
279
+ zpeerRoomId: lease.zpeerRoomId,
280
+ zpeerAlias: lease.zpeerAlias,
281
+ zpeerActiveRoomId: lease.zpeerActiveRoomId,
282
+ zpeerMemberships: lease.zpeerMemberships,
283
+ zpeerLocalOnly: lease.zpeerLocalOnly,
284
+ staleAfterMs: lease.staleAfterMs,
285
+ offlineAfterMs: lease.offlineAfterMs,
286
+ bodyStored: false,
287
+ };
288
+ }
289
+
290
+ function readLeaseAtPath(filePath: string): ZobLiveTeamAgentLease | undefined {
291
+ if (!existsSync(filePath)) return undefined;
292
+ try {
293
+ return parseTeamAgentLease(JSON.parse(readFileSync(filePath, "utf8")) as unknown);
294
+ } catch {
295
+ return undefined;
296
+ }
297
+ }
298
+
299
+ function writeLeaseAtPath(filePath: string, lease: ZobLiveTeamAgentLease): ZobLiveTeamAgentLease {
300
+ if (hasForbiddenPersistedKey(lease)) throw new Error("Refusing to persist ZOB live team-agent lease with forbidden body-like keys");
301
+ if (lease.bodyStored !== false || lease.localOnly !== true || lease.networkEnabled !== false) throw new Error("ZOB live team-agent lease must be localOnly=true networkEnabled=false bodyStored=false");
302
+ mkdirSync(dirname(filePath), { recursive: true });
303
+ writeFileSync(filePath, `${JSON.stringify(lease, null, 2)}\n`, "utf8");
304
+ return lease;
305
+ }
306
+
307
+ function leaseHasLocalSocketEndpoint(lease: ZobLiveTeamAgentLease): boolean {
308
+ return lease.transport === "local_socket"
309
+ && Boolean(lease.endpoint)
310
+ && !lease.endpoint.startsWith("pending-")
311
+ && lease.endpoint !== "observe-only"
312
+ && existsSync(lease.endpoint);
313
+ }
314
+
315
+ async function leaseRespondsToPing(lease: ZobLiveTeamAgentLease): Promise<boolean> {
316
+ if (!leaseHasLocalSocketEndpoint(lease)) return false;
317
+ try {
318
+ const response = await pingZobLocalEndpoint(lease.endpoint, `zteam-agent-lease-ping:${lease.teamId}:${lease.agentId}:${Date.now()}`);
319
+ return response.type === "pong" || response.type === "ack";
320
+ } catch {
321
+ return false;
322
+ }
323
+ }
324
+
112
325
  export function pruneExpiredZobLivePeers(repoRoot: string, input: { teamName?: string; nowMs?: number; retentionMs?: number } = {}): { schema: "zob.live-registry-prune.v1"; pruned: number; retained: number; retentionMs: number; bodyStored: false } {
113
326
  const { dir } = projectAgentsDir(repoRoot);
114
327
  const nowMs = input.nowMs ?? Date.now();
115
328
  const retentionMs = boundedOfflinePeerRetentionMs(input.retentionMs);
116
329
  let pruned = 0;
117
330
  let retained = 0;
118
- if (!existsSync(dir)) return { schema: "zob.live-registry-prune.v1", pruned, retained, retentionMs, bodyStored: false };
119
- for (const entry of readdirSync(dir).filter((name) => name.endsWith(".json"))) {
120
- const filePath = join(dir, entry);
121
- try {
122
- const peer = parsePeerCard(JSON.parse(readFileSync(filePath, "utf8")) as unknown);
123
- if (!peer || (input.teamName && peer.team !== input.teamName)) {
124
- retained += 1;
125
- continue;
126
- }
127
- if (offlinePeerExpired(peer, nowMs, retentionMs)) {
128
- unlinkSync(filePath);
129
- pruned += 1;
130
- } else {
331
+ if (existsSync(dir)) {
332
+ for (const entry of readdirSync(dir).filter((name) => name.endsWith(".json"))) {
333
+ const filePath = join(dir, entry);
334
+ try {
335
+ const peer = parsePeerCard(JSON.parse(readFileSync(filePath, "utf8")) as unknown);
336
+ if (!peer || (input.teamName && peer.team !== input.teamName)) {
337
+ retained += 1;
338
+ continue;
339
+ }
340
+ if (offlinePeerExpired(peer, nowMs, retentionMs)) {
341
+ unlinkSync(filePath);
342
+ pruned += 1;
343
+ } else {
344
+ retained += 1;
345
+ }
346
+ } catch {
131
347
  retained += 1;
132
348
  }
133
- } catch {
349
+ }
350
+ }
351
+ const leasePrune = pruneExpiredZobLiveTeamAgentLeases(repoRoot, input);
352
+ return { schema: "zob.live-registry-prune.v1", pruned: pruned + leasePrune.pruned, retained: retained + leasePrune.retained, retentionMs, bodyStored: false };
353
+ }
354
+
355
+ export function pruneExpiredZobLiveTeamAgentLeases(repoRoot: string, input: { teamName?: string; nowMs?: number; retentionMs?: number } = {}): { schema: "zob.live-team-agent-lease-prune.v1"; pruned: number; retained: number; retentionMs: number; bodyStored: false } {
356
+ const { dir } = projectLeasesDir(repoRoot);
357
+ const nowMs = input.nowMs ?? Date.now();
358
+ const retentionMs = boundedOfflinePeerRetentionMs(input.retentionMs);
359
+ let pruned = 0;
360
+ let retained = 0;
361
+ if (!existsSync(dir)) return { schema: "zob.live-team-agent-lease-prune.v1", pruned, retained, retentionMs, bodyStored: false };
362
+ for (const entry of readdirSync(dir).filter((name) => name.endsWith(".json"))) {
363
+ const filePath = join(dir, entry);
364
+ const lease = readLeaseAtPath(filePath);
365
+ if (!lease || (input.teamName && lease.teamId !== input.teamName)) {
366
+ retained += 1;
367
+ continue;
368
+ }
369
+ if (leaseExpired(lease, nowMs, retentionMs)) {
370
+ unlinkSync(filePath);
371
+ pruned += 1;
372
+ } else {
134
373
  retained += 1;
135
374
  }
136
375
  }
137
- return { schema: "zob.live-registry-prune.v1", pruned, retained, retentionMs, bodyStored: false };
376
+ return { schema: "zob.live-team-agent-lease-prune.v1", pruned, retained, retentionMs, bodyStored: false };
138
377
  }
139
378
 
140
379
  export function writeZobLivePeerCard(repoRoot: string, peer: ZobLivePeerCard): ZobLivePeerCard {
@@ -142,7 +381,7 @@ export function writeZobLivePeerCard(repoRoot: string, peer: ZobLivePeerCard): Z
142
381
  if (peer.bodyStored !== false) throw new Error("ZOB live peer card bodyStored must be false");
143
382
  const { dir } = projectAgentsDir(repoRoot);
144
383
  mkdirSync(dir, { recursive: true });
145
- writeFileSync(peerPath(repoRoot, peer), `${JSON.stringify(peer, null, 2)}\n`, "utf8");
384
+ writeFileSync(peerPath(repoRoot, peer), `${JSON.stringify({ ...peer, projectId: buildZobComsProjectId(repoRoot) }, null, 2)}\n`, "utf8");
146
385
  return peer;
147
386
  }
148
387
 
@@ -155,18 +394,112 @@ export function writeZobLivePeerCardToProjectId(peer: ZobLivePeerCard): ZobLiveP
155
394
  return peer;
156
395
  }
157
396
 
397
+ export function writeZobLiveTeamAgentLease(repoRoot: string, peer: ZobLivePeerCard, input: { reason?: string; nowMs?: number } = {}): ZobLiveTeamAgentLease {
398
+ const filePath = leasePath(repoRoot, peer);
399
+ const existing = readLeaseAtPath(filePath);
400
+ const nowMs = input.nowMs ?? Date.now();
401
+ if (existing && !sameLeaseOwner(existing, peer) && !leaseExpired(existing, nowMs)) return existing;
402
+ const ownedExisting = existing && sameLeaseOwner(existing, peer) ? existing : undefined;
403
+ return writeLeaseAtPath(filePath, renewTeamAgentLease(repoRoot, ownedExisting, peer, { nowMs }));
404
+ }
405
+
406
+ export async function claimZobLiveTeamAgentLease(repoRoot: string, peer: ZobLivePeerCard, input: { reason?: string; nowMs?: number } = {}): Promise<
407
+ | { ok: true; status: "acquired" | "renewed" | "reclaimed"; lease: ZobLiveTeamAgentLease; previousOwnerHash?: string; pingChecked: boolean; bodyStored: false }
408
+ | { ok: false; status: "blocked_live_owner"; lease: ZobLiveTeamAgentLease; ownerHash: string; reason: string; pingChecked: true; bodyStored: false }
409
+ > {
410
+ const filePath = leasePath(repoRoot, peer);
411
+ const existing = readLeaseAtPath(filePath);
412
+ const nowMs = input.nowMs ?? Date.now();
413
+ if (!existing) {
414
+ const lease = writeLeaseAtPath(filePath, buildTeamAgentLease(repoRoot, peer, { nowMs }));
415
+ return { ok: true, status: "acquired", lease, pingChecked: false, bodyStored: false };
416
+ }
417
+ if (sameLeaseOwner(existing, peer)) {
418
+ const lease = writeLeaseAtPath(filePath, renewTeamAgentLease(repoRoot, existing, peer, { nowMs }));
419
+ return { ok: true, status: "renewed", lease, pingChecked: false, bodyStored: false };
420
+ }
421
+ const responsive = await leaseRespondsToPing(existing);
422
+ if (responsive) {
423
+ return {
424
+ ok: false,
425
+ status: "blocked_live_owner",
426
+ lease: existing,
427
+ ownerHash: existing.leaseOwnerHash,
428
+ reason: `stable team-agent lease for ${existing.teamId}/${existing.agentId} is held by a responsive live endpoint`,
429
+ pingChecked: true,
430
+ bodyStored: false,
431
+ };
432
+ }
433
+ const lease = writeLeaseAtPath(filePath, buildTeamAgentLease(repoRoot, peer, { nowMs }));
434
+ return { ok: true, status: "reclaimed", lease, previousOwnerHash: existing.leaseOwnerHash, pingChecked: true, bodyStored: false };
435
+ }
436
+
437
+ export function releaseZobLiveTeamAgentLease(repoRoot: string, peer: ZobLivePeerCard, input: { reason?: string } = {}): { schema: "zob.live-team-agent-lease-release.v1"; released: boolean; reason: "released" | "not_found" | "owner_mismatch"; teamId: string; agentId: string; leaseOwnerHash?: string; bodyStored: false } {
438
+ void input;
439
+ const filePath = leasePath(repoRoot, peer);
440
+ const existing = readLeaseAtPath(filePath);
441
+ if (!existing) return { schema: "zob.live-team-agent-lease-release.v1", released: false, reason: "not_found", teamId: peer.team, agentId: peer.roleId, bodyStored: false };
442
+ if (!sameLeaseOwner(existing, peer)) return { schema: "zob.live-team-agent-lease-release.v1", released: false, reason: "owner_mismatch", teamId: peer.team, agentId: peer.roleId, leaseOwnerHash: existing.leaseOwnerHash, bodyStored: false };
443
+ unlinkSync(filePath);
444
+ return { schema: "zob.live-team-agent-lease-release.v1", released: true, reason: "released", teamId: peer.team, agentId: peer.roleId, leaseOwnerHash: existing.leaseOwnerHash, bodyStored: false };
445
+ }
446
+
447
+ export function ownsZobLiveTeamAgentLease(repoRoot: string, peer: ZobLivePeerCard): { schema: "zob.live-team-agent-lease-ownership.v1"; owned: boolean; reason: "owned" | "not_found" | "owner_mismatch" | "expired"; teamId: string; agentId: string; leaseOwnerHash?: string; bodyStored: false } {
448
+ const filePath = leasePath(repoRoot, peer);
449
+ const existing = readLeaseAtPath(filePath);
450
+ if (!existing) return { schema: "zob.live-team-agent-lease-ownership.v1", owned: false, reason: "not_found", teamId: peer.team, agentId: peer.roleId, bodyStored: false };
451
+ if (leaseExpired(existing, Date.now())) return { schema: "zob.live-team-agent-lease-ownership.v1", owned: false, reason: "expired", teamId: peer.team, agentId: peer.roleId, leaseOwnerHash: existing.leaseOwnerHash, bodyStored: false };
452
+ if (!sameLeaseOwner(existing, peer)) return { schema: "zob.live-team-agent-lease-ownership.v1", owned: false, reason: "owner_mismatch", teamId: peer.team, agentId: peer.roleId, leaseOwnerHash: existing.leaseOwnerHash, bodyStored: false };
453
+ return { schema: "zob.live-team-agent-lease-ownership.v1", owned: true, reason: "owned", teamId: peer.team, agentId: peer.roleId, leaseOwnerHash: existing.leaseOwnerHash, bodyStored: false };
454
+ }
455
+
456
+ export async function retireInactiveZobLiveTeamAgentLeases(repoRoot: string, input: { teamName: string; agentIds: string[]; nowMs?: number }): Promise<{ schema: "zob.live-team-agent-lease-retire.v1"; teamName: string; checked: number; retired: number; retainedLive: number; missing: number; errorHashes: string[]; bodyStored: false }> {
457
+ const errors: string[] = [];
458
+ let checked = 0;
459
+ let retired = 0;
460
+ let retainedLive = 0;
461
+ let missing = 0;
462
+ const uniqueAgentIds = [...new Set(input.agentIds.filter((agentId) => agentId.trim().length > 0))];
463
+ for (const agentId of uniqueAgentIds) {
464
+ const filePath = leasePathForTeamAgent(repoRoot, input.teamName, agentId);
465
+ const lease = readLeaseAtPath(filePath);
466
+ if (!lease) {
467
+ missing += 1;
468
+ continue;
469
+ }
470
+ checked += 1;
471
+ try {
472
+ const live = await leaseRespondsToPing(lease);
473
+ if (live) {
474
+ retainedLive += 1;
475
+ } else {
476
+ unlinkSync(filePath);
477
+ retired += 1;
478
+ }
479
+ } catch (error) {
480
+ errors.push(error instanceof Error ? error.message : String(error));
481
+ }
482
+ }
483
+ return { schema: "zob.live-team-agent-lease-retire.v1", teamName: input.teamName, checked, retired, retainedLive, missing, errorHashes: errors.map((error) => sha256(error)), bodyStored: false };
484
+ }
485
+
158
486
  export function registerCurrentZobLivePeer(repoRoot: string, teamName = "zob-core"): ZobLivePeerCard | undefined {
159
487
  const policy = readZobComsV2Policy(repoRoot);
160
488
  if (!zobComsRegistryEnabled(policy)) return undefined;
161
489
  const team = loadTeamDefinition(repoRoot, teamName);
162
490
  const errors = [...team.errors, ...validateTeamDefinition(repoRoot, team.definition)];
163
491
  if (errors.length > 0 || !team.definition) throw new Error(`Cannot register ZOB live peer: ${errors.join("; ")}`);
164
- return writeZobLivePeerCard(repoRoot, buildCurrentZobLivePeerCard(repoRoot, team.definition, policy));
492
+ const peer = writeZobLivePeerCard(repoRoot, buildCurrentZobLivePeerCard(repoRoot, team.definition, policy));
493
+ writeZobLiveTeamAgentLease(repoRoot, peer, { reason: "register_current" });
494
+ return peer;
165
495
  }
166
496
 
167
497
  export function touchCurrentZobLivePeer(repoRoot: string, teamName = "zob-core"): ZobLivePeerCard | undefined {
168
498
  const peer = registerCurrentZobLivePeer(repoRoot, teamName);
169
- return peer ? writeZobLivePeerCard(repoRoot, { ...peer, heartbeatAt: new Date().toISOString(), status: "online" }) : undefined;
499
+ if (!peer) return undefined;
500
+ const touched = writeZobLivePeerCard(repoRoot, { ...peer, heartbeatAt: new Date().toISOString(), status: "online" });
501
+ writeZobLiveTeamAgentLease(repoRoot, touched, { reason: "touch_current" });
502
+ return touched;
170
503
  }
171
504
 
172
505
  export function unregisterCurrentZobLivePeer(repoRoot: string, teamName = "zob-core"): ZobLivePeerCard | undefined {
@@ -174,17 +507,25 @@ export function unregisterCurrentZobLivePeer(repoRoot: string, teamName = "zob-c
174
507
  const team = loadTeamDefinition(repoRoot, teamName);
175
508
  if (!team.definition || !zobComsRegistryEnabled(policy)) return undefined;
176
509
  const peer = buildCurrentZobLivePeerCard(repoRoot, team.definition, policy);
510
+ releaseZobLiveTeamAgentLease(repoRoot, peer, { reason: "unregister_current" });
177
511
  return writeZobLivePeerCard(repoRoot, { ...peer, heartbeatAt: new Date().toISOString(), status: "offline" });
178
512
  }
179
513
 
180
514
  export function readZobLiveRegistrySnapshot(repoRoot: string, teamName?: string): ZobLiveRegistrySnapshot {
181
- const { dir, projectId, kind } = projectAgentsDir(repoRoot);
182
- return buildSnapshot(projectId, kind, readPeerCardsFromAgentsDir(dir, Date.now(), teamName), teamName);
515
+ const { dir, projectId, kind } = projectLeasesDir(repoRoot);
516
+ const nowMs = Date.now();
517
+ if (existsSync(dir)) return buildSnapshot(projectId, kind, readLeasesFromDir(dir, nowMs, teamName), teamName);
518
+ const agents = projectAgentsDir(repoRoot);
519
+ return buildSnapshot(agents.projectId, agents.kind, readPeerCardsFromAgentsDir(agents.dir, nowMs, teamName), teamName);
183
520
  }
184
521
 
185
522
  export function readZobLiveRegistryAllProjectsSnapshot(repoRoot: string, teamName?: string): ZobLiveRegistrySnapshot {
186
523
  const { projectId, kind } = projectAgentsDir(repoRoot);
187
524
  const nowMs = Date.now();
188
- const peers = allProjectAgentsDirs().flatMap((dir) => readPeerCardsFromAgentsDir(dir, nowMs, teamName));
525
+ const leaseDirs = allProjectLeasesDirs();
526
+ const hasLeaseDomain = leaseDirs.some((dir) => existsSync(dir));
527
+ const peers = hasLeaseDomain
528
+ ? leaseDirs.flatMap((dir) => readLeasesFromDir(dir, nowMs, teamName))
529
+ : allProjectAgentsDirs().flatMap((dir) => readPeerCardsFromAgentsDir(dir, nowMs, teamName));
189
530
  return buildSnapshot(projectId, kind, peers, teamName);
190
531
  }
@@ -114,6 +114,46 @@ export interface ZobLivePeerCard {
114
114
  bodyStored: false;
115
115
  }
116
116
 
117
+ export interface ZobLiveTeamAgentLease {
118
+ schema: "zob.live-team-agent-lease.v1";
119
+ projectId: string;
120
+ teamId: string;
121
+ agentId: string;
122
+ roleId: string;
123
+ roleType: ZobLiveRoleType;
124
+ leadId?: string;
125
+ agent: string;
126
+ sessionId: string;
127
+ sessionHash: string;
128
+ leaseOwnerId: string;
129
+ leaseOwnerHash: string;
130
+ transport: ZobLiveTransportKind;
131
+ endpoint: string;
132
+ endpointHash: string;
133
+ cwdHash: string;
134
+ pid?: number;
135
+ startedAt: string;
136
+ heartbeatAt: string;
137
+ leasedAt: string;
138
+ renewedAt: string;
139
+ expiresAt: string;
140
+ contextUsedPct: number;
141
+ queueDepth: number;
142
+ status: ZobLivePeerStatus;
143
+ zpeerRoomId?: string;
144
+ zpeerAlias?: string;
145
+ zpeerActiveRoomId?: string;
146
+ zpeerMemberships?: ZpeerRoomMembership[];
147
+ zpeerLocalOnly?: true;
148
+ staleAfterMs: number;
149
+ offlineAfterMs: number;
150
+ stableLease: true;
151
+ exclusiveBy: "teamId+agentId";
152
+ localOnly: true;
153
+ networkEnabled: false;
154
+ bodyStored: false;
155
+ }
156
+
117
157
  export interface ZobLiveRegistrySnapshot {
118
158
  schema: "zob.live-registry-snapshot.v1";
119
159
  projectId: string;