zob-harness 0.14.0 → 0.15.1

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.
@@ -719,7 +719,27 @@
719
719
  "docRefs": [
720
720
  ".pi/extensions/zob-harness/src/AGENTS.md"
721
721
  ],
722
- "noShipNotes": "Agent-initiated visible ZPeer ask; local_socket only, room-scoped with optional explicit roomId, mode defaults async, normal/urgent/force are rate/loop guarded, force requires a hashed reason and role/topology guards, raw bodies transient and durable records hash-only."
722
+ "noShipNotes": "Agent-initiated visible ZPeer ask; local_socket only, room-scoped with optional explicit roomId, mode defaults async, opt-in requireResponse tracks msgId-correlated replies with bounded timeout/reinjection metadata, normal/urgent/force are rate/loop guarded, force requires a hashed reason and role/topology guards, raw bodies transient and durable records hash-only."
723
+ },
724
+ {
725
+ "name": "zpeer_reply",
726
+ "family": "coms-v2-live",
727
+ "modes": [
728
+ "explore",
729
+ "plan",
730
+ "implement",
731
+ "oracle",
732
+ "factory",
733
+ "orchestrator"
734
+ ],
735
+ "skillRefs": [
736
+ ".pi/skills/zob-coms-v2-live/SKILL.md",
737
+ ".pi/skills/zob-coms-safety/SKILL.md"
738
+ ],
739
+ "docRefs": [
740
+ ".pi/extensions/zob-harness/src/AGENTS.md"
741
+ ],
742
+ "noShipNotes": "Explicit msgId-bound ZPeer reply path for active inbound messages; local_socket only, requires exact active msgId, wrong/expired/already-answered msgIds are blocked, raw reply body is transient and durable metadata stores outputHash/status only."
723
743
  },
724
744
  {
725
745
  "name": "zob_goal_room_send",
@@ -1671,7 +1691,7 @@
1671
1691
  "AGENTS.md",
1672
1692
  ".pi/extensions/zob-harness/src/AGENTS.md"
1673
1693
  ],
1674
- "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."
1694
+ "noShipNotes": "Local-only multi-room-scoped peer UX with active-room compatibility; supports /zpeer reply <msgId> <response> and --require-response; transient prompt/response over local_socket, persisted command/mission metadata is hash/body-free."
1675
1695
  },
