zob-harness 0.1.1 → 0.2.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.
@@ -1520,6 +1520,40 @@
1520
1520
  ],
1521
1521
  "noShipNotes": "Local-only multi-room-scoped peer UX with active-room compatibility; transient prompt/response over local_socket, persisted command/mission metadata is hash/body-free."
1522
1522
  },
1523
+ {
1524
+ "name": "zagent",
1525
+ "family": "zagent",
1526
+ "modes": [
1527
+ "all"
1528
+ ],
1529
+ "skillRefs": [
1530
+ ".pi/skills/zob-zagent-creator/SKILL.md",
1531
+ ".pi/skills/zob-coms-v2-live/SKILL.md",
1532
+ ".pi/skills/zob-harness/SKILL.md"
1533
+ ],
1534
+ "docRefs": [
1535
+ "AGENTS.md",
1536
+ ".pi/skills/zob-zagent-creator/SKILL.md"
1537
+ ],
1538
+ "noShipNotes": "Project-local ZAgent definition UX for .pi/zagents/ only. ZAgents 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."
1539
+ },
1540
+ {
1541
+ "name": "zteam",
1542
+ "family": "zagent",
1543
+ "modes": [
1544
+ "all"
1545
+ ],
1546
+ "skillRefs": [
1547
+ ".pi/skills/zob-zagent-creator/SKILL.md",
1548
+ ".pi/skills/zob-coms-v2-live/SKILL.md",
1549
+ ".pi/skills/zob-harness/SKILL.md"
1550
+ ],
1551
+ "docRefs": [
1552
+ "AGENTS.md",
1553
+ ".pi/skills/zob-zagent-creator/SKILL.md"
1554
+ ],
1555
+ "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."
1556
+ },
1523
1557
  {
1524
1558
  "name": "rules_status",
1525
1559
  "family": "safety",
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { join } from "node:path";
3
+ import { dirname, join } from "node:path";
4
4
 
5
5
  import { loadTeamDefinition, validateTeamDefinition } from "../topology/teams.js";
6
6
  import { isRecord } from "../utils/records.js";
@@ -34,6 +34,50 @@ function peerPath(repoRoot: string, peer: Pick<ZobLivePeerCard, "roleId" | "sess
34
34
  return join(dir, `${safeFileStem(`${peer.roleId}-${peer.sessionHash.slice(0, 12)}`)}.json`);
35
35
  }
36
36
 
37
+ function peerPathForProjectId(projectId: string, peer: Pick<ZobLivePeerCard, "roleId" | "sessionHash">): string {
38
+ return join(registryRoot().path, "projects", safeFileStem(projectId), "agents", `${safeFileStem(`${peer.roleId}-${peer.sessionHash.slice(0, 12)}`)}.json`);
39
+ }
40
+
41
+ function readPeerCardsFromAgentsDir(dir: string, nowMs: number, teamName?: string): ZobLivePeerCard[] {
42
+ if (!existsSync(dir)) return [];
43
+ return readdirSync(dir)
44
+ .filter((entry) => entry.endsWith(".json"))
45
+ .map((entry) => {
46
+ try {
47
+ return parsePeerCard(JSON.parse(readFileSync(join(dir, entry), "utf8")) as unknown);
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ })
52
+ .filter((peer): peer is ZobLivePeerCard => Boolean(peer))
53
+ .filter((peer) => !teamName || peer.team === teamName)
54
+ .map((peer) => ({ ...peer, status: derivePeerStatus(peer, nowMs) }));
55
+ }
56
+
57
+ function allProjectAgentsDirs(): string[] {
58
+ const root = registryRoot();
59
+ const projectsDir = join(root.path, "projects");
60
+ if (!existsSync(projectsDir)) return [];
61
+ return readdirSync(projectsDir, { withFileTypes: true })
62
+ .filter((entry) => entry.isDirectory())
63
+ .map((entry) => join(projectsDir, entry.name, "agents"));
64
+ }
65
+
66
+ function buildSnapshot(projectId: string, kind: "user_runtime" | "env_override", peers: ZobLivePeerCard[], teamName?: string): ZobLiveRegistrySnapshot {
67
+ const counts: Record<ZobLivePeerStatus, number> = { online: 0, stale: 0, offline: 0 };
68
+ for (const peer of peers) counts[peer.status] += 1;
69
+ return {
70
+ schema: "zob.live-registry-snapshot.v1",
71
+ projectId,
72
+ registry: kind,
73
+ team: teamName,
74
+ generatedAt: new Date().toISOString(),
75
+ peers,
76
+ counts,
77
+ bodyStored: false,
78
+ };
79
+ }
80
+
37
81
  function parsePeerCard(value: unknown): ZobLivePeerCard | undefined {
38
82
  if (!isRecord(value) || value.schema !== "zob.live-peer-card.v1") return undefined;
39
83
  if (hasForbiddenPersistedKey(value) || value.bodyStored !== false) return undefined;
@@ -59,6 +103,15 @@ export function writeZobLivePeerCard(repoRoot: string, peer: ZobLivePeerCard): Z
59
103
  return peer;
60
104
  }
61
105
 
106
+ export function writeZobLivePeerCardToProjectId(peer: ZobLivePeerCard): ZobLivePeerCard {
107
+ if (hasForbiddenPersistedKey(peer)) throw new Error("Refusing to persist ZOB live peer card with forbidden body-like keys");
108
+ if (peer.bodyStored !== false) throw new Error("ZOB live peer card bodyStored must be false");
109
+ const filePath = peerPathForProjectId(peer.projectId, peer);
110
+ mkdirSync(dirname(filePath), { recursive: true });
111
+ writeFileSync(filePath, `${JSON.stringify(peer, null, 2)}\n`, "utf8");
112
+ return peer;
113
+ }
114
+
62
115
  export function registerCurrentZobLivePeer(repoRoot: string, teamName = "zob-core"): ZobLivePeerCard | undefined {
63
116
  const policy = readZobComsV2Policy(repoRoot);
64
117
  if (!zobComsRegistryEnabled(policy)) return undefined;
@@ -83,31 +136,12 @@ export function unregisterCurrentZobLivePeer(repoRoot: string, teamName = "zob-c
83
136
 
84
137
  export function readZobLiveRegistrySnapshot(repoRoot: string, teamName?: string): ZobLiveRegistrySnapshot {
85
138
  const { dir, projectId, kind } = projectAgentsDir(repoRoot);
139
+ return buildSnapshot(projectId, kind, readPeerCardsFromAgentsDir(dir, Date.now(), teamName), teamName);
140
+ }
141
+
142
+ export function readZobLiveRegistryAllProjectsSnapshot(repoRoot: string, teamName?: string): ZobLiveRegistrySnapshot {
143
+ const { projectId, kind } = projectAgentsDir(repoRoot);
86
144
  const nowMs = Date.now();
87
- const peers = existsSync(dir)
88
- ? readdirSync(dir)
89
- .filter((entry) => entry.endsWith(".json"))
90
- .map((entry) => {
91
- try {
92
- return parsePeerCard(JSON.parse(readFileSync(join(dir, entry), "utf8")) as unknown);
93
- } catch {
94
- return undefined;
95
- }
96
- })
97
- .filter((peer): peer is ZobLivePeerCard => Boolean(peer))
98
- .filter((peer) => !teamName || peer.team === teamName)
99
- .map((peer) => ({ ...peer, status: derivePeerStatus(peer, nowMs) }))
100
- : [];
101
- const counts: Record<ZobLivePeerStatus, number> = { online: 0, stale: 0, offline: 0 };
102
- for (const peer of peers) counts[peer.status] += 1;
103
- return {
104
- schema: "zob.live-registry-snapshot.v1",
105
- projectId,
106
- registry: kind,
107
- team: teamName,
108
- generatedAt: new Date().toISOString(),
109
- peers,
110
- counts,
111
- bodyStored: false,
112
- };
145
+ const peers = allProjectAgentsDirs().flatMap((dir) => readPeerCardsFromAgentsDir(dir, nowMs, teamName));
146
+ return buildSnapshot(projectId, kind, peers, teamName);
113
147
  }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
@@ -9,6 +9,8 @@ import { isRecord } from "../utils/records.js";
9
9
  import { safeFileStem } from "../utils/paths.js";
10
10
 
11
11
  const PROFILE_SCHEMA = "zob.zpeer-local-profile.v1";
12
+ const NEW_CARRYOVER_SCHEMA = "zob.zpeer-new-carryover.v1";
13
+ const DEFAULT_NEW_CARRYOVER_TTL_MS = 30 * 60 * 1000;
12
14
  const FORBIDDEN_PROFILE_KEYS = new Set(["body", "task", "prompt", "output", "content", "message", "response", "rationale", "text", "diff", "patch"]);
13
15
 
14
16
  export interface ZpeerLocalProfile {
@@ -26,6 +28,31 @@ export interface ZpeerLocalProfile {
26
28
  bodyStored: false;
27
29
  }
28
30
 
31
+ export interface ZpeerNewCarryoverProfile {
32
+ schema: typeof NEW_CARRYOVER_SCHEMA;
33
+ projectId: string;
34
+ alias?: string;
35
+ roomId?: string;
36
+ activeRoomId?: string;
37
+ memberships?: ZpeerRoomMembership[];
38
+ zagentId?: string;
39
+ createdAt: string;
40
+ updatedAt: string;
41
+ expiresAt: string;
42
+ localOnly: true;
43
+ networkEnabled: false;
44
+ bodyStored: false;
45
+ }
46
+
47
+ export interface ZpeerNewCarryoverInput {
48
+ alias?: string;
49
+ roomId?: string;
50
+ activeRoomId?: string;
51
+ memberships?: ZpeerRoomMembership[];
52
+ zagentId?: string;
53
+ ttlMs?: number;
54
+ }
55
+
29
56
  function registryRoot(): string {
30
57
  const override = process.env.ZOB_COMS_REGISTRY_ROOT;
31
58
  if (override && override.trim().length > 0) return override;
@@ -59,11 +86,17 @@ function derivedSessionSeed(repoRoot: string): string | undefined {
59
86
  export function resolveZpeerProfileId(repoRoot: string): string {
60
87
  const explicit = firstEnvValue(["ZOB_ZPEER_PROFILE_ID", "ZPEER_PROFILE"]);
61
88
  if (explicit) return safeFileStem(`explicit-${explicit.value}`).slice(0, 80) || `explicit-${sha256(explicit.value).slice(0, 16)}`;
62
- const comsSession = firstEnvValue(["ZOB_COMS_SESSION_ID"]);
63
- if (comsSession) return safeFileStem(`coms-${comsSession.value}`).slice(0, 80) || `coms-${sha256(comsSession.value).slice(0, 16)}`;
64
89
  const terminalSeed = derivedSessionSeed(repoRoot);
65
90
  if (terminalSeed) return `terminal-${sha256(terminalSeed).slice(0, 20)}`;
66
- return `process-${sha256(`${repoRoot}:${process.pid}:${Date.now()}`).slice(0, 20)}`;
91
+ const comsSession = firstEnvValue(["ZOB_COMS_SESSION_ID"]);
92
+ if (comsSession) return safeFileStem(`coms-${comsSession.value}`).slice(0, 80) || `coms-${sha256(comsSession.value).slice(0, 16)}`;
93
+ const role = firstEnvValue(["ZOB_COMS_ROLE_ID"]);
94
+ const roleId = role?.value ?? "zob-orchestrator";
95
+ return `role-${safeFileStem(roleId).slice(0, 40) || sha256(roleId).slice(0, 16)}`;
96
+ }
97
+
98
+ export function zpeerProfileIdIsSharedFallback(profileId = resolveZpeerProfileId("")): boolean {
99
+ return profileId.startsWith("role-");
67
100
  }
68
101
 
69
102
  function zpeerProfileDir(repoRoot: string): { dir: string; projectId: string } {
@@ -76,12 +109,32 @@ export function zpeerProfilePath(repoRoot: string, profileId = resolveZpeerProfi
76
109
  return join(dir, `${safeFileStem(sha256(profileId).slice(0, 32))}.json`);
77
110
  }
78
111
 
112
+ function zpeerNewCarryoverProfilePath(repoRoot: string): string {
113
+ const { dir } = zpeerProfileDir(repoRoot);
114
+ return join(dir, "new-carryover.json");
115
+ }
116
+
79
117
  function hasForbiddenProfileKey(value: unknown): boolean {
80
118
  if (!value || typeof value !== "object") return false;
81
119
  if (Array.isArray(value)) return value.some(hasForbiddenProfileKey);
82
120
  return Object.entries(value).some(([key, child]) => FORBIDDEN_PROFILE_KEYS.has(key) || hasForbiddenProfileKey(child));
83
121
  }
84
122
 
123
+ function validZpeerMemberships(value: unknown): value is ZpeerRoomMembership[] {
124
+ if (!Array.isArray(value)) return false;
125
+ for (const membership of value) {
126
+ if (!isRecord(membership)) return false;
127
+ if (hasForbiddenProfileKey(membership)) return false;
128
+ if (typeof membership.roomId !== "string" || typeof membership.alias !== "string" || typeof membership.role !== "string") return false;
129
+ if (typeof membership.joinedAt !== "string" || membership.localOnly !== true || membership.networkEnabled !== false || membership.bodyStored !== false) return false;
130
+ }
131
+ return true;
132
+ }
133
+
134
+ function validIsoDate(value: unknown): value is string {
135
+ return typeof value === "string" && Number.isFinite(Date.parse(value));
136
+ }
137
+
85
138
  function parseZpeerLocalProfile(value: unknown, repoRoot: string): ZpeerLocalProfile | undefined {
86
139
  if (!isRecord(value) || value.schema !== PROFILE_SCHEMA) return undefined;
87
140
  if (hasForbiddenProfileKey(value) || value.bodyStored !== false || value.localOnly !== true || value.networkEnabled !== false) return undefined;
@@ -89,18 +142,25 @@ function parseZpeerLocalProfile(value: unknown, repoRoot: string): ZpeerLocalPro
89
142
  if (value.alias !== undefined && typeof value.alias !== "string") return undefined;
90
143
  if (value.roomId !== undefined && typeof value.roomId !== "string") return undefined;
91
144
  if (value.activeRoomId !== undefined && typeof value.activeRoomId !== "string") return undefined;
92
- if (value.memberships !== undefined) {
93
- if (!Array.isArray(value.memberships)) return undefined;
94
- for (const membership of value.memberships) {
95
- if (!isRecord(membership)) return undefined;
96
- if (typeof membership.roomId !== "string" || typeof membership.alias !== "string" || typeof membership.role !== "string") return undefined;
97
- if (typeof membership.joinedAt !== "string" || membership.localOnly !== true || membership.networkEnabled !== false || membership.bodyStored !== false) return undefined;
98
- }
99
- }
145
+ if (value.memberships !== undefined && !validZpeerMemberships(value.memberships)) return undefined;
100
146
  if (typeof value.createdAt !== "string" || typeof value.updatedAt !== "string") return undefined;
101
147
  return value as unknown as ZpeerLocalProfile;
102
148
  }
103
149
 
150
+ function parseZpeerNewCarryoverProfile(value: unknown, repoRoot: string, nowMs = Date.now()): ZpeerNewCarryoverProfile | undefined {
151
+ if (!isRecord(value) || value.schema !== NEW_CARRYOVER_SCHEMA) return undefined;
152
+ if (hasForbiddenProfileKey(value) || value.bodyStored !== false || value.localOnly !== true || value.networkEnabled !== false) return undefined;
153
+ if (value.projectId !== buildZobComsProjectId(repoRoot)) return undefined;
154
+ if (value.alias !== undefined && typeof value.alias !== "string") return undefined;
155
+ if (value.roomId !== undefined && typeof value.roomId !== "string") return undefined;
156
+ if (value.activeRoomId !== undefined && typeof value.activeRoomId !== "string") return undefined;
157
+ if (value.zagentId !== undefined && typeof value.zagentId !== "string") return undefined;
158
+ if (value.memberships !== undefined && !validZpeerMemberships(value.memberships)) return undefined;
159
+ if (!validIsoDate(value.createdAt) || !validIsoDate(value.updatedAt) || !validIsoDate(value.expiresAt)) return undefined;
160
+ if (Date.parse(value.expiresAt) <= nowMs) return undefined;
161
+ return value as unknown as ZpeerNewCarryoverProfile;
162
+ }
163
+
104
164
  export function readZpeerLocalProfile(repoRoot: string, profileId = resolveZpeerProfileId(repoRoot)): ZpeerLocalProfile | undefined {
105
165
  const path = zpeerProfilePath(repoRoot, profileId);
106
166
  if (!existsSync(path)) return undefined;
@@ -115,14 +175,15 @@ export function writeZpeerLocalProfile(repoRoot: string, input: { alias?: string
115
175
  const { dir, projectId } = zpeerProfileDir(repoRoot);
116
176
  const existing = readZpeerLocalProfile(repoRoot, profileId);
117
177
  const now = new Date().toISOString();
178
+ const sharedFallback = zpeerProfileIdIsSharedFallback(profileId);
118
179
  const profile: ZpeerLocalProfile = {
119
180
  schema: PROFILE_SCHEMA,
120
181
  profileId,
121
182
  projectId,
122
- alias: input.alias ?? existing?.alias,
183
+ alias: sharedFallback ? undefined : input.alias ?? existing?.alias,
123
184
  roomId: input.roomId ?? existing?.roomId,
124
185
  activeRoomId: input.activeRoomId ?? input.roomId ?? existing?.activeRoomId,
125
- memberships: input.memberships ?? existing?.memberships,
186
+ memberships: sharedFallback ? undefined : input.memberships ?? existing?.memberships,
126
187
  createdAt: existing?.createdAt ?? now,
127
188
  updatedAt: now,
128
189
  localOnly: true,
@@ -135,6 +196,68 @@ export function writeZpeerLocalProfile(repoRoot: string, input: { alias?: string
135
196
  return profile;
136
197
  }
137
198
 
138
- export function writeZpeerLocalProfileFromPeer(repoRoot: string, peer: Pick<ZobLivePeerCard, "zpeerAlias" | "zpeerRoomId" | "zpeerActiveRoomId" | "zpeerMemberships">, profileId = resolveZpeerProfileId(repoRoot)): ZpeerLocalProfile {
139
- return writeZpeerLocalProfile(repoRoot, { alias: peer.zpeerAlias, roomId: peer.zpeerRoomId, activeRoomId: peer.zpeerActiveRoomId, memberships: peer.zpeerMemberships }, profileId);
199
+ export function readZpeerNewCarryoverProfile(repoRoot: string): ZpeerNewCarryoverProfile | undefined {
200
+ const path = zpeerNewCarryoverProfilePath(repoRoot);
201
+ if (!existsSync(path)) return undefined;
202
+ try {
203
+ const profile = parseZpeerNewCarryoverProfile(JSON.parse(readFileSync(path, "utf8")) as unknown, repoRoot);
204
+ if (!profile) clearZpeerNewCarryoverProfile(repoRoot);
205
+ return profile;
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ }
210
+
211
+ export function writeZpeerNewCarryoverProfile(repoRoot: string, input: ZpeerNewCarryoverInput): ZpeerNewCarryoverProfile {
212
+ if (hasForbiddenProfileKey(input)) throw new Error("Refusing to persist ZPeer /new carryover profile with forbidden body-like keys");
213
+ if (input.memberships !== undefined && !validZpeerMemberships(input.memberships)) throw new Error("Refusing to persist ZPeer /new carryover profile with invalid memberships");
214
+ const { dir, projectId } = zpeerProfileDir(repoRoot);
215
+ const existing = readZpeerNewCarryoverProfile(repoRoot);
216
+ const nowMs = Date.now();
217
+ const now = new Date(nowMs).toISOString();
218
+ const ttlMs = input.ttlMs !== undefined && Number.isFinite(input.ttlMs) && input.ttlMs > 0 ? input.ttlMs : DEFAULT_NEW_CARRYOVER_TTL_MS;
219
+ const profile: ZpeerNewCarryoverProfile = {
220
+ schema: NEW_CARRYOVER_SCHEMA,
221
+ projectId,
222
+ alias: input.alias ?? existing?.alias,
223
+ roomId: input.roomId ?? existing?.roomId,
224
+ activeRoomId: input.activeRoomId ?? input.roomId ?? existing?.activeRoomId,
225
+ memberships: input.memberships ?? existing?.memberships,
226
+ zagentId: input.zagentId ?? existing?.zagentId,
227
+ createdAt: existing?.createdAt ?? now,
228
+ updatedAt: now,
229
+ expiresAt: new Date(nowMs + ttlMs).toISOString(),
230
+ localOnly: true,
231
+ networkEnabled: false,
232
+ bodyStored: false,
233
+ };
234
+ if (hasForbiddenProfileKey(profile)) throw new Error("Refusing to persist ZPeer /new carryover profile with forbidden body-like keys");
235
+ mkdirSync(dir, { recursive: true });
236
+ writeFileSync(zpeerNewCarryoverProfilePath(repoRoot), `${JSON.stringify(profile, null, 2)}\n`, "utf8");
237
+ return profile;
238
+ }
239
+
240
+ export function clearZpeerNewCarryoverProfile(repoRoot: string): void {
241
+ const path = zpeerNewCarryoverProfilePath(repoRoot);
242
+ if (existsSync(path)) unlinkSync(path);
243
+ }
244
+
245
+ function generatedAliasForPeer(peer: Pick<ZobLivePeerCard, "roleId" | "sessionHash">): string | undefined {
246
+ const roleAlias = `${peer.roleId}-${peer.sessionHash.slice(0, 6)}`;
247
+ if (/^[a-zA-Z][a-zA-Z0-9_-]{1,31}$/.test(roleAlias)) return roleAlias;
248
+ return `peer-${peer.sessionHash.slice(0, 8)}`;
249
+ }
250
+
251
+ function activeMembershipAlias(peer: Pick<ZobLivePeerCard, "zpeerRoomId" | "zpeerActiveRoomId" | "zpeerMemberships">): string | undefined {
252
+ const activeRoomId = peer.zpeerActiveRoomId ?? peer.zpeerRoomId;
253
+ return peer.zpeerMemberships?.find((membership) => membership.roomId === activeRoomId)?.alias;
254
+ }
255
+
256
+ export function writeZpeerLocalProfileFromPeer(repoRoot: string, peer: Pick<ZobLivePeerCard, "roleId" | "sessionHash" | "zpeerAlias" | "zpeerRoomId" | "zpeerActiveRoomId" | "zpeerMemberships">, profileId = resolveZpeerProfileId(repoRoot)): ZpeerLocalProfile {
257
+ const existing = readZpeerLocalProfile(repoRoot, profileId);
258
+ const generatedAlias = generatedAliasForPeer(peer);
259
+ const membershipAlias = activeMembershipAlias(peer);
260
+ const candidateAlias = membershipAlias ?? peer.zpeerAlias;
261
+ const alias = candidateAlias && candidateAlias !== generatedAlias ? candidateAlias : existing?.alias ?? candidateAlias;
262
+ return writeZpeerLocalProfile(repoRoot, { alias, roomId: peer.zpeerRoomId, activeRoomId: peer.zpeerActiveRoomId, memberships: peer.zpeerMemberships }, profileId);
140
263
  }
@@ -4,10 +4,11 @@ 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 { readZobLiveRegistrySnapshot, writeZobLivePeerCard } from "./registry.js";
7
+ import { readZobLiveRegistryAllProjectsSnapshot, writeZobLivePeerCard, writeZobLivePeerCardToProjectId } 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";
11
+ import { loadZteamManifest, zteamAllowsZpeerContact } from "../zagents.js";
11
12
  import { sha256 } from "../utils/hashing.js";
12
13
 
13
14
  const DEFAULT_ROOM_ID = "default";
@@ -180,6 +181,34 @@ function zpeerReachableStatus(peer: ZobLivePeerCard): ZobLivePeerStatus {
180
181
  return "offline";
181
182
  }
182
183
 
184
+ async function peerRespondsToAliasPing(peer: ZobLivePeerCard): Promise<boolean> {
185
+ if (zpeerReachableStatus(peer) !== "online") return false;
186
+ try {
187
+ const response = await sendZobLocalEnvelope(peer.endpoint, {
188
+ schema: "zob.live-envelope.v1",
189
+ type: "ping",
190
+ msgId: `zpeer-alias-ping:${Date.now()}`,
191
+ hops: 0,
192
+ timestamp: new Date().toISOString(),
193
+ bodyStored: false,
194
+ }, { timeoutMs: 1_000 });
195
+ return response.type === "pong" || response.type === "ack";
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ async function activeAliasCollision(repoRoot: string, self: ZobLivePeerCard, roomId: string, alias: string): Promise<ZpeerRoomPeer | undefined> {
202
+ for (const entry of peersInRoom(repoRoot, roomId)) {
203
+ if (entry.peer.sessionHash === self.sessionHash || entry.membership.alias !== alias) continue;
204
+ if (await peerRespondsToAliasPing(entry.peer)) return entry;
205
+ if (zpeerReachableStatus(entry.peer) === "online") {
206
+ try { writeZobLivePeerCard(repoRoot, { ...entry.peer, heartbeatAt: new Date().toISOString(), status: "offline" }); } catch { /* best-effort stale alias release */ }
207
+ }
208
+ }
209
+ return undefined;
210
+ }
211
+
183
212
  export function ensureZpeerFields(repoRoot: string, peer: ZobLivePeerCard, roomId?: string, alias?: string, restoredMemberships?: ZpeerRoomMembership[]): ZobLivePeerCard {
184
213
  const hasRestoredMemberships = Boolean(restoredMemberships && restoredMemberships.length > 0);
185
214
  const baseMemberships = hasRestoredMemberships
@@ -204,25 +233,27 @@ export function refreshZpeerSelf(repoRoot: string, peer: ZobLivePeerCard, roomId
204
233
  return writeZobLivePeerCard(repoRoot, { ...ensured, heartbeatAt: new Date().toISOString(), status: "online" });
205
234
  }
206
235
 
207
- function peersInRoom(repoRoot: string, roomId: string): ZpeerRoomPeer[] {
208
- const projectId = buildZobComsProjectId(repoRoot);
209
- return readZobLiveRegistrySnapshot(repoRoot).peers
210
- .filter((peer) => peer.projectId === projectId)
236
+ type ZobLiveRegistrySnapshotValue = ReturnType<typeof readZobLiveRegistryAllProjectsSnapshot>;
237
+
238
+ function peersInRoomFromSnapshot(snapshot: ZobLiveRegistrySnapshotValue, roomId: string): ZpeerRoomPeer[] {
239
+ return snapshot.peers
211
240
  .flatMap((peer) => zpeerMembershipsForPeer(peer)
212
241
  .filter((membership) => membership.roomId === roomId)
213
242
  .map((membership) => ({ peer, membership })));
214
243
  }
215
244
 
216
- export function buildZpeerRoomSummary(repoRoot: string, self?: ZobLivePeerCard, requestedRoomId?: string): ZpeerRoomSummary {
217
- const roomId = safeZpeerRoomId(requestedRoomId) ?? (self ? activeZpeerRoomId(self) : DEFAULT_ROOM_ID);
218
- const peers = peersInRoom(repoRoot, roomId);
245
+ function peersInRoom(repoRoot: string, roomId: string): ZpeerRoomPeer[] {
246
+ return peersInRoomFromSnapshot(readZobLiveRegistryAllProjectsSnapshot(repoRoot), roomId);
247
+ }
248
+
249
+ function buildZpeerRoomSummaryFromPeers(projectId: string, self: ZobLivePeerCard | undefined, roomId: string, peers: ZpeerRoomPeer[]): ZpeerRoomSummary {
219
250
  const counts: Record<ZobLivePeerStatus, number> = { online: 0, stale: 0, offline: 0 };
220
251
  const aliases = peers.map((entry) => entry.membership.alias).sort();
221
252
  for (const entry of peers) counts[zpeerReachableStatus(entry.peer)] += 1;
222
253
  const duplicateAliases = aliases.filter((alias, index) => aliases.indexOf(alias) !== index).filter((alias, index, all) => all.indexOf(alias) === index);
223
254
  return {
224
255
  schema: "zob.zpeer-room-summary.v1",
225
- projectId: buildZobComsProjectId(repoRoot),
256
+ projectId,
226
257
  roomId,
227
258
  selfAlias: self ? peerAliasInRoom(self, roomId) : undefined,
228
259
  peerCount: peers.length,
@@ -238,10 +269,17 @@ export function buildZpeerRoomSummary(repoRoot: string, self?: ZobLivePeerCard,
238
269
  };
239
270
  }
240
271
 
272
+ export function buildZpeerRoomSummary(repoRoot: string, self?: ZobLivePeerCard, requestedRoomId?: string): ZpeerRoomSummary {
273
+ const snapshot = readZobLiveRegistryAllProjectsSnapshot(repoRoot);
274
+ const roomId = safeZpeerRoomId(requestedRoomId) ?? (self ? activeZpeerRoomId(self) : DEFAULT_ROOM_ID);
275
+ return buildZpeerRoomSummaryFromPeers(snapshot.projectId, self, roomId, peersInRoomFromSnapshot(snapshot, roomId));
276
+ }
277
+
241
278
  export function buildZpeerPeerRoomSummaries(repoRoot: string, self: ZobLivePeerCard): ZpeerPeerRoomSummary[] {
279
+ const snapshot = readZobLiveRegistryAllProjectsSnapshot(repoRoot);
242
280
  const activeRoomId = activeZpeerRoomId(self);
243
281
  return zpeerMembershipsForPeer(self).map((membership) => ({
244
- ...buildZpeerRoomSummary(repoRoot, self, membership.roomId),
282
+ ...buildZpeerRoomSummaryFromPeers(snapshot.projectId, self, membership.roomId, peersInRoomFromSnapshot(snapshot, membership.roomId)),
245
283
  active: membership.roomId === activeRoomId,
246
284
  })).sort((left, right) => {
247
285
  if (left.active !== right.active) return left.active ? -1 : 1;
@@ -249,26 +287,26 @@ export function buildZpeerPeerRoomSummaries(repoRoot: string, self: ZobLivePeerC
249
287
  });
250
288
  }
251
289
 
252
- export function changeZpeerAlias(repoRoot: string, self: ZobLivePeerCard, requestedAlias: string, requestedRoomId?: string): { ok: true; peer: ZobLivePeerCard } | { ok: false; reason: string } {
290
+ export async function changeZpeerAlias(repoRoot: string, self: ZobLivePeerCard, requestedAlias: string, requestedRoomId?: string): Promise<{ ok: true; peer: ZobLivePeerCard } | { ok: false; reason: string }> {
253
291
  const alias = safeZpeerAlias(requestedAlias);
254
292
  if (!alias) return { ok: false, reason: "alias must match [a-zA-Z][a-zA-Z0-9_-]{1,31}" };
255
293
  const roomId = safeZpeerRoomId(requestedRoomId) ?? activeZpeerRoomId(self);
256
294
  const memberships = zpeerMembershipsForPeer(self);
257
295
  const current = memberships.find((membership) => membership.roomId === roomId);
258
296
  if (!current) return { ok: false, reason: `current peer is not a member of room '${roomId}'` };
259
- const collision = peersInRoom(repoRoot, roomId).find((entry) => entry.peer.sessionHash !== self.sessionHash && entry.membership.alias === alias && entry.peer.status !== "offline");
260
- if (collision) return { ok: false, reason: `alias '${alias}' is already used in room '${roomId}'` };
297
+ const collision = await activeAliasCollision(repoRoot, self, roomId, alias);
298
+ if (collision) return { ok: false, reason: `alias '${alias}' is already used by a live peer in room '${roomId}'` };
261
299
  return { ok: true, peer: refreshZpeerSelf(repoRoot, withZpeerMembershipState(repoRoot, self, upsertMembership(memberships, { ...current, alias }), activeZpeerRoomId(self))) };
262
300
  }
263
301
 
264
- export function joinZpeerRoom(repoRoot: string, self: ZobLivePeerCard, requestedRoom: string, requestedAlias?: string, role: ZpeerRoomMembershipRole = "member"): { ok: true; peer: ZobLivePeerCard } | { ok: false; reason: string } {
302
+ export async function joinZpeerRoom(repoRoot: string, self: ZobLivePeerCard, requestedRoom: string, requestedAlias?: string, role: ZpeerRoomMembershipRole = "member"): Promise<{ ok: true; peer: ZobLivePeerCard } | { ok: false; reason: string }> {
265
303
  const roomId = safeZpeerRoomId(requestedRoom);
266
304
  if (!roomId) return { ok: false, reason: "room must match [a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}" };
267
305
  const memberships = zpeerMembershipsForPeer(self);
268
306
  const existing = memberships.find((membership) => membership.roomId === roomId);
269
307
  const alias = safeZpeerAlias(requestedAlias ?? existing?.alias ?? self.zpeerAlias) ?? generatedZpeerAlias(self);
270
- const collision = peersInRoom(repoRoot, roomId).find((entry) => entry.peer.sessionHash !== self.sessionHash && entry.membership.alias === alias && entry.peer.status !== "offline");
271
- if (collision) return { ok: false, reason: `alias '${alias}' already exists in target room '${roomId}'; rename first or use 'as <alias>'` };
308
+ const collision = await activeAliasCollision(repoRoot, self, roomId, alias);
309
+ if (collision) return { ok: false, reason: `alias '${alias}' already exists on a live peer in target room '${roomId}'; rename first or use 'as <alias>'` };
272
310
  const next = buildMembership({ roomId, alias, role, joinedAt: existing?.joinedAt ?? new Date().toISOString() });
273
311
  return { ok: true, peer: refreshZpeerSelf(repoRoot, withZpeerMembershipState(repoRoot, self, upsertMembership(memberships, next), activeZpeerRoomId(self))) };
274
312
  }
@@ -292,12 +330,37 @@ export function useZpeerRoom(repoRoot: string, self: ZobLivePeerCard, requestedR
292
330
  return { ok: true, peer: refreshZpeerSelf(repoRoot, withZpeerMembershipState(repoRoot, self, memberships, roomId)) };
293
331
  }
294
332
 
295
- export function changeZpeerRoom(repoRoot: string, self: ZobLivePeerCard, requestedRoom: string): { ok: true; peer: ZobLivePeerCard } | { ok: false; reason: string } {
333
+ export function clearZpeerRoom(repoRoot: string, self: ZobLivePeerCard, requestedRoom: string): { ok: true; roomId: string; cleared: number; preservedSelf: true } | { ok: false; reason: string } {
334
+ const roomId = safeZpeerRoomId(requestedRoom);
335
+ if (!roomId) return { ok: false, reason: "room must match [a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}" };
336
+ let cleared = 0;
337
+ for (const entry of peersInRoom(repoRoot, roomId)) {
338
+ if (entry.peer.sessionHash === self.sessionHash) continue;
339
+ const remaining = zpeerMembershipsForPeer(entry.peer).filter((membership) => membership.roomId !== roomId);
340
+ const fallbackRoomId = `cleared-${entry.peer.sessionHash.slice(0, 8)}`;
341
+ const fallbackAlias = safeZpeerAlias(entry.peer.zpeerAlias) ?? generatedZpeerAlias(entry.peer);
342
+ const nextMemberships = remaining.length > 0 ? remaining : [buildMembership({ roomId: fallbackRoomId, alias: fallbackAlias, role: "observer", joinedAt: new Date().toISOString() })];
343
+ const active = nextMemberships[0];
344
+ writeZobLivePeerCardToProjectId({
345
+ ...entry.peer,
346
+ heartbeatAt: new Date().toISOString(),
347
+ status: "offline",
348
+ zpeerRoomId: active.roomId,
349
+ zpeerActiveRoomId: active.roomId,
350
+ zpeerAlias: active.alias,
351
+ zpeerMemberships: nextMemberships,
352
+ });
353
+ cleared += 1;
354
+ }
355
+ return { ok: true, roomId, cleared, preservedSelf: true };
356
+ }
357
+
358
+ export async function changeZpeerRoom(repoRoot: string, self: ZobLivePeerCard, requestedRoom: string): Promise<{ ok: true; peer: ZobLivePeerCard } | { ok: false; reason: string }> {
296
359
  const roomId = safeZpeerRoomId(requestedRoom);
297
360
  if (!roomId) return { ok: false, reason: "room must match [a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}" };
298
361
  const memberships = zpeerMembershipsForPeer(self);
299
362
  if (!memberships.some((membership) => membership.roomId === roomId)) {
300
- const joined = joinZpeerRoom(repoRoot, self, roomId, self.zpeerAlias ?? generatedZpeerAlias(self));
363
+ const joined = await joinZpeerRoom(repoRoot, self, roomId, self.zpeerAlias ?? generatedZpeerAlias(self));
301
364
  if (!joined.ok) return joined;
302
365
  return useZpeerRoom(repoRoot, joined.peer, roomId);
303
366
  }
@@ -345,8 +408,12 @@ function appendZpeerPeerRecords(repoRoot: string, record: {
345
408
  appendHashOnlyZpeerJsonl(repoRoot, "peer-status.jsonl", { ...base, schema: "zob.zpeer-peer-status.v1" });
346
409
  }
347
410
 
348
- function validateZpeerTopology(repoRoot: string, self: ZobLivePeerCard, target: ZobLivePeerCard): string | undefined {
411
+ function validateZpeerTopology(repoRoot: string, self: ZobLivePeerCard, target: ZobLivePeerCard, roomId: string, selfAlias?: string, targetAlias?: string): string | undefined {
349
412
  if (self.roleId === target.roleId && self.roleType === "orchestrator" && target.roleType === "orchestrator") return undefined;
413
+ const selfInRequestedRoom = isPeerInZpeerRoom(self, roomId);
414
+ const targetInRequestedRoom = isPeerInZpeerRoom(target, roomId);
415
+ const bothPeersAreWorkers = self.roleType === "worker" && target.roleType === "worker";
416
+ if (selfInRequestedRoom && targetInRequestedRoom && !bothPeersAreWorkers) return undefined;
350
417
  const teamName = self.team || target.team || "zob-core";
351
418
  if (target.team !== teamName) return "zpeer topology blocked: peers are in different teams";
352
419
  let topologyRoot = repoRoot;
@@ -355,11 +422,19 @@ function validateZpeerTopology(repoRoot: string, self: ZobLivePeerCard, target:
355
422
  topologyRoot = process.cwd();
356
423
  loaded = loadTeamDefinition(topologyRoot, teamName);
357
424
  }
358
- if (!loaded.definition) return `zpeer topology blocked: ${loaded.errors.join("; ")}`;
359
- const definitionErrors = validateTeamDefinition(topologyRoot, loaded.definition);
360
- if (definitionErrors.length > 0) return `zpeer topology blocked: ${definitionErrors.join("; ")}`;
361
- const edgeErrors = validateZobComsEdge(loaded.definition, self.roleId, target.roleId);
362
- if (edgeErrors.length > 0) return `zpeer topology blocked: ${edgeErrors.join("; ")}`;
425
+ if (loaded.definition) {
426
+ const definitionErrors = validateTeamDefinition(topologyRoot, loaded.definition);
427
+ if (definitionErrors.length > 0) return `zpeer topology blocked: ${definitionErrors.join("; ")}`;
428
+ const edgeErrors = validateZobComsEdge(loaded.definition, self.roleId, target.roleId);
429
+ if (edgeErrors.length === 0) return undefined;
430
+ if (!edgeErrors.some((error) => error.startsWith("Unknown coms sender") || error.startsWith("Unknown coms receiver"))) return `zpeer topology blocked: ${edgeErrors.join("; ")}`;
431
+ }
432
+
433
+ if (!self.team || self.team !== target.team) return "zpeer topology blocked: peers are not in the same zteam";
434
+ const zteam = loadZteamManifest(repoRoot, self.team);
435
+ if (zteam.errors.length > 0) return `zpeer topology blocked: ${loaded.errors.join("; ") || "legacy topology rejected ZAgent role ids"}; zteam fallback blocked: ${zteam.errors.join("; ")}`;
436
+ if (!zteamAllowsZpeerContact(zteam.manifest, self.roleId, roomId, selfAlias)) return `zpeer topology blocked: zteam '${self.team}' does not allow ${self.roleId} in room '${roomId}'`;
437
+ if (!zteamAllowsZpeerContact(zteam.manifest, target.roleId, roomId, targetAlias)) return `zpeer topology blocked: zteam '${target.team}' does not allow ${target.roleId} in room '${roomId}'`;
363
438
  return undefined;
364
439
  }
365
440
 
@@ -400,7 +475,7 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
400
475
  const target = candidates[0];
401
476
  const targetReachableStatus = zpeerReachableStatus(target.peer);
402
477
  if (targetReachableStatus !== "online") return finish("attempt", { status: "blocked", reason: `peer @${targetAlias} is ${targetReachableStatus}`, targetAlias, taskHash, bodyStored: false }, 1);
403
- const topologyBlocker = validateZpeerTopology(repoRoot, self, target.peer);
478
+ const topologyBlocker = validateZpeerTopology(repoRoot, self, target.peer, roomId, senderAlias, target.membership.alias);
404
479
  if (topologyBlocker) return finish("attempt", { status: "blocked", reason: topologyBlocker, targetAlias, taskHash, bodyStored: false }, 1);
405
480
  if (target.peer.transport !== "local_socket" || target.peer.endpoint.startsWith("pending-") || target.peer.endpoint === "observe-only") return finish("attempt", { status: "blocked", reason: `peer @${targetAlias} is not reachable by local_socket`, targetAlias, taskHash, bodyStored: false }, 1);
406
481
  if (self.transport !== "local_socket" || !self.endpoint || self.endpoint.startsWith("pending-") || self.endpoint === "observe-only") return finish("attempt", { status: "blocked", reason: "current session has no local_socket reply endpoint", targetAlias, taskHash, bodyStored: false }, 1);
@@ -560,6 +560,10 @@ export function resumeRuntimeGoal(goal: RuntimeGoal, requestedAdditionalTurns?:
560
560
  export function queueRuntimeGoalContinuation(pi: ExtensionAPI, state: HarnessRuntimeState, ctx: ExtensionContext, options: { userVisible?: boolean; retryMs?: number } = {}): void {
561
561
  const goal = state.runtimeGoal;
562
562
  if (!canContinue(goal)) return;
563
+ if (state.zobLive.passivePeerWait?.suppressGoalContinuation === true) {
564
+ clearRuntimeGoalContinuationTimer(state);
565
+ return;
566
+ }
563
567
  const humanDecision = pauseIfHumanDecisionRequired(pi, state, goal);
564
568
  if (humanDecision) {
565
569
  ctx.ui.notify(`ZOB /goal paused: ${goal.oracle.blockerSummary}`, "warning");