zob-harness 0.7.1 → 0.8.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 (32) hide show
  1. package/.pi/capabilities/zob-public-runtime-capabilities.json +1 -1
  2. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/registry.ts +363 -22
  3. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/types.ts +40 -0
  4. package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer.ts +6 -2
  5. package/.pi/extensions/zob-harness/src/domains/coms/zagents.ts +304 -0
  6. package/.pi/extensions/zob-harness/src/runtime/commands.ts +266 -8
  7. package/.pi/extensions/zob-harness/src/runtime/events.ts +134 -13
  8. package/.pi/extensions/zob-harness/src/runtime/state.ts +22 -0
  9. package/.pi/extensions/zob-harness/src/runtime/widget.ts +35 -2
  10. package/.pi/skills/zob-factory/SKILL.md +4 -2
  11. package/.pi/skills/zob-zagent-creator/SKILL.md +62 -9
  12. package/.pi/zagents/harness-architect.json +67 -0
  13. package/.pi/zagents/harness-chief.json +73 -0
  14. package/.pi/zagents/harness-coms-steward.json +64 -0
  15. package/.pi/zagents/harness-factory-engineer.json +61 -0
  16. package/.pi/zagents/harness-implementer.json +69 -0
  17. package/.pi/zagents/harness-oracle.json +70 -0
  18. package/.pi/zagents/prompts/harness-architect.md +52 -0
  19. package/.pi/zagents/prompts/harness-chief.md +66 -0
  20. package/.pi/zagents/prompts/harness-coms-steward.md +50 -0
  21. package/.pi/zagents/prompts/harness-factory-engineer.md +49 -0
  22. package/.pi/zagents/prompts/harness-implementer.md +56 -0
  23. package/.pi/zagents/prompts/harness-oracle.md +54 -0
  24. package/.pi/zteams/agent-factory-pacman-multiplayer.tmux.sh +27 -0
  25. package/.pi/zteams/zob-harness-devs.json +97 -0
  26. package/.pi/zteams/zob-harness-devs.modes.json +111 -0
  27. package/.pi/zteams/zob-harness-devs.tmux.sh +239 -0
  28. package/package.json +3 -1
  29. package/scripts/zagent-static-smoke.mjs +238 -1
  30. package/scripts/zpeer-local-e2e-smoke.mjs +9 -7
  31. package/scripts/zpeer-static-smoke.mjs +5 -0
  32. package/scripts/zteam-lifecycle-smoke.mjs +154 -0
@@ -1626,7 +1626,7 @@
1626
1626
  "AGENTS.md",
1627
1627
  ".pi/skills/zob-zagent-creator/SKILL.md"
1628
1628
  ],
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."
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; reset sends Pi /new to existing scoped tmux agent windows without close/start. Persisted command metadata must remain hash/body-free."
1630
1630
  },
1631
1631
  {
1632
1632
  "name": "rules_status",
@@ -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;
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { buildZobComsProjectId } from "./identity.js";
5
5
  import { buildZobLiveEnvelope } from "./envelope.js";
6
6
  import { sendZobLocalEnvelope } from "./local-transport.js";
7
- import { readZobLiveRegistryAllProjectsSnapshot, writeZobLivePeerCard, writeZobLivePeerCardToProjectId } from "./registry.js";
7
+ import { ownsZobLiveTeamAgentLease, readZobLiveRegistryAllProjectsSnapshot, writeZobLivePeerCard, writeZobLivePeerCardToProjectId, writeZobLiveTeamAgentLease } from "./registry.js";
8
8
  import type { ZobLivePeerCard, ZobLivePeerStatus, ZpeerRoomMembership, ZpeerRoomMembershipRole } from "./types.js";
9
9
  import { validateZobComsEdge } from "../../topology/coms.js";
10
10
  import { loadTeamDefinition, validateTeamDefinition } from "../../topology/teams.js";
@@ -233,7 +233,9 @@ export function ensureZpeerFields(repoRoot: string, peer: ZobLivePeerCard, roomI
233
233
  export function refreshZpeerSelf(repoRoot: string, peer: ZobLivePeerCard, roomId?: string, alias?: string, restoredMemberships?: ZpeerRoomMembership[]): ZobLivePeerCard {
234
234
  const ensured = ensureZpeerFields(repoRoot, peer, roomId, alias, restoredMemberships);
235
235
  if (!hasLocalSocketEndpointEvidence(ensured)) return ensured;
236
- return writeZobLivePeerCard(repoRoot, { ...ensured, heartbeatAt: new Date().toISOString(), status: "online" });
236
+ const refreshed = writeZobLivePeerCard(repoRoot, { ...ensured, heartbeatAt: new Date().toISOString(), status: "online" });
237
+ writeZobLiveTeamAgentLease(repoRoot, refreshed, { reason: "zpeer_refresh" });
238
+ return refreshed;
237
239
  }
238
240
 
239
241
  type ZobLiveRegistrySnapshotValue = ReturnType<typeof readZobLiveRegistryAllProjectsSnapshot>;
@@ -477,6 +479,8 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
477
479
  };
478
480
 
479
481
  if (!selfMembership) return finish("attempt", { status: "blocked", reason: `current peer is not a member of room '${roomId}'`, targetAlias: targetAlias ?? undefined, taskHash, bodyStored: false });
482
+ const leaseOwnership = ownsZobLiveTeamAgentLease(repoRoot, self);
483
+ if (!leaseOwnership.owned) return finish("attempt", { status: "blocked", reason: `current peer does not own stable team-agent lease (${leaseOwnership.reason})`, targetAlias: targetAlias ?? undefined, taskHash, bodyStored: false });
480
484
  if (selfMembership.role === "observer") return finish("attempt", { status: "blocked", reason: `current peer is observer-only in room '${roomId}'`, targetAlias: targetAlias ?? undefined, taskHash, bodyStored: false });
481
485
  if (!targetAlias) return finish("attempt", { status: "blocked", reason: "invalid target alias", bodyStored: false });
482
486
  if (!transientPrompt.trim()) return finish("attempt", { status: "blocked", reason: "empty peer prompt", targetAlias, bodyStored: false });