1676
1696
  {
1677
1697
  "name": "zagent",
@@ -46,7 +46,7 @@ export const SUPERVISED_READONLY_CHILD_TOOLS = ["read", "grep", "find", "ls"] as
46
46
  export const READ_ONLY_CHAIN_TOOLS = ["read", "grep", "find", "ls"] as const;
47
47
  export const BLOCKED_CHAIN_TOOLS = ["bash", "edit", "write", "delegate_agent", "delegate_task", "orchestrate_run", "factory_run", "factory_quarantine_review", "factory_quarantine_activate", "factory_quarantine_verify_activation", "chain_run"] as const;
48
48
 
49
- export const ZOB_COMS_TOOLS = ["zob_coms_send", "zob_coms_ack", "zob_coms_status", "zob_coms_reply", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask"] as const;
49
+ export const ZOB_COMS_TOOLS = ["zob_coms_send", "zob_coms_ack", "zob_coms_status", "zob_coms_reply", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zpeer_reply"] as const;
50
50
  export const ZOB_GOAL_ROOM_TOOLS = ["zob_goal_room_send", "zob_goal_room_list"] as const;
51
51
  export const ZOB_GOVERNED_REQUEST_TOOLS = ["zob_governed_request_extract"] as const;
52
52
  export const ZOB_WORKSPACE_CLAIM_TOOLS = ["zob_workspace_claim", "zob_workspace_release", "zob_workspace_claims_list"] as const;
@@ -70,10 +70,10 @@ export const ZOB_AUTONOMOUS_READ_TOOLS = ["zob_autonomous_validate_run", "zob_au
70
70
  export const ZOB_AUTONOMOUS_FACTORY_TOOLS = ["zob_autonomous_dry_run", "zob_autonomous_readonly_smoke"] as const;
71
71
 
72
72
  export const MODE_TOOLS: Record<ModeName, string[]> = {
73
- 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],
73
+ explore: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zpeer_reply", "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
74
  plan: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_PLAN_LAUNCH_TOOLS, ...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_WORKLIST_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
75
  implement: ["read", "bash", "edit", "write", "grep", "find", "ls", "delegate_agent", "delegate_task", ...ZOB_PLAN_LAUNCH_TOOLS, ...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_WORKLIST_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
- 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],
76
+ oracle: ["read", "grep", "find", "ls", "bash", "delegate_agent", "delegate_task", "zob_coms_list", "zob_coms_get", "zob_coms_await", "zpeer_ask", "zpeer_reply", "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],
77
77
  orchestrator: ["read", "grep", "find", "ls", "delegate_agent", "delegate_task", "orchestrate_run", "chain_run", ...ZOB_PLAN_LAUNCH_TOOLS, ...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_WORKLIST_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],
78
78
  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_PLAN_LAUNCH_TOOLS, ...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_WORKLIST_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],
79
79
  // Vanilla is handled specially by applyMode: all currently available Pi tools are enabled.
@@ -28,6 +28,13 @@ export interface ZobLiveEnvelope {
28
28
  artifactHashes?: string[];
29
29
  replyEndpoint?: string;
30
30
  replyEndpointHash?: string;
31
+ requireResponse?: boolean;
32
+ responseTimeoutMs?: number;
33
+ responseRequiredBy?: string;
34
+ maxReinjects?: number;
35
+ reinjectCount?: number;
36
+ replyToMsgId?: string;
37
+ responseHash?: string;
31
38
  transientPrompt?: string;
32
39
  transientResponse?: string;
33
40
  priority?: ZpeerInterruptPriority;
@@ -76,6 +83,10 @@ export function buildZobLiveAckEnvelope(request: ZobLiveEnvelope, interruptStatu
76
83
  interruptMode: request.interruptMode,
77
84
  interruptReasonHash: request.interruptReasonHash,
78
85
  interruptStatus: interruptStatus ?? request.interruptStatus,
86
+ requireResponse: request.requireResponse,
87
+ responseTimeoutMs: request.responseTimeoutMs,
88
+ responseRequiredBy: request.responseRequiredBy,
89
+ maxReinjects: request.maxReinjects,
79
90
  });
80
91
  }
81
92
 
@@ -122,6 +133,18 @@ export function validateZobLiveEnvelope(value: unknown): string[] {
122
133
  if (value.interruptMode !== undefined && (typeof value.interruptMode !== "string" || !ZPEER_INTERRUPT_MODES.has(value.interruptMode))) errors.push("ZOB live envelope interruptMode is invalid");
123
134
  if (value.interruptReasonHash !== undefined && (typeof value.interruptReasonHash !== "string" || !/^[a-f0-9]{64}$/i.test(value.interruptReasonHash))) errors.push("ZOB live envelope interruptReasonHash must be sha256 hex when provided");
124
135
  if (value.interruptStatus !== undefined && (typeof value.interruptStatus !== "string" || !ZPEER_INTERRUPT_STATUSES.has(value.interruptStatus))) errors.push("ZOB live envelope interruptStatus is invalid");
136
+ if (value.requireResponse !== undefined && typeof value.requireResponse !== "boolean") errors.push("ZOB live envelope requireResponse must be boolean when provided");
137
+ if (value.responseTimeoutMs !== undefined && (typeof value.responseTimeoutMs !== "number" || !Number.isFinite(value.responseTimeoutMs) || value.responseTimeoutMs < 25 || value.responseTimeoutMs > 30 * 60 * 1000)) errors.push("ZOB live envelope responseTimeoutMs must be 25..1800000 when provided");
138
+ if (value.responseRequiredBy !== undefined && (typeof value.responseRequiredBy !== "string" || !Number.isFinite(Date.parse(value.responseRequiredBy)))) errors.push("ZOB live envelope responseRequiredBy must be an ISO timestamp when provided");
139
+ if (value.maxReinjects !== undefined && (typeof value.maxReinjects !== "number" || !Number.isInteger(value.maxReinjects) || value.maxReinjects < 0 || value.maxReinjects > 3)) errors.push("ZOB live envelope maxReinjects must be 0..3 when provided");
140
+ if (value.reinjectCount !== undefined && (typeof value.reinjectCount !== "number" || !Number.isInteger(value.reinjectCount) || value.reinjectCount < 0 || value.reinjectCount > 3)) errors.push("ZOB live envelope reinjectCount must be 0..3 when provided");
141
+ if (value.replyToMsgId !== undefined && (typeof value.replyToMsgId !== "string" || value.replyToMsgId.trim().length === 0)) errors.push("ZOB live envelope replyToMsgId must be non-empty when provided");
142
+ if (value.responseHash !== undefined && (typeof value.responseHash !== "string" || !/^[a-f0-9]{64}$/i.test(value.responseHash))) errors.push("ZOB live envelope responseHash must be sha256 hex when provided");
143
+ if (value.type === "prompt" && value.requireResponse === true) {
144
+ if (typeof value.replyEndpoint !== "string" || value.replyEndpoint.length === 0) errors.push("ZOB live required-response prompt requires replyEndpoint");
145
+ if (typeof value.responseRequiredBy !== "string") errors.push("ZOB live required-response prompt requires responseRequiredBy");
146
+ }
147
+ if (value.type === "response" && value.replyToMsgId !== value.msgId) errors.push("ZOB live response replyToMsgId must match msgId");
125
148
  if (value.type === "prompt") {
126
149
  if (typeof value.sender !== "string" || typeof value.receiver !== "string") errors.push("ZOB live prompt envelope requires sender and receiver");
127
150
  if (typeof value.taskHash !== "string") errors.push("ZOB live prompt envelope requires taskHash");
@@ -2,7 +2,7 @@ import type { ZobLiveEnvelope } from "./envelope.js";
2
2
 
3
3
  export interface ZobPendingReplyResult {
4
4
  msgId: string;
5
- status: "completed" | "error" | "timeout";
5
+ status: "completed" | "error" | "timeout" | "required_response_expired";
6
6
  envelope?: ZobLiveEnvelope;
7
7
  errorHash?: string;
8
8
  }
@@ -10,6 +10,7 @@ export interface ZobPendingReplyResult {
10
10
  interface PendingReply {
11
11
  msgId: string;
12
12
  createdAt: number;
13
+ requireResponse: boolean;
13
14
  resolve: (result: ZobPendingReplyResult) => void;
14
15
  timer: ReturnType<typeof setTimeout>;
15
16
  }
@@ -17,11 +18,16 @@ interface PendingReply {
17
18
  export class ZobPendingReplies {
18
19
  private readonly pending = new Map<string, PendingReply>();
19
20
  private readonly completed = new Map<string, ZobPendingReplyResult>();
21
+ private readonly expired = new Set<string>();
20
22
 
21
- wait(msgId: string, timeoutMs: number): Promise<ZobPendingReplyResult> {
23
+ wait(msgId: string, timeoutMs: number, options: { requireResponse?: boolean } = {}): Promise<ZobPendingReplyResult> {
22
24
  const completed = this.completed.get(msgId);
23
25
  if (completed) {
24
26
  this.completed.delete(msgId);
27
+ if (options.requireResponse === true && completed.status === "completed" && completed.envelope?.replyToMsgId !== msgId) {
28
+ this.expired.add(msgId);
29
+ return Promise.resolve({ msgId, status: "required_response_expired" });
30
+ }
25
31
  return Promise.resolve(completed);
26
32
  }
27
33
  const boundedTimeout = Math.max(25, Math.min(30 * 60 * 1000, Math.floor(timeoutMs)));
@@ -29,22 +35,27 @@ export class ZobPendingReplies {
29
35
  return new Promise<ZobPendingReplyResult>((resolve) => {
30
36
  const timer = setTimeout(() => {
31
37
  this.pending.delete(msgId);
32
- resolve({ msgId, status: "timeout" });
38
+ if (options.requireResponse === true) this.expired.add(msgId);
39
+ resolve({ msgId, status: options.requireResponse === true ? "required_response_expired" : "timeout" });
33
40
  }, boundedTimeout);
34
41
  timer.unref?.();
35
- this.pending.set(msgId, { msgId, createdAt: Date.now(), resolve, timer });
42
+ this.pending.set(msgId, { msgId, createdAt: Date.now(), requireResponse: options.requireResponse === true, resolve, timer });
36
43
  });
37
44
  }
38
45
 
39
46
  complete(msgId: string, envelope: ZobLiveEnvelope): boolean {
40
- const result: ZobPendingReplyResult = { msgId, status: "completed", envelope };
47
+ if (this.expired.has(msgId)) return false;
41
48
  const item = this.pending.get(msgId);
49
+ if (item?.requireResponse && envelope.replyToMsgId !== msgId) return false;
50
+ if (envelope.replyToMsgId !== undefined && envelope.replyToMsgId !== msgId) return false;
51
+ const result: ZobPendingReplyResult = { msgId, status: "completed", envelope };
42
52
  if (!item) {
43
53
  this.completed.set(msgId, result);
44
54
  return false;
45
55
  }
46
56
  clearTimeout(item.timer);
47
57
  this.pending.delete(msgId);
58
+ this.expired.delete(msgId);
48
59
  item.resolve(result);
49
60
  return true;
50
61
  }
@@ -58,11 +69,27 @@ export class ZobPendingReplies {
58
69
  }
59
70
  clearTimeout(item.timer);
60
71
  this.pending.delete(msgId);
72
+ this.expired.delete(msgId);
73
+ item.resolve(result);
74
+ return true;
75
+ }
76
+
77
+ expire(msgId: string, errorHash?: string): boolean {
78
+ const result: ZobPendingReplyResult = { msgId, status: "required_response_expired", errorHash };
79
+ this.expired.add(msgId);
80
+ const item = this.pending.get(msgId);
81
+ if (!item) {
82
+ this.completed.set(msgId, result);
83
+ return false;
84
+ }
85
+ clearTimeout(item.timer);
86
+ this.pending.delete(msgId);
61
87
  item.resolve(result);
62
88
  return true;
63
89
  }
64
90
 
65
91
  cancel(msgId: string): boolean {
92
+ this.expired.delete(msgId);
66
93
  const item = this.pending.get(msgId);
67
94
  if (!item) return false;
68
95
  clearTimeout(item.timer);
@@ -75,6 +102,7 @@ export class ZobPendingReplies {
75
102
  return [
76
103
  ...[...this.pending.values()].map((item) => ({ msgId: item.msgId, ageMs: now - item.createdAt, status: "pending", bodyStored: false })),
77
104
  ...[...this.completed.values()].map((item) => ({ msgId: item.msgId, status: item.status, bodyStored: false })),
105
+ ...[...this.expired.values()].map((msgId) => ({ msgId, status: "required_response_expired", bodyStored: false })),
78
106
  ];
79
107
  }
80
108
  }
@@ -246,6 +246,7 @@ function buildTeamAgentLease(repoRoot: string, peer: ZobLivePeerCard, input: { n
246
246
  contextUsedPct: peer.contextUsedPct,
247
247
  queueDepth: peer.queueDepth,
248
248
  status: peer.status === "offline" ? "offline" : "online",
249
+ socketVerifiedAt: peer.socketVerifiedAt,
249
250
  zpeerRoomId: peer.zpeerRoomId,
250
251
  zpeerAlias: peer.zpeerAlias,
251
252
  zpeerActiveRoomId: peer.zpeerActiveRoomId,
@@ -291,6 +292,7 @@ function leaseToPeerCard(lease: ZobLiveTeamAgentLease, nowMs: number): ZobLivePe
291
292
  contextUsedPct: lease.contextUsedPct,
292
293
  queueDepth: lease.queueDepth,
293
294
  status: deriveLeaseStatus(lease, nowMs),
295
+ socketVerifiedAt: lease.socketVerifiedAt,
294
296
  zpeerRoomId: lease.zpeerRoomId,
295
297
  zpeerAlias: lease.zpeerAlias,
296
298
  zpeerActiveRoomId: lease.zpeerActiveRoomId,
@@ -578,8 +580,9 @@ export async function sweepZobLivePeerHealth(repoRoot: string, input: { teamName
578
580
  }
579
581
  }
580
582
  if (responds) {
583
+ const livePeer = { ...peer, heartbeatAt: nowIso, status: "online", socketVerifiedAt: nowIso } as ZobLivePeerCard;
584
+ applySweepPeerUpdate(repoRoot, livePeer, nowMs);
581
585
  if (peer.status !== "online") {
582
- applySweepPeerUpdate(repoRoot, { ...peer, heartbeatAt: nowIso, status: "online", socketVerifiedAt: nowIso } as ZobLivePeerCard, nowMs);
583
586
  revived += 1;
584
587
  } else {
585
588
  retainedLive += 1;
@@ -45,6 +45,8 @@ export function buildZobLiveResponseEnvelope(request: ZobLiveEnvelope, transient
45
45
  outputHash: capture.outputHash,
46
46
  artifactRefs: capture.artifactRefs,
47
47
  artifactHashes: capture.artifactHashes,
48
+ replyToMsgId: request.msgId,
49
+ responseHash: capture.outputHash,
48
50
  transientResponse,
49
51
  });
50
52
  }
@@ -104,6 +104,7 @@ export interface ZobLivePeerCard {
104
104
  contextUsedPct: number;
105
105
  queueDepth: number;
106
106
  status: ZobLivePeerStatus;
107
+ socketVerifiedAt?: string;
107
108
  zpeerRoomId?: string;
108
109
  zpeerAlias?: string;
109
110
  zpeerActiveRoomId?: string;
@@ -141,6 +142,7 @@ export interface ZobLiveTeamAgentLease {
141
142
  contextUsedPct: number;
142
143
  queueDepth: number;
143
144
  status: ZobLivePeerStatus;
145
+ socketVerifiedAt?: string;
144
146
  zpeerRoomId?: string;
145
147
  zpeerAlias?: string;
146
148
  zpeerActiveRoomId?: string;
@@ -43,7 +43,7 @@ export interface ZpeerPeerRoomSummary extends ZpeerRoomSummary {
43
43
  }
44
44
 
45
45
  export type ZpeerSendMode = "await" | "async" | "long";
46
- export type ZpeerSendStatus = "delivered" | "waiting" | "reply" | "completed" | "blocked" | "error" | "timeout" | "expired";
46
+ export type ZpeerSendStatus = "delivered" | "waiting" | "reply" | "completed" | "blocked" | "error" | "timeout" | "expired" | "required_response_expired";
47
47
 
48
48
  export interface ZpeerSendResult {
49
49
  status: ZpeerSendStatus;
@@ -58,6 +58,12 @@ export interface ZpeerSendResult {
58
58
  interruptMode?: ZpeerInterruptMode;
59
59
  interruptStatus?: ZpeerInterruptStatus;
60
60
  interruptReasonHash?: string;
61
+ requireResponse?: boolean;
62
+ responseRequiredBy?: string;
63
+ responseTimeoutMs?: number;
64
+ maxReinjects?: number;
65
+ responseReceived?: boolean;
66
+ deliveryStatus?: "delivered" | "blocked" | "stale" | "offline" | "transport_error";
61
67
  deliveryMethod?: "local_socket" | "tmux_sendkeys";
62
68
  fallback_delivery?: boolean;
63
69
  best_effort?: boolean;
@@ -69,20 +75,36 @@ export interface ZpeerSendFeedback {
69
75
  result: ZpeerSendResult;
70
76
  }
71
77
 
78
+ export type ZpeerFallbackDelivery = (input: {
79
+ targetAlias: string;
80
+ senderAlias: string;
81
+ roomId: string;
82
+ taskHash?: string;
83
+ prompt: string;
84
+ priority: ZpeerInterruptPriority;
85
+ interruptMode: ZpeerInterruptMode;
86
+ requireResponse: boolean;
87
+ responseTimeoutMs?: number;
88
+ maxReinjects?: number;
89
+ }) => Promise<{ delivered: boolean; target?: string; reason?: string }>;
90
+
72
91
  export interface ZpeerSendOptions {
73
92
  mode?: ZpeerSendMode;
74
93
  roomId?: string;
75
94
  priority?: ZpeerInterruptPriority;
76
95
  interruptMode?: ZpeerInterruptMode;
77
96
  interruptReasonHash?: string;
97
+ requireResponse?: boolean;
98
+ responseTimeoutMs?: number;
99
+ maxReinjects?: number;
78
100
  onFeedback?: (feedback: ZpeerSendFeedback) => void;
79
101
  // WS-ZH4: optional best-effort fallback delivery seam. When local-socket delivery is
80
102
  // blocked (0 live candidates), the app may inject a fallback (e.g. tmux send-keys to
81
103
  // the target agent's pane). This keeps coms-v2 IO-free; the app supplies the tmux
82
104
  // delivery. A fallback delivery is NOT verified delivery: outputHash stays absent,
83
105
  // confirmation_ref stays null, and bodyStored stays false (coms-safety: append-only /
84
- // best-effort is not delivery success).
85
- fallbackDelivery?: (input: { targetAlias: string; roomId: string; taskHash?: string; prompt: string }) => Promise<{ delivered: boolean; target?: string; reason?: string }>;
106
+ // best-effort is not delivery success). Force/abort is never eligible for fallback.
107
+ fallbackDelivery?: ZpeerFallbackDelivery;
86
108
  }
87
109
 
88
110
  interface ZpeerRoomPeer {
@@ -100,6 +122,37 @@ export function safeZpeerAlias(value: string | undefined): string | undefined {
100
122
  return trimmed;
101
123
  }
102
124
 
125
+ export function zpeerAliasLookupKey(value: string | undefined): string | undefined {
126
+ const alias = safeZpeerAlias(value);
127
+ return alias ? alias.replaceAll("-", "_") : undefined;
128
+ }
129
+
130
+ export function zpeerAliasesEquivalent(left: string | undefined, right: string | undefined): boolean {
131
+ const leftKey = zpeerAliasLookupKey(left);
132
+ const rightKey = zpeerAliasLookupKey(right);
133
+ return Boolean(leftKey && rightKey && leftKey === rightKey);
134
+ }
135
+
136
+ export function zpeerAliasIncluded(aliases: readonly string[] | undefined, alias: string | undefined): boolean {
137
+ const key = zpeerAliasLookupKey(alias);
138
+ return Boolean(key && aliases?.some((candidate) => zpeerAliasLookupKey(candidate) === key));
139
+ }
140
+
141
+ export function duplicateZpeerAliasLookupKeys(aliases: readonly string[]): string[] {
142
+ const seen = new Set<string>();
143
+ const duplicates = new Set<string>();
144
+ for (const alias of aliases) {
145
+ const key = zpeerAliasLookupKey(alias);
146
+ if (!key) continue;
147
+ if (seen.has(key)) duplicates.add(alias);
148
+ else seen.add(key);
149
+ }
150
+ return aliases.filter((alias) => {
151
+ const key = zpeerAliasLookupKey(alias);
152
+ return Boolean(key && duplicates.has(alias)) || Boolean(key && aliases.filter((candidate) => zpeerAliasLookupKey(candidate) === key).length > 1);
153
+ }).filter((alias, index, all) => all.indexOf(alias) === index).sort();
154
+ }
155
+
103
156
  export function safeZpeerRoomId(value: string | undefined): string | undefined {
104
157
  const trimmed = value?.trim();
105
158
  if (!trimmed || !ROOM_PATTERN.test(trimmed)) return undefined;
@@ -189,8 +242,12 @@ function withZpeerMembershipState(repoRoot: string, peer: ZobLivePeerCard, membe
189
242
  });
190
243
  }
191
244
 
245
+ function zpeerStrictSocketEvidenceRequired(): boolean {
246
+ return /^(1|true|yes|on|strict)$/i.test(process.env.ZOB_ZPEER_REQUIRE_VERIFIED_SOCKET ?? "");
247
+ }
248
+
192
249
  function zpeerReachableStatus(peer: ZobLivePeerCard): ZobLivePeerStatus {
193
- if (peer.status === "online" && hasLocalSocketEndpointEvidence(peer)) return "online";
250
+ if (peer.status === "online" && hasLocalSocketEndpointEvidence(peer, { requireVerified: zpeerStrictSocketEvidenceRequired() })) return "online";
194
251
  if (peer.status === "stale") return "stale";
195
252
  return "offline";
196
253
  }
@@ -212,9 +269,34 @@ async function peerRespondsToAliasPing(peer: ZobLivePeerCard): Promise<boolean>
212
269
  }
213
270
  }
214
271
 
272
+ async function probeAndReviveZpeerCandidate(repoRoot: string, entry: ZpeerRoomPeer): Promise<ZpeerRoomPeer | undefined> {
273
+ if (!hasLocalSocketEndpointEvidence(entry.peer)) return undefined;
274
+ const nowIso = new Date().toISOString();
275
+ try {
276
+ const response = await sendZobLocalEnvelope(entry.peer.endpoint, {
277
+ schema: "zob.live-envelope.v1",
278
+ type: "ping",
279
+ msgId: `zpeer-candidate-probe:${entry.peer.roleId}:${Date.now()}`,
280
+ hops: 0,
281
+ timestamp: nowIso,
282
+ bodyStored: false,
283
+ }, { timeoutMs: 1_000 });
284
+ if (response.type !== "pong" && response.type !== "ack") return undefined;
285
+ const revived = { ...entry.peer, heartbeatAt: nowIso, status: "online", socketVerifiedAt: nowIso } as ZobLivePeerCard;
286
+ if (revived.zpeerAdhoc === true || revived.projectId !== buildZobComsProjectId(repoRoot)) {
287
+ writeZobLivePeerCardToProjectId(revived);
288
+ } else {
289
+ writeZobLiveTeamAgentLease(repoRoot, revived, { reason: "zpeer_candidate_probe" });
290
+ }
291
+ return { ...entry, peer: revived };
292
+ } catch {
293
+ return undefined;
294
+ }
295
+ }
296
+
215
297
  async function activeAliasCollision(repoRoot: string, self: ZobLivePeerCard, roomId: string, alias: string): Promise<ZpeerRoomPeer | undefined> {
216
298
  for (const entry of peersInRoom(repoRoot, roomId)) {
217
- if (entry.peer.sessionHash === self.sessionHash || entry.membership.alias !== alias) continue;
299
+ if (entry.peer.sessionHash === self.sessionHash || !zpeerAliasesEquivalent(entry.membership.alias, alias)) continue;
218
300
  if (await peerRespondsToAliasPing(entry.peer)) return entry;
219
301
  if (zpeerReachableStatus(entry.peer) === "online") {
220
302
  try { writeZobLivePeerCard(repoRoot, { ...entry.peer, heartbeatAt: new Date().toISOString(), status: "offline" }); } catch { /* best-effort stale alias release */ }
@@ -281,7 +363,7 @@ function buildZpeerRoomSummaryFromPeers(projectId: string, self: ZobLivePeerCard
281
363
  statusAliases[status].push(entry.membership.alias);
282
364
  }
283
365
  const onlineAliases = statusAliases.online.sort();
284
- const duplicateAliases = onlineAliases.filter((alias, index) => onlineAliases.indexOf(alias) !== index).filter((alias, index, all) => all.indexOf(alias) === index);
366
+ const duplicateAliases = duplicateZpeerAliasLookupKeys(onlineAliases);
285
367
  return {
286
368
  schema: "zob.zpeer-room-summary.v1",
287
369
  projectId,
@@ -425,6 +507,12 @@ function appendZpeerPeerRecords(repoRoot: string, record: {
425
507
  interruptMode?: ZpeerInterruptMode;
426
508
  interruptStatus?: ZpeerInterruptStatus;
427
509
  interruptReasonHash?: string;
510
+ requireResponse?: boolean;
511
+ responseRequiredBy?: string;
512
+ responseTimeoutMs?: number;
513
+ maxReinjects?: number;
514
+ responseReceived?: boolean;
515
+ deliveryStatus?: "delivered" | "blocked" | "stale" | "offline" | "transport_error";
428
516
  deliveryMethod?: "local_socket" | "tmux_sendkeys";
429
517
  fallbackDelivery?: boolean;
430
518
  }): void {
@@ -444,6 +532,12 @@ function appendZpeerPeerRecords(repoRoot: string, record: {
444
532
  interruptMode: record.interruptMode,
445
533
  interruptStatus: record.interruptStatus,
446
534
  interruptReasonHash: record.interruptReasonHash,
535
+ requireResponse: record.requireResponse,
536
+ responseRequiredBy: record.responseRequiredBy,
537
+ responseTimeoutMs: record.responseTimeoutMs,
538
+ maxReinjects: record.maxReinjects,
539
+ responseReceived: record.responseReceived,
540
+ deliveryStatus: record.deliveryStatus,
447
541
  deliveryMethod: record.deliveryMethod,
448
542
  fallbackDelivery: record.fallbackDelivery,
449
543
  localOnly: true,
@@ -495,6 +589,9 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
495
589
  const interruptMode = options.interruptMode ?? (priority === "force" ? "abort" : priority === "urgent" ? "steer" : "none");
496
590
  const interruptReasonHash = options.interruptReasonHash;
497
591
  const interruptRequested = priority !== "normal" || interruptMode !== "none";
592
+ const requireResponse = options.requireResponse === true;
593
+ const responseTimeoutMs = Math.max(1_000, Math.min(30 * 60 * 1000, Math.floor(options.responseTimeoutMs ?? (mode === "long" ? 30 * 60 * 1000 : 10 * 60 * 1000))));
594
+ const maxReinjects = Math.max(0, Math.min(3, Math.floor(options.maxReinjects ?? 1)));
498
595
  const emitFeedback = (kind: ZpeerSendFeedback["kind"], result: ZpeerSendResult): void => {
499
596
  options.onFeedback?.({ kind, result });
500
597
  };
@@ -514,10 +611,16 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
514
611
  interruptMode: result.interruptMode ?? interruptMode,
515
612
  interruptStatus: result.interruptStatus,
516
613
  interruptReasonHash: result.interruptReasonHash ?? interruptReasonHash,
614
+ requireResponse: result.requireResponse,
615
+ responseRequiredBy: result.responseRequiredBy,
616
+ responseTimeoutMs: result.responseTimeoutMs,
617
+ maxReinjects: result.maxReinjects,
618
+ responseReceived: result.responseReceived,
619
+ deliveryStatus: result.deliveryStatus,
517
620
  deliveryMethod: result.deliveryMethod,
518
621
  fallbackDelivery: result.fallback_delivery,
519
622
  });
520
- return { roomId, priority, interruptMode, interruptReasonHash, ...result };
623
+ return { roomId, priority, interruptMode, interruptReasonHash, requireResponse: requireResponse || undefined, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined, ...result };
521
624
  };
522
625
 
523
626
  if (priority === "force" && !interruptReasonHash) return finish("attempt", { status: "blocked", reason: "force interrupt requires reason hash", targetAlias: targetAlias ?? undefined, taskHash, interruptStatus: "force_blocked", bodyStored: false });
@@ -530,8 +633,8 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
530
633
  if (priority === "force" && !ZPEER_FORCE_ALLOWED_SENDER_ROLE_TYPES.has(self.roleType)) return finish("attempt", { status: "blocked", reason: `force interrupt not allowed from role type ${self.roleType}`, targetAlias: targetAlias ?? undefined, taskHash, interruptStatus: "force_blocked", bodyStored: false });
531
634
  if (!targetAlias) return finish("attempt", { status: "blocked", reason: "invalid target alias", bodyStored: false });
532
635
  if (!transientPrompt.trim()) return finish("attempt", { status: "blocked", reason: "empty peer prompt", targetAlias, bodyStored: false });
533
- const candidates = peersInRoom(repoRoot, roomId).filter((entry) => entry.membership.alias === targetAlias && entry.peer.sessionHash !== self.sessionHash);
534
- if (targetAlias === senderAlias) return finish("attempt", { status: "blocked", reason: "cannot send to self", targetAlias, taskHash, bodyStored: false });
636
+ const candidates = peersInRoom(repoRoot, roomId).filter((entry) => zpeerAliasesEquivalent(entry.membership.alias, targetAlias) && entry.peer.sessionHash !== self.sessionHash);
637
+ if (zpeerAliasesEquivalent(targetAlias, senderAlias)) return finish("attempt", { status: "blocked", reason: "cannot send to self", targetAlias, taskHash, bodyStored: false });
535
638
  if (candidates.length === 0) return finish("attempt", { status: "blocked", reason: `peer @${targetAlias} not found in room '${roomId}'`, targetAlias, taskHash, bodyStored: false }, 0);
536
639
  let liveCandidates = candidates.filter((entry) => zpeerReachableStatus(entry.peer) === "online");
537
640
  if (liveCandidates.length > 1) {
@@ -545,6 +648,14 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
545
648
  }
546
649
  liveCandidates = responsiveCandidates;
547
650
  }
651
+ if (liveCandidates.length === 0) {
652
+ const probedCandidates: ZpeerRoomPeer[] = [];
653
+ for (const entry of candidates) {
654
+ const revived = await probeAndReviveZpeerCandidate(repoRoot, entry);
655
+ if (revived) probedCandidates.push(revived);
656
+ }
657
+ liveCandidates = probedCandidates;
658
+ }
548
659
  if (liveCandidates.length === 0) {
549
660
  const statuses = [...new Set(candidates.map((entry) => zpeerReachableStatus(entry.peer)))].sort().join("/") || "offline";
550
661
  // WS-ZH4: last-resort fallback delivery. When local-socket transport is blocked
@@ -552,14 +663,15 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
552
663
  // pane), deliver the prompt best-effort. This is NOT verified delivery:
553
664
  // outputHash/confirmation_ref stay absent and bodyStored stays false; the receiver,
554
665
  // if it answers at all, answers via its own local_socket later (that ledgered reply
555
- // is what WS-Q5 counts). If no fallback is supplied or it fails/declines, fall
556
- // through to the standard blocked result unchanged.
557
- if (options.fallbackDelivery && targetAlias && priority === "normal") {
666
+ // is what WS-Q5 counts). Urgent/steer is eligible when the caller explicitly wires
667
+ // a safe fallback hook; force/abort is never eligible. If no fallback is supplied or
668
+ // it fails/declines, fall through to the standard blocked result unchanged.
669
+ if (options.fallbackDelivery && targetAlias && priority !== "force") {
558
670
  try {
559
- const fallback = await options.fallbackDelivery({ targetAlias, roomId, taskHash, prompt: transientPrompt });
671
+ const fallback = await options.fallbackDelivery({ targetAlias, senderAlias, roomId, taskHash, prompt: transientPrompt, priority, interruptMode, requireResponse, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined });
560
672
  if (fallback.delivered) {
561
673
  const fallbackMsgId = `zpeer-fallback:${self.sessionHash.slice(0, 8)}:${Date.now()}`;
562
- return finish("attempt", { status: "delivered", reason: `tmux_sendkeys_fallback: peer @${targetAlias} socket ${statuses}; best-effort delivery to ${fallback.target ?? targetAlias} (not verified)`, msgId: fallbackMsgId, targetAlias, taskHash, deliveryMethod: "tmux_sendkeys", fallback_delivery: true, best_effort: true, bodyStored: false }, candidates.length);
674
+ return finish("attempt", { status: "delivered", reason: `tmux_sendkeys_fallback: peer @${targetAlias} socket ${statuses}; best-effort delivery to ${fallback.target ?? targetAlias} (not verified)`, msgId: fallbackMsgId, targetAlias, taskHash, deliveryStatus: "blocked", deliveryMethod: "tmux_sendkeys", fallback_delivery: true, best_effort: true, responseReceived: requireResponse ? false : undefined, bodyStored: false }, candidates.length);
563
675
  }
564
676
  } catch {
565
677
  // best-effort fallback failed; fall through to the standard blocked result.
@@ -578,7 +690,8 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
578
690
  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);
579
691
 
580
692
  const msgId = `zpeer:${self.sessionHash.slice(0, 8)}:${target.peer.sessionHash.slice(0, 8)}:${Date.now()}`;
581
- finish("attempt", { status: "delivered", msgId, targetAlias, taskHash, bodyStored: false }, 1);
693
+ const responseRequiredBy = requireResponse ? new Date(Date.now() + responseTimeoutMs).toISOString() : undefined;
694
+ finish("attempt", { status: "delivered", msgId, targetAlias, taskHash, requireResponse: requireResponse || undefined, responseRequiredBy, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined, deliveryStatus: "delivered", responseReceived: requireResponse ? false : undefined, bodyStored: false }, 1);
582
695
  const liveEnvelope = buildZobLiveEnvelope({
583
696
  type: "prompt",
584
697
  msgId,
@@ -594,6 +707,10 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
594
707
  interruptRequested,
595
708
  interruptMode,
596
709
  interruptReasonHash,
710
+ requireResponse: requireResponse || undefined,
711
+ responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined,
712
+ responseRequiredBy,
713
+ maxReinjects: requireResponse ? maxReinjects : undefined,
597
714
  });
598
715
  try {
599
716
  const ack = await sendZobLocalEnvelope(target.peer.endpoint, liveEnvelope, { timeoutMs: 5_000 });
@@ -601,27 +718,32 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
601
718
  const ackInterruptStatus = ack.interruptStatus;
602
719
  appendZpeerPeerRecords(repoRoot, { event: "ack", status: "delivered", roomId, msgId, senderAlias, targetAlias, taskHash, priority, interruptMode, interruptStatus: ackInterruptStatus, interruptReasonHash, peerCount: 1 });
603
720
  if (ackInterruptStatus === "force_blocked") return finish("terminal", { status: "blocked", reason: "force interrupt blocked by receiver policy", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, bodyStored: false }, 1);
604
- if (mode === "async") {
605
- const waiting = finish("terminal", { status: "waiting", reason: "delivered locally; awaiting async reply", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, bodyStored: false }, 1);
721
+ if (mode === "async" && !requireResponse) {
722
+ const waiting = finish("terminal", { status: "waiting", reason: "delivered locally; awaiting async reply", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, deliveryStatus: "delivered", bodyStored: false }, 1);
606
723
  emitFeedback("waiting", waiting);
607
724
  return waiting;
608
725
  }
609
- emitFeedback("delivered", { status: "delivered", roomId, msgId, targetAlias, taskHash, priority, interruptMode, interruptStatus: ackInterruptStatus, interruptReasonHash, bodyStored: false });
610
- emitFeedback("waiting", { status: "waiting", roomId, reason: mode === "long" ? "waiting for long peer reply" : "waiting for peer reply", msgId, targetAlias, taskHash, priority, interruptMode, interruptStatus: ackInterruptStatus, interruptReasonHash, bodyStored: false });
726
+ emitFeedback("delivered", { status: "delivered", roomId, msgId, targetAlias, taskHash, priority, interruptMode, interruptStatus: ackInterruptStatus, interruptReasonHash, requireResponse: requireResponse || undefined, responseRequiredBy, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined, deliveryStatus: "delivered", responseReceived: requireResponse ? false : undefined, bodyStored: false });
727
+ emitFeedback("waiting", { status: "waiting", roomId, reason: requireResponse ? "waiting for required peer response" : mode === "long" ? "waiting for long peer reply" : "waiting for peer reply", msgId, targetAlias, taskHash, priority, interruptMode, interruptStatus: ackInterruptStatus, interruptReasonHash, requireResponse: requireResponse || undefined, responseRequiredBy, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined, deliveryStatus: "delivered", responseReceived: requireResponse ? false : undefined, bodyStored: false });
611
728
  const reply = await awaitReply(msgId);
612
729
  const replyEnvelope = reply["envelope"];
613
730
  if (reply.status === "completed") {
614
731
  const transientResponse = replyEnvelope?.transientResponse;
615
- const result = finish("terminal", { status: "reply", msgId, targetAlias, taskHash, outputHash: replyEnvelope?.outputHash ?? (transientResponse ? sha256(transientResponse) : undefined), transientResponse, interruptStatus: ackInterruptStatus, bodyStored: false }, 1);
732
+ const result = finish("terminal", { status: "reply", msgId, targetAlias, taskHash, outputHash: replyEnvelope?.outputHash ?? (transientResponse ? sha256(transientResponse) : undefined), transientResponse, interruptStatus: ackInterruptStatus, requireResponse: requireResponse || undefined, responseRequiredBy, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined, deliveryStatus: "delivered", responseReceived: requireResponse ? true : undefined, bodyStored: false }, 1);
616
733
  emitFeedback("reply", result);
617
734
  return result;
618
735
  }
736
+ if (reply.status === "required_response_expired") {
737
+ const result = finish("terminal", { status: "required_response_expired", reason: "required peer response expired", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, requireResponse: true, responseRequiredBy, responseTimeoutMs, maxReinjects, deliveryStatus: "delivered", responseReceived: false, bodyStored: false }, 1);
738
+ emitFeedback("expired", result);
739
+ return result;
740
+ }
619
741
  if (reply.status === "timeout") {
620
- const result = finish("terminal", { status: "timeout", reason: "await response timed out", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, bodyStored: false }, 1);
742
+ const result = finish("terminal", { status: "timeout", reason: "await response timed out", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, deliveryStatus: "delivered", bodyStored: false }, 1);
621
743
  emitFeedback(mode === "long" ? "expired" : "timeout", result);
622
744
  return result;
623
745
  }
624
- const result = finish("terminal", { status: "error", reason: replyEnvelope?.errorHash ?? "peer response error", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, bodyStored: false }, 1);
746
+ const result = finish("terminal", { status: "error", reason: replyEnvelope?.errorHash ?? "peer response error", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, requireResponse: requireResponse || undefined, responseRequiredBy, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined, deliveryStatus: "delivered", responseReceived: requireResponse ? false : undefined, bodyStored: false }, 1);
625
747
  emitFeedback("error", result);
626
748
  return result;
627
749
  } catch (error) {
@@ -3,7 +3,7 @@ import { basename, join, relative, resolve } from "node:path";
3
3
 
4
4
  import type { ModeName } from "../../core/types/core.js";
5
5
  import { MODE_TOOLS } from "../../core/constants.js";
6
- import { safeZpeerAlias, safeZpeerRoomId } from "./coms-v2/zpeer.js";
6
+ import { safeZpeerAlias, safeZpeerRoomId, zpeerAliasIncluded, zpeerAliasesEquivalent } from "./coms-v2/zpeer.js";
7
7
  import { parseJsonFile } from "../../core/utils/json.js";
8
8
  import { isSafeArtifactName } from "../../core/utils/paths.js";
9
9
  import { isRecord } from "../../core/utils/records.js";
@@ -438,7 +438,7 @@ function communicationPolicyNarrowingErrors(base: ZAgentCommunicationPolicy | un
438
438
  for (const room of overlay.allowedRooms ?? []) if (!base.allowedRooms.includes(room)) errors.push(`${label}.allowedRooms must be a subset of the base policy: ${room}`);
439
439
  }
440
440
  if (base?.allowedAliases) {
441
- for (const alias of overlay.allowedAliases ?? []) if (!base.allowedAliases.includes(alias)) errors.push(`${label}.allowedAliases must be a subset of the base policy: ${alias}`);
441
+ for (const alias of overlay.allowedAliases ?? []) if (!zpeerAliasIncluded(base.allowedAliases, alias)) errors.push(`${label}.allowedAliases must be a subset of the base policy: ${alias}`);
442
442
  }
443
443
  return errors;
444
444
  }
@@ -865,7 +865,7 @@ function policyAllowsZpeerContact(policy: ZAgentCommunicationPolicy | undefined,
865
865
  if (!policy) return true;
866
866
  if (policy.zpeerContact === false || policy.allowZpeerContact === false) return false;
867
867
  if (roomId && policy.allowedRooms && !policy.allowedRooms.includes(roomId)) return false;
868
- if (alias && policy.allowedAliases && !policy.allowedAliases.includes(alias)) return false;
868
+ if (alias && policy.allowedAliases && !zpeerAliasIncluded(policy.allowedAliases, alias)) return false;
869
869
  if (policy.requireActiveRoom && !roomId) return false;
870
870
  return true;
871
871
  }
@@ -876,7 +876,7 @@ export function zteamAllowsZpeerContact(team: ZTeamManifest, zagentId: string, r
876
876
  ...(team.members ?? []),
877
877
  ...(team.agents ?? []),
878
878
  ];
879
- const member = members.find((candidate) => zteamMemberAgentId(candidate) === zagentId || candidate.alias === alias);
879
+ const member = members.find((candidate) => zteamMemberAgentId(candidate) === zagentId || zpeerAliasesEquivalent(candidate.alias, alias));
880
880
  if (!member) return false;
881
881
  if (!policyAllowsZpeerContact(member.communicationPolicy, roomId, alias ?? member.alias)) return false;
882
882
  const rooms = zteamMemberRooms(member, team.defaultRoom);