zob-harness 0.14.0 → 0.15.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.
@@ -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
  }
@@ -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
  }
@@ -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;
@@ -75,6 +81,9 @@ export interface ZpeerSendOptions {
75
81
  priority?: ZpeerInterruptPriority;
76
82
  interruptMode?: ZpeerInterruptMode;
77
83
  interruptReasonHash?: string;
84
+ requireResponse?: boolean;
85
+ responseTimeoutMs?: number;
86
+ maxReinjects?: number;
78
87
  onFeedback?: (feedback: ZpeerSendFeedback) => void;
79
88
  // WS-ZH4: optional best-effort fallback delivery seam. When local-socket delivery is
80
89
  // blocked (0 live candidates), the app may inject a fallback (e.g. tmux send-keys to
@@ -425,6 +434,12 @@ function appendZpeerPeerRecords(repoRoot: string, record: {
425
434
  interruptMode?: ZpeerInterruptMode;
426
435
  interruptStatus?: ZpeerInterruptStatus;
427
436
  interruptReasonHash?: string;
437
+ requireResponse?: boolean;
438
+ responseRequiredBy?: string;
439
+ responseTimeoutMs?: number;
440
+ maxReinjects?: number;
441
+ responseReceived?: boolean;
442
+ deliveryStatus?: "delivered" | "blocked" | "stale" | "offline" | "transport_error";
428
443
  deliveryMethod?: "local_socket" | "tmux_sendkeys";
429
444
  fallbackDelivery?: boolean;
430
445
  }): void {
@@ -444,6 +459,12 @@ function appendZpeerPeerRecords(repoRoot: string, record: {
444
459
  interruptMode: record.interruptMode,
445
460
  interruptStatus: record.interruptStatus,
446
461
  interruptReasonHash: record.interruptReasonHash,
462
+ requireResponse: record.requireResponse,
463
+ responseRequiredBy: record.responseRequiredBy,
464
+ responseTimeoutMs: record.responseTimeoutMs,
465
+ maxReinjects: record.maxReinjects,
466
+ responseReceived: record.responseReceived,
467
+ deliveryStatus: record.deliveryStatus,
447
468
  deliveryMethod: record.deliveryMethod,
448
469
  fallbackDelivery: record.fallbackDelivery,
449
470
  localOnly: true,
@@ -495,6 +516,9 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
495
516
  const interruptMode = options.interruptMode ?? (priority === "force" ? "abort" : priority === "urgent" ? "steer" : "none");
496
517
  const interruptReasonHash = options.interruptReasonHash;
497
518
  const interruptRequested = priority !== "normal" || interruptMode !== "none";
519
+ const requireResponse = options.requireResponse === true;
520
+ const responseTimeoutMs = Math.max(1_000, Math.min(30 * 60 * 1000, Math.floor(options.responseTimeoutMs ?? (mode === "long" ? 30 * 60 * 1000 : 10 * 60 * 1000))));
521
+ const maxReinjects = Math.max(0, Math.min(3, Math.floor(options.maxReinjects ?? 1)));
498
522
  const emitFeedback = (kind: ZpeerSendFeedback["kind"], result: ZpeerSendResult): void => {
499
523
  options.onFeedback?.({ kind, result });
500
524
  };
@@ -514,10 +538,16 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
514
538
  interruptMode: result.interruptMode ?? interruptMode,
515
539
  interruptStatus: result.interruptStatus,
516
540
  interruptReasonHash: result.interruptReasonHash ?? interruptReasonHash,
541
+ requireResponse: result.requireResponse,
542
+ responseRequiredBy: result.responseRequiredBy,
543
+ responseTimeoutMs: result.responseTimeoutMs,
544
+ maxReinjects: result.maxReinjects,
545
+ responseReceived: result.responseReceived,
546
+ deliveryStatus: result.deliveryStatus,
517
547
  deliveryMethod: result.deliveryMethod,
518
548
  fallbackDelivery: result.fallback_delivery,
519
549
  });
520
- return { roomId, priority, interruptMode, interruptReasonHash, ...result };
550
+ return { roomId, priority, interruptMode, interruptReasonHash, requireResponse: requireResponse || undefined, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined, ...result };
521
551
  };
522
552
 
523
553
  if (priority === "force" && !interruptReasonHash) return finish("attempt", { status: "blocked", reason: "force interrupt requires reason hash", targetAlias: targetAlias ?? undefined, taskHash, interruptStatus: "force_blocked", bodyStored: false });
@@ -578,7 +608,8 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
578
608
  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
609
 
580
610
  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);
611
+ const responseRequiredBy = requireResponse ? new Date(Date.now() + responseTimeoutMs).toISOString() : undefined;
612
+ 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
613
  const liveEnvelope = buildZobLiveEnvelope({
583
614
  type: "prompt",
584
615
  msgId,
@@ -594,6 +625,10 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
594
625
  interruptRequested,
595
626
  interruptMode,
596
627
  interruptReasonHash,
628
+ requireResponse: requireResponse || undefined,
629
+ responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined,
630
+ responseRequiredBy,
631
+ maxReinjects: requireResponse ? maxReinjects : undefined,
597
632
  });
598
633
  try {
599
634
  const ack = await sendZobLocalEnvelope(target.peer.endpoint, liveEnvelope, { timeoutMs: 5_000 });
@@ -601,27 +636,32 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
601
636
  const ackInterruptStatus = ack.interruptStatus;
602
637
  appendZpeerPeerRecords(repoRoot, { event: "ack", status: "delivered", roomId, msgId, senderAlias, targetAlias, taskHash, priority, interruptMode, interruptStatus: ackInterruptStatus, interruptReasonHash, peerCount: 1 });
603
638
  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);
639
+ if (mode === "async" && !requireResponse) {
640
+ const waiting = finish("terminal", { status: "waiting", reason: "delivered locally; awaiting async reply", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, deliveryStatus: "delivered", bodyStored: false }, 1);
606
641
  emitFeedback("waiting", waiting);
607
642
  return waiting;
608
643
  }
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 });
644
+ 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 });
645
+ 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
646
  const reply = await awaitReply(msgId);
612
647
  const replyEnvelope = reply["envelope"];
613
648
  if (reply.status === "completed") {
614
649
  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);
650
+ 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
651
  emitFeedback("reply", result);
617
652
  return result;
618
653
  }
654
+ if (reply.status === "required_response_expired") {
655
+ 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);
656
+ emitFeedback("expired", result);
657
+ return result;
658
+ }
619
659
  if (reply.status === "timeout") {
620
- const result = finish("terminal", { status: "timeout", reason: "await response timed out", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, bodyStored: false }, 1);
660
+ const result = finish("terminal", { status: "timeout", reason: "await response timed out", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, deliveryStatus: "delivered", bodyStored: false }, 1);
621
661
  emitFeedback(mode === "long" ? "expired" : "timeout", result);
622
662
  return result;
623
663
  }
624
- const result = finish("terminal", { status: "error", reason: replyEnvelope?.errorHash ?? "peer response error", msgId, targetAlias, taskHash, interruptStatus: ackInterruptStatus, bodyStored: false }, 1);
664
+ 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
665
  emitFeedback("error", result);
626
666
  return result;
627
667
  } catch (error) {
@@ -10,6 +10,8 @@ import { sha256 } from "../../core/utils/hashing.js";
10
10
  import { writeZpeerLocalProfileFromPeer } from "../../domains/coms/coms-v2/zpeer-profile.js";
11
11
  import { buildZpeerRoomSummary, changeZpeerAlias, changeZpeerRoom, clearZpeerRoom, joinZpeerRoom, leaveZpeerRoom, peerAliasInRoom, refreshZpeerSelf, safeZpeerAlias, safeZpeerRoomId, sendZpeerPrompt, useZpeerRoom, zpeerMembershipsForPeer, type ZpeerSendMode } from "../../domains/coms/coms-v2/zpeer.js";
12
12
  import type { ZpeerInterruptMode, ZpeerInterruptPriority, ZpeerInterruptStatus } from "../../domains/coms/coms-v2/envelope.js";
13
+ import { sendZobLocalEnvelope } from "../../domains/coms/coms-v2/local-transport.js";
14
+ import { buildZobLiveResponseEnvelope } from "../../domains/coms/coms-v2/response-capture.js";
13
15
  import { readZobLiveRegistryAllProjectsSnapshot } from "../../domains/coms/coms-v2/registry.js";
14
16
  import { loadActiveZagentScopedMode } from "../events.js";
15
17
  import { resolveRuleProfile } from "../../domains/governance/rules.js";
@@ -1412,7 +1414,7 @@ export function registerZliveCommands(pi: ExtensionAPI, state: HarnessRuntimeSta
1412
1414
  });
1413
1415
 
1414
1416
  pi.registerCommand("zpeer", {
1415
- description: "Room-scoped local peer sessions: /zpeer, /zpeer name <alias>, /zpeer room <roomId>, /zpeer @alias <prompt>, /zpeer urgent @alias <prompt>, /zpeer force @alias --reason <reason> <prompt>",
1417
+ description: "Room-scoped local peer sessions: /zpeer, /zpeer name <alias>, /zpeer room <roomId>, /zpeer @alias <prompt>, /zpeer reply <msgId> <response>, /zpeer --require-response @alias <prompt>, /zpeer urgent @alias <prompt>, /zpeer force @alias --reason <reason> <prompt>",
1416
1418
  handler: async (args, ctx) => {
1417
1419
  if (!state.zobLive.peerCard) {
1418
1420
  ctx.ui.notify("/zpeer unavailable: current session has not registered a local peer endpoint yet", "warning");
@@ -1539,9 +1541,50 @@ export function registerZliveCommands(pi: ExtensionAPI, state: HarnessRuntimeSta
1539
1541
  ctx.ui.notify(`zpeer left ${parts[1]}; active=${result.peer.zpeerRoomId}`, "info");
1540
1542
  return;
1541
1543
  }
1542
- const sendModeFromParts = (inputParts: string[]): { mode: ZpeerSendMode; priority: ZpeerInterruptPriority; interruptMode: ZpeerInterruptMode; interruptReasonHash?: string; reasonHash?: string; aliasToken?: string; bodyParts: string[]; error?: string } => {
1544
+ if (verb === "reply") {
1545
+ const msgId = parts[1]?.trim();
1546
+ const responseText = parts.slice(2).join(" ").trim();
1547
+ const outputHash = responseText ? sha256(responseText) : undefined;
1548
+ const inbound = msgId ? state.zobLive.inboundByMsgId?.[msgId] : undefined;
1549
+ const block = !msgId ? "msgId is required" : !responseText ? "response text is required" : !inbound ? "no active inbound ZPeer message for msgId" : inbound.responseSent || inbound.requiredResponseStatus === "replied" ? "ZPeer msgId already answered" : inbound.requiredResponseStatus === "expired" ? "ZPeer msgId required response already expired" : !inbound.envelope.replyEndpoint ? "ZPeer inbound msgId has no reply endpoint" : undefined;
1550
+ if (block) {
1551
+ pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-command.v1", action: "reply_blocked", status: "blocked", reasonHash: sha256(block), msgId, outputHash, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
1552
+ ctx.ui.notify(`/zpeer reply blocked: ${block}`, "warning");
1553
+ return;
1554
+ }
1555
+ if (!inbound || !inbound.envelope.replyEndpoint) {
1556
+ ctx.ui.notify("/zpeer reply blocked: invalid reply state", "warning");
1557
+ return;
1558
+ }
1559
+ const replyEndpoint = inbound.envelope.replyEndpoint;
1560
+ try {
1561
+ const responseEnvelope = { ...buildZobLiveResponseEnvelope(inbound.envelope, responseText, inbound.envelope.artifactRefs, inbound.envelope.artifactHashes), replyToMsgId: inbound.envelope.msgId, responseHash: outputHash };
1562
+ const ack = await sendZobLocalEnvelope(replyEndpoint, responseEnvelope, { timeoutMs: 5_000 });
1563
+ if (ack.type !== "ack") throw new Error(`expected ack, got ${ack.type}`);
1564
+ if (inbound.watchdogTimer) clearTimeout(inbound.watchdogTimer);
1565
+ inbound.responseSent = true;
1566
+ inbound.requiredResponseStatus = "replied";
1567
+ if (state.zobLive.inboundByMsgId) delete state.zobLive.inboundByMsgId[inbound.envelope.msgId];
1568
+ if (state.zobLive.inbound?.envelope.msgId === inbound.envelope.msgId) state.zobLive.inbound = { ...state.zobLive.inbound, responseSent: true };
1569
+ state.zobLive.activeInboundMsgId = undefined;
1570
+ state.zobLive.inboundQueue = (state.zobLive.inboundQueue ?? []).filter((candidate) => candidate !== inbound.envelope.msgId);
1571
+ const roomId = inbound.envelope.runId?.startsWith("zpeer:") ? inbound.envelope.runId.slice("zpeer:".length) : undefined;
1572
+ emitZpeerEvent({ kind: "response_sent", roomId, fromAlias: inbound.envelope.receiver, toAlias: inbound.envelope.sender, status: "response_sent", msgId: inbound.envelope.msgId, taskHash: inbound.envelope.taskHash, outputHash, priority: inbound.priority, interruptMode: inbound.interruptMode });
1573
+ pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-command.v1", action: "reply", status: "response_sent", msgId: inbound.envelope.msgId, taskHash: inbound.envelope.taskHash, outputHash, priority: inbound.priority, interruptMode: inbound.interruptMode, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
1574
+ renderHarnessWidget(pi, state, ctx);
1575
+ ctx.ui.notify(`zpeer reply sent msgId=${inbound.envelope.msgId} outputHash=${outputHash}`, "info");
1576
+ } catch (error) {
1577
+ const reason = error instanceof Error ? error.message : String(error);
1578
+ pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-command.v1", action: "reply_error", status: "error", reasonHash: sha256(reason), msgId, outputHash, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
1579
+ ctx.ui.notify(`/zpeer reply error: ${reason}`, "warning");
1580
+ }
1581
+ return;
1582
+ }
1583
+ const sendModeFromParts = (inputParts: string[]): { mode: ZpeerSendMode; priority: ZpeerInterruptPriority; interruptMode: ZpeerInterruptMode; interruptReasonHash?: string; reasonHash?: string; requireResponse?: boolean; maxReinjects?: number; aliasToken?: string; bodyParts: string[]; error?: string } => {
1543
1584
  let mode: ZpeerSendMode = inputParts.includes("--async") ? "async" : inputParts.includes("--long") ? "long" : "await";
1544
1585
  let priority: ZpeerInterruptPriority = "normal";
1586
+ let requireResponse = false;
1587
+ let maxReinjects = 1;
1545
1588
  let aliasToken: string | undefined;
1546
1589
  let reason: string | undefined;
1547
1590
  const bodyParts: string[] = [];
@@ -1574,6 +1617,23 @@ export function registerZliveCommands(pi: ExtensionAPI, state: HarnessRuntimeSta
1574
1617
  priority = "force";
1575
1618
  continue;
1576
1619
  }
1620
+ if (lower === "--require-response") {
1621
+ requireResponse = true;
1622
+ continue;
1623
+ }
1624
+ if (lower === "--max-reinjects") {
1625
+ const parsed = Number(inputParts[index + 1]);
1626
+ if (!Number.isFinite(parsed)) return { mode, priority, interruptMode: "none", aliasToken, bodyParts, error: "--max-reinjects requires a number" };
1627
+ maxReinjects = Math.max(0, Math.min(3, Math.floor(parsed)));
1628
+ index += 1;
1629
+ continue;
1630
+ }
1631
+ if (lower.startsWith("--max-reinjects=")) {
1632
+ const parsed = Number(part.slice("--max-reinjects=".length));
1633
+ if (!Number.isFinite(parsed)) return { mode, priority, interruptMode: "none", aliasToken, bodyParts, error: "--max-reinjects requires a number" };
1634
+ maxReinjects = Math.max(0, Math.min(3, Math.floor(parsed)));
1635
+ continue;
1636
+ }
1577
1637
  if (lower === "--reason") {
1578
1638
  reason = inputParts[index + 1];
1579
1639
  index += 1;
@@ -1591,7 +1651,7 @@ export function registerZliveCommands(pi: ExtensionAPI, state: HarnessRuntimeSta
1591
1651
  }
1592
1652
  const interruptMode: ZpeerInterruptMode = priority === "force" ? "abort" : priority === "urgent" ? "steer" : "none";
1593
1653
  if (priority === "force" && !reason?.trim()) return { mode, priority, interruptMode, aliasToken, bodyParts, error: "--reason is required for force" };
1594
- return { mode, priority, interruptMode, interruptReasonHash: reason?.trim() ? sha256(reason) : undefined, reasonHash: reason?.trim() ? sha256(reason) : undefined, aliasToken, bodyParts };
1654
+ return { mode, priority, interruptMode, interruptReasonHash: reason?.trim() ? sha256(reason) : undefined, reasonHash: reason?.trim() ? sha256(reason) : undefined, requireResponse, maxReinjects, aliasToken, bodyParts };
1595
1655
  };
1596
1656
  const explicitRoomId = verb === "in" ? parts[1] : undefined;
1597
1657
  const sendParts = explicitRoomId ? parts.slice(2) : parts;
@@ -1611,19 +1671,22 @@ export function registerZliveCommands(pi: ExtensionAPI, state: HarnessRuntimeSta
1611
1671
  }
1612
1672
  if (sendMode.mode !== "async") emitZpeerEvent({ kind: "attempt", roomId: eventRoomId, fromAlias: eventFromAlias, toAlias: targetAlias, status: "attempt", taskHash: transientPrompt.trim() ? sha256(transientPrompt) : undefined, priority: sendMode.priority, interruptMode: sendMode.interruptMode });
1613
1673
  let feedbackEmittedTerminal = false;
1614
- const result = await sendZpeerPrompt(ctx.cwd, state.zobLive.peerCard, targetAlias, transientPrompt, (msgId) => state.zobLive.pendingReplies.wait(msgId, replyTimeoutMs), {
1674
+ const result = await sendZpeerPrompt(ctx.cwd, state.zobLive.peerCard, targetAlias, transientPrompt, (msgId) => state.zobLive.pendingReplies.wait(msgId, replyTimeoutMs, { requireResponse: sendMode.requireResponse === true }), {
1615
1675
  mode: sendMode.mode,
1616
1676
  roomId: explicitRoomId,
1617
1677
  priority: sendMode.priority,
1618
1678
  interruptMode: sendMode.interruptMode,
1619
1679
  interruptReasonHash: sendMode.interruptReasonHash,
1680
+ requireResponse: sendMode.requireResponse === true,
1681
+ responseTimeoutMs: replyTimeoutMs,
1682
+ maxReinjects: sendMode.maxReinjects,
1620
1683
  onFeedback: (feedback) => {
1621
- feedbackEmittedTerminal = feedback.result.status === "waiting" || feedback.result.status === "reply" || feedback.result.status === "completed" || feedback.result.status === "blocked" || feedback.result.status === "error" || feedback.result.status === "timeout" || feedback.result.status === "expired";
1684
+ feedbackEmittedTerminal = feedback.result.status === "waiting" || feedback.result.status === "reply" || feedback.result.status === "completed" || feedback.result.status === "blocked" || feedback.result.status === "error" || feedback.result.status === "timeout" || feedback.result.status === "expired" || feedback.result.status === "required_response_expired";
1622
1685
  const feedbackRoomId = feedback.result.roomId ?? eventRoomId;
1623
1686
  emitZpeerEvent({ kind: feedback.kind, roomId: feedbackRoomId, fromAlias: state.zobLive.peerCard ? peerAliasInRoom(state.zobLive.peerCard, feedbackRoomId) ?? eventFromAlias : eventFromAlias, toAlias: feedback.result.targetAlias ?? targetAlias, status: feedback.result.status, reason: feedback.result.reason, msgId: feedback.result.msgId, taskHash: feedback.result.taskHash, outputHash: feedback.result.outputHash, priority: feedback.result.priority ?? sendMode.priority, interruptMode: feedback.result.interruptMode ?? sendMode.interruptMode, interruptStatus: feedback.result.interruptStatus });
1624
1687
  },
1625
1688
  });
1626
- const terminalKind = result.status === "reply" || result.status === "completed" ? "reply" : result.status === "blocked" ? "blocked" : result.status === "timeout" ? "timeout" : result.status === "expired" ? "expired" : result.status === "error" ? "error" : result.status === "waiting" ? "waiting" : "delivered";
1689
+ const terminalKind = result.status === "reply" || result.status === "completed" ? "reply" : result.status === "blocked" ? "blocked" : result.status === "timeout" ? "timeout" : result.status === "expired" ? "expired" : result.status === "required_response_expired" ? "required_response_expired" : result.status === "error" ? "error" : result.status === "waiting" ? "waiting" : "delivered";
1627
1690
  if (!feedbackEmittedTerminal) {
1628
1691
  const resultRoomId = result.roomId ?? eventRoomId;
1629
1692
  emitZpeerEvent({ kind: terminalKind, roomId: resultRoomId, fromAlias: peerAliasInRoom(state.zobLive.peerCard, resultRoomId) ?? eventFromAlias, toAlias: result.targetAlias ?? targetAlias, status: result.status, reason: result.reason, msgId: result.msgId, taskHash: result.taskHash, outputHash: result.outputHash, priority: result.priority ?? sendMode.priority, interruptMode: result.interruptMode ?? sendMode.interruptMode, interruptStatus: result.interruptStatus });
@@ -1643,6 +1706,12 @@ export function registerZliveCommands(pi: ExtensionAPI, state: HarnessRuntimeSta
1643
1706
  interruptStatus: result.interruptStatus,
1644
1707
  interruptReasonHash: result.interruptReasonHash ?? sendMode.interruptReasonHash,
1645
1708
  reasonInputHash: sendMode.reasonHash,
1709
+ requireResponse: sendMode.requireResponse === true || undefined,
1710
+ responseRequiredBy: result.responseRequiredBy,
1711
+ responseTimeoutMs: result.responseTimeoutMs,
1712
+ maxReinjects: result.maxReinjects,
1713
+ responseReceived: result.responseReceived,
1714
+ deliveryStatus: result.deliveryStatus,
1646
1715
  localOnly: true,
1647
1716
  networkEnabled: false,
1648
1717
  bodyStored: false,
@@ -1667,7 +1736,7 @@ export function registerZliveCommands(pi: ExtensionAPI, state: HarnessRuntimeSta
1667
1736
  }
1668
1737
  return;
1669
1738
  }
1670
- ctx.ui.notify("Usage: /zpeer | /zpeer rooms | /zpeer clear <roomId> | /zpeer join <roomId> [as <alias>] | /zpeer use <roomId> | /zpeer leave <roomId> | /zpeer @alias <prompt> | /zpeer urgent @alias <prompt> | /zpeer force @alias --reason <reason> <prompt> | /zpeer in <roomId> urgent|force @alias <prompt>", "warning");
1739
+ ctx.ui.notify("Usage: /zpeer | /zpeer rooms | /zpeer clear <roomId> | /zpeer join <roomId> [as <alias>] | /zpeer use <roomId> | /zpeer leave <roomId> | /zpeer reply <msgId> <response> | /zpeer @alias <prompt> | /zpeer --require-response @alias <prompt> | /zpeer urgent @alias <prompt> | /zpeer force @alias --reason <reason> <prompt> | /zpeer in <roomId> urgent|force @alias <prompt>", "warning");
1671
1740
  },
1672
1741
  });
1673
1742
  }
@@ -92,7 +92,8 @@ function recordZpeerInbound(state: HarnessRuntimeState, envelope: ZobLiveEnvelop
92
92
  ensureZpeerInboundState(state);
93
93
  const priority = zpeerEnvelopePriority(envelope);
94
94
  const interruptMode = zpeerEnvelopeInterruptMode(envelope, priority);
95
- const inbound: ZobInboundZpeerMessage = { envelope, receivedAt: new Date().toISOString(), responseSent: false, priority, interruptMode, repoRoot };
95
+ const requireResponse = envelope.requireResponse === true;
96
+ const inbound: ZobInboundZpeerMessage = { envelope, receivedAt: new Date().toISOString(), responseSent: false, priority, interruptMode, repoRoot, requireResponse, responseRequiredBy: envelope.responseRequiredBy, responseTimeoutMs: envelope.responseTimeoutMs, reinjectCount: 0, maxReinjects: envelope.maxReinjects ?? 1, requiredResponseStatus: requireResponse ? "owed" : undefined };
96
97
  state.zobLive.inboundByMsgId![envelope.msgId] = inbound;
97
98
  if (!state.zobLive.inboundQueue!.includes(envelope.msgId)) state.zobLive.inboundQueue!.push(envelope.msgId);
98
99
  state.zobLive.inbound = { envelope, receivedAt: inbound.receivedAt, responseSent: false, repoRoot };
@@ -122,14 +123,80 @@ function activeZpeerInboundForResponse(state: HarnessRuntimeState): ZobInboundZp
122
123
  return undefined;
123
124
  }
124
125
 
126
+ function clearZpeerInboundWatchdog(inbound: ZobInboundZpeerMessage | undefined): void {
127
+ if (inbound?.watchdogTimer) clearTimeout(inbound.watchdogTimer);
128
+ if (inbound) inbound.watchdogTimer = undefined;
129
+ }
130
+
125
131
  function finishZpeerInboundResponse(state: HarnessRuntimeState, msgId: string, responseSent: boolean): void {
126
132
  const inbound = state.zobLive.inboundByMsgId?.[msgId];
127
- if (inbound) inbound.responseSent = responseSent;
133
+ clearZpeerInboundWatchdog(inbound);
134
+ if (inbound) {
135
+ inbound.responseSent = responseSent;
136
+ if (inbound.requireResponse && responseSent) inbound.requiredResponseStatus = "replied";
137
+ }
128
138
  if (state.zobLive.inbound?.envelope.msgId === msgId) state.zobLive.inbound = { ...state.zobLive.inbound, responseSent };
129
139
  state.zobLive.activeInboundMsgId = undefined;
130
140
  state.zobLive.inboundQueue = (state.zobLive.inboundQueue ?? []).filter((candidate) => candidate !== msgId);
131
141
  }
132
142
 
143
+ function boundedRequiredResponseTimeoutMs(envelope: ZobLiveEnvelope): number {
144
+ return Math.max(1_000, Math.min(30 * 60 * 1000, Math.floor(envelope.responseTimeoutMs ?? 10 * 60 * 1000)));
145
+ }
146
+
147
+ function scheduleZpeerRequiredResponseWatchdog(pi: ExtensionAPI, state: HarnessRuntimeState, inbound: ZobInboundZpeerMessage): void {
148
+ if (inbound.requireResponse !== true || inbound.responseSent) return;
149
+ clearZpeerInboundWatchdog(inbound);
150
+ const timeoutMs = boundedRequiredResponseTimeoutMs(inbound.envelope);
151
+ const maxReinjects = Math.max(0, Math.min(3, Math.floor(inbound.maxReinjects ?? 1)));
152
+ const deadlineMs = inbound.responseRequiredBy ? Date.parse(inbound.responseRequiredBy) : Date.now() + timeoutMs;
153
+ const remainingMs = Number.isFinite(deadlineMs) ? Math.max(25, deadlineMs - Date.now()) : timeoutMs;
154
+ const reminderSliceMs = Math.max(25, Math.floor(timeoutMs / Math.max(1, maxReinjects + 1)));
155
+ const dueMs = Math.min(remainingMs, reminderSliceMs);
156
+ const timer = setTimeout(() => {
157
+ const current = state.zobLive.inboundByMsgId?.[inbound.envelope.msgId];
158
+ if (!current || current.responseSent || current.requiredResponseStatus === "replied" || current.requiredResponseStatus === "cancelled") return;
159
+ const reinjectCount = current.reinjectCount ?? 0;
160
+ const roomId = current.envelope.runId?.startsWith("zpeer:") ? current.envelope.runId.slice("zpeer:".length) : undefined;
161
+ if (reinjectCount < maxReinjects) {
162
+ current.reinjectCount = reinjectCount + 1;
163
+ current.requiredResponseStatus = "reinjecting";
164
+ setZpeerLastEvent(state, { kind: "required_response_reinject", roomId, fromAlias: current.envelope.sender, toAlias: current.envelope.receiver, status: "required_response_reinject", msgId: current.envelope.msgId, taskHash: current.envelope.taskHash, priority: current.priority, interruptMode: current.priority === "normal" ? "none" : "steer", interruptStatus: current.envelope.interruptStatus });
165
+ pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-required-response-watchdog.v1", action: "reinject", status: "required_response_reinject", msgId: current.envelope.msgId, roomIdHash: roomId ? sha256(roomId) : undefined, senderAliasHash: current.envelope.sender ? sha256(current.envelope.sender) : undefined, receiverAliasHash: current.envelope.receiver ? sha256(current.envelope.receiver) : undefined, taskHash: current.envelope.taskHash, reinjectCount: current.reinjectCount, maxReinjects, priority: current.priority, interruptMode: current.priority === "normal" ? "none" : "steer", forceAbortRepeated: false, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
166
+ void pi.sendMessage({
167
+ customType: "zob-coms-inbound",
168
+ content: [
169
+ "ZPEER_RESPONSE_REQUIRED.v1",
170
+ "",
171
+ "You have an unanswered ZPeer message waiting. Process it now or state the blocker.",
172
+ `msgId: ${current.envelope.msgId}`,
173
+ `from: @${current.envelope.sender ?? "?"}`,
174
+ roomId ? `room: ${roomId}` : undefined,
175
+ `reinject: ${current.reinjectCount}/${maxReinjects}`,
176
+ `priority: ${current.priority}`,
177
+ "reply binding: active; your assistant response to this turn will be sent back for this msgId.",
178
+ "",
179
+ "Original transient message:",
180
+ current.envelope.transientPrompt ?? "",
181
+ ].filter((line): line is string => line !== undefined).join("\n"),
182
+ display: false,
183
+ details: { kind: "zpeer-required-response-reminder", msgId: current.envelope.msgId, runId: current.envelope.runId, sender: current.envelope.sender, receiver: current.envelope.receiver, taskHash: current.envelope.taskHash, requireResponse: true, reinjectCount: current.reinjectCount, maxReinjects, priority: current.priority, interruptMode: current.priority === "normal" ? "none" : "steer", forceAbortRepeated: false, bodyStored: false, localOnly: true, networkEnabled: false },
184
+ }, { triggerTurn: true, deliverAs: current.priority === "normal" ? "followUp" : "steer" });
185
+ scheduleZpeerRequiredResponseWatchdog(pi, state, current);
186
+ return;
187
+ }
188
+ current.requiredResponseStatus = "expired";
189
+ setZpeerLastEvent(state, { kind: "required_response_expired", roomId, fromAlias: current.envelope.sender, toAlias: current.envelope.receiver, status: "required_response_expired", msgId: current.envelope.msgId, taskHash: current.envelope.taskHash, priority: current.priority, interruptMode: current.interruptMode });
190
+ pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-required-response-watchdog.v1", action: "expire", status: "required_response_expired", msgId: current.envelope.msgId, roomIdHash: roomId ? sha256(roomId) : undefined, senderAliasHash: current.envelope.sender ? sha256(current.envelope.sender) : undefined, receiverAliasHash: current.envelope.receiver ? sha256(current.envelope.receiver) : undefined, taskHash: current.envelope.taskHash, reinjectCount: current.reinjectCount ?? 0, maxReinjects, responseRequiredBy: current.responseRequiredBy, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
191
+ if (current.envelope.replyEndpoint) {
192
+ void sendZobLocalEnvelope(current.envelope.replyEndpoint, buildZobLiveErrorEnvelope(current.envelope, "required ZPeer response expired", "zpeer_required_response_expired"), { timeoutMs: 5_000 }).catch(() => undefined);
193
+ }
194
+ finishZpeerInboundResponse(state, current.envelope.msgId, false);
195
+ }, dueMs);
196
+ timer.unref?.();
197
+ inbound.watchdogTimer = timer;
198
+ }
199
+
133
200
  function forceAbortAllowedForCurrentState(ctx: ExtensionContext): boolean {
134
201
  return typeof ctx.abort === "function" && !ctx.isIdle();
135
202
  }
@@ -170,6 +237,7 @@ function handleInboundZpeerPrompt(pi: ExtensionAPI, state: HarnessRuntimeState,
170
237
  pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-inbound-interrupt.v1", status: interruptStatus ?? "prompt_received", priority, interruptMode, interruptStatus, msgId: envelope.msgId, roomIdHash: roomId ? sha256(roomId) : undefined, senderAliasHash: envelope.sender ? sha256(envelope.sender) : undefined, receiverAliasHash: envelope.receiver ? sha256(envelope.receiver) : undefined, taskHash: envelope.taskHash, interruptReasonHash: envelope.interruptReasonHash, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
171
238
  if (interruptStatus === "force_blocked") return interruptStatus;
172
239
  const inbound = recordZpeerInbound(state, envelope, repoRoot);
240
+ scheduleZpeerRequiredResponseWatchdog(pi, state, inbound);
173
241
  const deliverAs = priority === "normal" ? "followUp" : "steer";
174
242
  void pi.sendMessage({
175
243
  customType: "zob-zpeer-event",
@@ -181,7 +249,7 @@ function handleInboundZpeerPrompt(pi: ExtensionAPI, state: HarnessRuntimeState,
181
249
  customType: "zob-coms-inbound",
182
250
  content: envelope.transientPrompt ?? "",
183
251
  display: false,
184
- details: { kind: "zob-coms-inbound", msgId: envelope.msgId, runId: envelope.runId, sender: envelope.sender, receiver: envelope.receiver, taskHash: envelope.taskHash, priority: inbound.priority, interruptMode: inbound.interruptMode, interruptStatus, bodyStored: false, localOnly: true, networkEnabled: false },
252
+ details: { kind: "zob-coms-inbound", msgId: envelope.msgId, runId: envelope.runId, sender: envelope.sender, receiver: envelope.receiver, taskHash: envelope.taskHash, priority: inbound.priority, interruptMode: inbound.interruptMode, interruptStatus, requireResponse: inbound.requireResponse || undefined, responseRequiredBy: inbound.responseRequiredBy, maxReinjects: inbound.maxReinjects, bodyStored: false, localOnly: true, networkEnabled: false },
185
253
  }, { triggerTurn: true, deliverAs });
186
254
  return interruptStatus;
187
255
  }
@@ -699,7 +767,29 @@ async function startOrRefreshZobLiveRuntime(pi: ExtensionAPI, state: HarnessRunt
699
767
  const endpoint = makeZobLocalEndpoint(basePeer.sessionId);
700
768
  const server = await bindZobLocalEndpoint(endpoint, async (envelope) => {
701
769
  if (envelope.type === "ping") return buildZobLivePongEnvelope(envelope);
770
+ if (envelope.type === "error") {
771
+ if (envelope.errorCode === "zpeer_required_response_expired") state.zobLive.pendingReplies.expire(envelope.msgId, envelope.errorHash);
772
+ else state.zobLive.pendingReplies.fail(envelope.msgId, envelope.errorHash ?? sha256(envelope.errorCode ?? "zpeer_error"));
773
+ clearPassivePeerWaitForResponse(state, { ...envelope, type: "response" });
774
+ setZpeerLastEvent(state, {
775
+ kind: envelope.errorCode === "zpeer_required_response_expired" ? "required_response_expired" : "error",
776
+ roomId: envelope.runId?.startsWith("zpeer:") ? envelope.runId.slice("zpeer:".length) : undefined,
777
+ fromAlias: envelope.sender,
778
+ toAlias: envelope.receiver,
779
+ status: envelope.errorCode === "zpeer_required_response_expired" ? "required_response_expired" : "error",
780
+ msgId: envelope.msgId,
781
+ taskHash: envelope.taskHash,
782
+ });
783
+ void pi.sendMessage({
784
+ customType: "zob-zpeer-event",
785
+ content: envelope.errorCode === "zpeer_required_response_expired" ? "ZPeer required response expired" : "ZPeer error received",
786
+ display: true,
787
+ details: { kind: state.zobLive.lastEvent?.kind, roomId: state.zobLive.lastEvent?.roomId, fromAlias: envelope.sender, toAlias: envelope.receiver, status: state.zobLive.lastEvent?.status, msgId: envelope.msgId, taskHash: envelope.taskHash, errorHash: envelope.errorHash, bodyStored: false, localOnly: true, networkEnabled: false },
788
+ }, { triggerTurn: false });
789
+ return buildZobLiveAckEnvelope(envelope);
790
+ }
702
791
  if (envelope.type === "response") {
792
+ if (envelope.replyToMsgId !== envelope.msgId) return buildZobLiveErrorEnvelope(envelope, "wrong or missing replyToMsgId for ZPeer response", "wrong_msgId");
703
793
  const completedActiveWait = state.zobLive.pendingReplies.complete(envelope.msgId, envelope);
704
794
  clearPassivePeerWaitForResponse(state, envelope);
705
795
  setZpeerLastEvent(state, {
@@ -970,6 +1060,7 @@ async function sendInboundZobLiveResponse(pi: ExtensionAPI, state: HarnessRuntim
970
1060
  const activeInbound = activeZpeerInboundForResponse(state);
971
1061
  const inbound = activeInbound ?? state.zobLive.inbound;
972
1062
  if (!inbound || inbound.responseSent || !inbound.envelope.replyEndpoint) return;
1063
+ if (activeInbound?.requiredResponseStatus === "expired") return;
973
1064
  if (state.zobLive.inboundByMsgId && !activeInbound) return;
974
1065
  const responseText = latestAssistantText(event);
975
1066
  if (!responseText.trim()) return;
@@ -994,7 +1085,7 @@ async function sendInboundZobLiveResponse(pi: ExtensionAPI, state: HarnessRuntim
994
1085
  }
995
1086
  const artifactRefs = responseCapture ? [...(inbound.envelope.artifactRefs ?? []), responseCapture.artifactRef] : inbound.envelope.artifactRefs;
996
1087
  const artifactHashes = responseCapture ? [...(inbound.envelope.artifactHashes ?? []), responseCapture.artifactHash] : inbound.envelope.artifactHashes;
997
- const responseEnvelope = buildZobLiveResponseEnvelope(inbound.envelope, responseText, artifactRefs, artifactHashes);
1088
+ const responseEnvelope = { ...buildZobLiveResponseEnvelope(inbound.envelope, responseText, artifactRefs, artifactHashes), replyToMsgId: inbound.envelope.msgId, responseHash: sha256(responseText) };
998
1089
  await sendZobLocalEnvelope(inbound.envelope.replyEndpoint, responseEnvelope, { timeoutMs: 5_000 });
999
1090
  finishZpeerInboundResponse(state, inbound.envelope.msgId, true);
1000
1091
  setZpeerLastEvent(state, {
@@ -1027,7 +1118,7 @@ export function registerHarnessEvents(pi: ExtensionAPI, state: HarnessRuntimeSta
1027
1118
  const taskHash = typeof details.taskHash === "string" ? details.taskHash : undefined;
1028
1119
  const outputHash = typeof details.outputHash === "string" ? details.outputHash : undefined;
1029
1120
  const route = fromAlias || toAlias ? `${fromAlias ? `@${fromAlias}` : "?"} → ${toAlias ? `@${toAlias}` : "?"}` : "room status";
1030
- const statusColor = status === "completed" || status === "sent" || status === "prompt_received" || status === "response_sent" ? "success" : status === "blocked" || status === "timeout" || status === "error" ? "warning" : "muted";
1121
+ const statusColor = status === "completed" || status === "sent" || status === "prompt_received" || status === "response_sent" ? "success" : status === "blocked" || status === "timeout" || status === "error" || status === "required_response_expired" ? "warning" : "muted";
1031
1122
  const line = [
1032
1123
  theme.fg("accent", "◆ ZPeer"),
1033
1124
  theme.fg("muted", kind),
@@ -359,7 +359,14 @@ const ZpeerAskParams = Type.Object({
359
359
  urgency: Type.Optional(StringEnum(["normal", "urgent", "force"] as const, { description: "ZPeer delivery priority. normal=follow-up, urgent=steer, force=controlled abort+steer when policy allows." })),
360
360
  force: Type.Optional(Type.Boolean({ description: "Alias for urgency=force. Requires reason and remains local/hash-only." })),
361
361
  interruptMode: Type.Optional(StringEnum(["none", "steer", "abort"] as const, { description: "Requested interrupt mode. Derived from urgency when omitted; invalid broadening is blocked by runtime." })),
362
- timeoutMs: Type.Optional(Type.Number({ description: "Bounded reply wait timeout for await/long modes. Ignored by async mode; capped by runtime." })),
362
+ timeoutMs: Type.Optional(Type.Number({ description: "Bounded reply wait timeout for await/long modes, and required-response expiration when requireResponse=true; capped by runtime." })),
363
+ requireResponse: Type.Optional(Type.Boolean({ description: "Opt in to a real msgId-correlated required response. Default false; when true the sender waits only for the exact msgId reply and expires explicitly." })),
364
+ maxReinjects: Type.Optional(Type.Number({ description: "Bounded receiver reminder budget for requireResponse. Default 1, capped 0..3." })),
365
+ });
366
+
367
+ const ZpeerReplyParams = Type.Object({
368
+ msgId: Type.String({ description: "ZPeer inbound msgId being answered. Must match an active unanswered inbound message." }),
369
+ message: Type.String({ description: "Transient reply body. Sent over local socket only; durable records store outputHash/metadata, not the raw message." }),
363
370
  });
364
371
 
365
372
  const ZobComsReadinessParams = Type.Object({
@@ -754,6 +761,7 @@ export {
754
761
  ZobComsSendParams,
755
762
  ZobComsStatusParams,
756
763
  ZpeerAskParams,
764
+ ZpeerReplyParams,
757
765
  ZcommitRunParams,
758
766
  ZteamHotAddParams,
759
767
  ZteamRemoveParams,
@@ -37,7 +37,7 @@ export interface DelegationMouseRuntimeState {
37
37
  }
38
38
 
39
39
  export interface ZobLiveLastEvent {
40
- kind: "status" | "attempt" | "delivered" | "waiting" | "reply" | "sent" | "completed" | "blocked" | "error" | "timeout" | "expired" | "inbound" | "response_sent" | "heartbeat" | "urgent_delivered" | "force_accepted" | "force_downgraded" | "force_blocked";
40
+ kind: "status" | "attempt" | "delivered" | "waiting" | "reply" | "sent" | "completed" | "blocked" | "error" | "timeout" | "expired" | "required_response_expired" | "required_response_reinject" | "inbound" | "response_sent" | "heartbeat" | "urgent_delivered" | "force_accepted" | "force_downgraded" | "force_blocked";
41
41
  roomId?: string;
42
42
  fromAlias?: string;
43
43
  toAlias?: string;
@@ -80,6 +80,13 @@ export interface ZobInboundZpeerMessage {
80
80
  priority: ZpeerInterruptPriority;
81
81
  interruptMode: ZpeerInterruptMode;
82
82
  repoRoot: string;
83
+ requireResponse?: boolean;
84
+ responseRequiredBy?: string;
85
+ responseTimeoutMs?: number;
86
+ reinjectCount?: number;
87
+ maxReinjects?: number;
88
+ requiredResponseStatus?: "owed" | "reinjecting" | "replied" | "expired" | "cancelled";
89
+ watchdogTimer?: ReturnType<typeof setTimeout>;
83
90
  }
84
91
 
85
92
  export interface ZobLiveRuntimeState {
@@ -4,6 +4,7 @@ import { readZobLiveRegistrySnapshot } from "../domains/coms/coms-v2/registry.js
4
4
  import { peerAliasInRoom, refreshZpeerSelf, safeZpeerAlias, safeZpeerRoomId, sendZpeerPrompt, type ZpeerSendMode, type ZpeerSendResult } from "../domains/coms/coms-v2/zpeer.js";
5
5
  import { buildZobLiveEnvelope, type ZpeerInterruptMode, type ZpeerInterruptPriority, type ZpeerInterruptStatus } from "../domains/coms/coms-v2/envelope.js";
6
6
  import { sendZobLocalEnvelope } from "../domains/coms/coms-v2/local-transport.js";
7
+ import { buildZobLiveResponseEnvelope } from "../domains/coms/coms-v2/response-capture.js";
7
8
  import { appendLiveDeliveredStatus, appendLiveErrorStatus, appendLiveSendRequestedRef } from "../domains/coms/coms-v2/ledger-bridge.js";
8
9
  import { writeZobComsRedactedCapture } from "../domains/coms/coms-v2/transcript-capture.js";
9
10
  import type { TeamDefinition } from "../types.js";
@@ -17,6 +18,7 @@ import {
17
18
  ZobComsSendParams,
18
19
  ZobComsStatusParams,
19
20
  ZpeerAskParams,
21
+ ZpeerReplyParams,
20
22
  } from "./schemas.js";
21
23
  import {
22
24
  ackZobComsMessage,
@@ -69,6 +71,13 @@ type ZpeerAskToolParams = {
69
71
  force?: boolean;
70
72
  interruptMode?: ZpeerInterruptMode;
71
73
  timeoutMs?: number;
74
+ requireResponse?: boolean;
75
+ maxReinjects?: number;
76
+ };
77
+
78
+ type ZpeerReplyToolParams = {
79
+ msgId: string;
80
+ message: string;
72
81
  };
73
82
 
74
83
  function boundedZpeerAskTimeoutMs(mode: ZpeerSendMode, raw: number | undefined): number {
@@ -107,8 +116,8 @@ function zpeerAskGuardBlock(state: HarnessRuntimeState, params: ZpeerAskToolPara
107
116
  return undefined;
108
117
  }
109
118
 
110
- function zpeerTerminalKind(status: ZpeerSendResult["status"]): "delivered" | "waiting" | "reply" | "blocked" | "error" | "timeout" | "expired" {
111
- return status === "reply" || status === "completed" ? "reply" : status === "blocked" ? "blocked" : status === "timeout" ? "timeout" : status === "expired" ? "expired" : status === "error" ? "error" : status === "waiting" ? "waiting" : "delivered";
119
+ function zpeerTerminalKind(status: ZpeerSendResult["status"]): "delivered" | "waiting" | "reply" | "blocked" | "error" | "timeout" | "expired" | "required_response_expired" {
120
+ return status === "reply" || status === "completed" ? "reply" : status === "blocked" ? "blocked" : status === "timeout" ? "timeout" : status === "expired" ? "expired" : status === "required_response_expired" ? "required_response_expired" : status === "error" ? "error" : status === "waiting" ? "waiting" : "delivered";
112
121
  }
113
122
 
114
123
  function updatePassivePeerWaitState(state: HarnessRuntimeState, result: ZpeerSendResult, fallback: { roomId: string; targetAlias: string }): void {
@@ -245,21 +254,26 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
245
254
  return { content: [{ type: "text", text: `zpeer_ask blocked: ${guardReason}` }], details: result };
246
255
  }
247
256
  const timeoutMs = boundedZpeerAskTimeoutMs(mode, params.timeoutMs);
257
+ const requireResponse = params.requireResponse === true;
258
+ const maxReinjects = Math.max(0, Math.min(3, Math.floor(params.maxReinjects ?? 1)));
248
259
  let feedbackEmittedTerminal = false;
249
- const result = await sendZpeerPrompt(ctx.cwd, self, targetAlias, params.message, (msgId) => state.zobLive.pendingReplies.wait(msgId, timeoutMs), {
260
+ const result = await sendZpeerPrompt(ctx.cwd, self, targetAlias, params.message, (msgId) => state.zobLive.pendingReplies.wait(msgId, timeoutMs, { requireResponse }), {
250
261
  mode,
251
262
  roomId: params.roomId,
252
263
  priority: interrupt.priority,
253
264
  interruptMode: interrupt.interruptMode,
254
265
  interruptReasonHash: interrupt.interruptReasonHash,
266
+ requireResponse,
267
+ responseTimeoutMs: timeoutMs,
268
+ maxReinjects,
255
269
  onFeedback: (feedback) => {
256
- feedbackEmittedTerminal = feedback.result.status === "waiting" || feedback.result.status === "reply" || feedback.result.status === "completed" || feedback.result.status === "blocked" || feedback.result.status === "error" || feedback.result.status === "timeout" || feedback.result.status === "expired";
270
+ feedbackEmittedTerminal = feedback.result.status === "waiting" || feedback.result.status === "reply" || feedback.result.status === "completed" || feedback.result.status === "blocked" || feedback.result.status === "error" || feedback.result.status === "timeout" || feedback.result.status === "expired" || feedback.result.status === "required_response_expired";
257
271
  emitZpeerAskEvent({ kind: feedback.kind, roomId: feedback.result.roomId, status: feedback.result.status, reason: feedback.result.reason, msgId: feedback.result.msgId, taskHash: feedback.result.taskHash, outputHash: feedback.result.outputHash, interruptStatus: feedback.result.interruptStatus });
258
272
  },
259
273
  });
260
274
  if (!feedbackEmittedTerminal) emitZpeerAskEvent({ kind: zpeerTerminalKind(result.status), roomId: result.roomId, status: result.status, reason: result.reason, msgId: result.msgId, taskHash: result.taskHash, outputHash: result.outputHash, interruptStatus: result.interruptStatus });
261
275
  updatePassivePeerWaitState(state, result, { roomId: requestedRoomId, targetAlias });
262
- pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-ask.v1", action: "agent_request", mode, status: result.status, priority: interrupt.priority, interruptMode: interrupt.interruptMode, interruptStatus: result.interruptStatus, reasonHash: result.reason ? sha256(result.reason) : undefined, msgId: result.msgId, targetAliasHash: result.targetAlias ? sha256(result.targetAlias) : sha256(targetAlias), roomIdHash: sha256(result.roomId ?? requestedRoomId), taskHash: result.taskHash, outputHash: result.outputHash, reasonInputHash: params.reason ? sha256(params.reason) : undefined, interruptReasonHash: interrupt.interruptReasonHash, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
276
+ pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-ask.v1", action: "agent_request", mode, status: result.status, priority: interrupt.priority, interruptMode: interrupt.interruptMode, interruptStatus: result.interruptStatus, reasonHash: result.reason ? sha256(result.reason) : undefined, msgId: result.msgId, targetAliasHash: result.targetAlias ? sha256(result.targetAlias) : sha256(targetAlias), roomIdHash: sha256(result.roomId ?? requestedRoomId), taskHash: result.taskHash, outputHash: result.outputHash, reasonInputHash: params.reason ? sha256(params.reason) : undefined, interruptReasonHash: interrupt.interruptReasonHash, requireResponse: requireResponse || undefined, responseRequiredBy: result.responseRequiredBy, responseTimeoutMs: result.responseTimeoutMs, maxReinjects: result.maxReinjects, responseReceived: result.responseReceived, deliveryStatus: result.deliveryStatus, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
263
277
  const ok = result.status === "reply" || result.status === "completed" || result.status === "waiting" || result.status === "delivered";
264
278
  const passiveWaitSuffix = result.status === "waiting" ? " · idle/passive wait: no follow-up turn queued; stop if no other action is actionable" : "";
265
279
  const transientReplyText = (result.status === "reply" || result.status === "completed") && result.transientResponse
@@ -270,6 +284,48 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
270
284
  },
271
285
  });
272
286
 
287
+ pi.registerTool({
288
+ name: "zpeer_reply",
289
+ label: "ZPeer Reply",
290
+ description: "Reply to an active inbound ZPeer message by msgId. Raw reply bodies are transient local-socket payloads only; durable metadata stores hashes/status only.",
291
+ promptSnippet: "Use zpeer_reply({ msgId, message }) when a ZPeer inbound requires an explicit msgId-bound answer after inspection; never reply to a different msgId.",
292
+ parameters: ZpeerReplyParams,
293
+ async execute(_toolCallId, params: ZpeerReplyToolParams, _signal, _onUpdate, ctx) {
294
+ const msgId = params.msgId?.trim();
295
+ const responseText = params.message ?? "";
296
+ const outputHash = responseText.trim() ? sha256(responseText) : undefined;
297
+ const inbound = msgId ? state?.zobLive.inboundByMsgId?.[msgId] : undefined;
298
+ const block = !state ? "zpeer runtime state unavailable" : !msgId ? "msgId is required" : !responseText.trim() ? "message is required" : !inbound ? "no active inbound ZPeer message for msgId" : inbound.responseSent || inbound.requiredResponseStatus === "replied" ? "ZPeer msgId already answered" : inbound.requiredResponseStatus === "expired" ? "ZPeer msgId required response already expired" : !inbound.envelope.replyEndpoint ? "ZPeer inbound msgId has no reply endpoint" : undefined;
299
+ if (block) {
300
+ pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-reply.v1", action: "reply_blocked", status: "blocked", reasonHash: sha256(block), msgId, outputHash, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
301
+ return { content: [{ type: "text", text: `zpeer_reply blocked: ${block}` }], details: { schema: "zob.zpeer-reply-result.v1", status: "blocked", reason: block, msgId, outputHash, bodyStored: false, localOnly: true, networkEnabled: false } };
302
+ }
303
+ if (!state || !inbound || !inbound.envelope.replyEndpoint) return { content: [{ type: "text", text: "zpeer_reply blocked: invalid reply state" }], details: { schema: "zob.zpeer-reply-result.v1", status: "blocked", reason: "invalid_reply_state", msgId, outputHash, bodyStored: false, localOnly: true, networkEnabled: false } };
304
+ const replyEndpoint = inbound.envelope.replyEndpoint;
305
+ try {
306
+ const responseEnvelope = { ...buildZobLiveResponseEnvelope(inbound.envelope, responseText, inbound.envelope.artifactRefs, inbound.envelope.artifactHashes), replyToMsgId: inbound.envelope.msgId, responseHash: outputHash };
307
+ const ack = await sendZobLocalEnvelope(replyEndpoint, responseEnvelope, { timeoutMs: 5_000 });
308
+ if (ack.type !== "ack") throw new Error(`expected ack, got ${ack.type}`);
309
+ if (inbound.watchdogTimer) clearTimeout(inbound.watchdogTimer);
310
+ inbound.responseSent = true;
311
+ inbound.requiredResponseStatus = "replied";
312
+ if (state.zobLive.inboundByMsgId) delete state.zobLive.inboundByMsgId[inbound.envelope.msgId];
313
+ if (state.zobLive.inbound?.envelope.msgId === inbound.envelope.msgId) state.zobLive.inbound = { ...state.zobLive.inbound, responseSent: true };
314
+ state.zobLive.activeInboundMsgId = undefined;
315
+ state.zobLive.inboundQueue = (state.zobLive.inboundQueue ?? []).filter((candidate) => candidate !== inbound.envelope.msgId);
316
+ const roomId = inbound.envelope.runId?.startsWith("zpeer:") ? inbound.envelope.runId.slice("zpeer:".length) : undefined;
317
+ state.zobLive.lastEvent = { kind: "response_sent", roomId, fromAlias: inbound.envelope.receiver, toAlias: inbound.envelope.sender, status: "response_sent", msgId: inbound.envelope.msgId, taskHash: inbound.envelope.taskHash, outputHash, priority: inbound.priority, interruptMode: inbound.interruptMode, at: new Date().toISOString(), localOnly: true, networkEnabled: false, bodyStored: false };
318
+ void pi.sendMessage({ customType: "zob-zpeer-event", content: "ZPeer explicit reply sent", display: true, details: { ...state.zobLive.lastEvent } }, { triggerTurn: false });
319
+ pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-reply.v1", action: "reply", status: "response_sent", msgId: inbound.envelope.msgId, taskHash: inbound.envelope.taskHash, outputHash, priority: inbound.priority, interruptMode: inbound.interruptMode, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
320
+ return { content: [{ type: "text", text: `zpeer_reply sent: msgId=${inbound.envelope.msgId} outputHash=${outputHash}` }], details: { schema: "zob.zpeer-reply-result.v1", status: "response_sent", msgId: inbound.envelope.msgId, taskHash: inbound.envelope.taskHash, outputHash, bodyStored: false, localOnly: true, networkEnabled: false } };
321
+ } catch (error) {
322
+ const reason = error instanceof Error ? error.message : String(error);
323
+ pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-reply.v1", action: "reply_error", status: "error", reasonHash: sha256(reason), msgId, outputHash, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
324
+ return { content: [{ type: "text", text: `zpeer_reply error: ${reason}` }], details: { schema: "zob.zpeer-reply-result.v1", status: "error", reasonHash: sha256(reason), msgId, outputHash, bodyStored: false, localOnly: true, networkEnabled: false } };
325
+ }
326
+ },
327
+ });
328
+
273
329
  pi.registerTool({
274
330
  name: "zob_coms_send",
275
331
  label: "ZOB Coms Send",
@@ -13,7 +13,7 @@ Rules:
13
13
  - In a parallel owner pool, edit only your owned/write paths; read-across sibling refs for context/evidence only. If you need another owner's path, send a typed owner request and wait for parent/owner decision.
14
14
  - If running without harness tools/extensions, do not call Goal Room/ZPeer directly; emit a hash/body-free `OWNER_CHANGE_REQUEST.v1` final-output block (`requested_by`, `owner_worker`, `requested_paths`, `body_hash`, `change_hash`, `reason_hash`, optional `validation_plan_hash`, safe refs, `FINAL_MARKER: OWNER_CHANGE_REQUEST_END`) for the parent to extract with `zob_governed_request_extract`.
15
15
  - Goal Room is canonical for owner requests, decisions, blockers, and evidence; ZPeer is transient clarification only and cannot replace parent-owned arbitration.
16
- - If using ZPeer urgent/force, load `.pi/skills/zob-coms-v2-live/SKILL.md` and `.pi/skills/zob-coms-safety/SKILL.md`; `urgent` is steer-only, `force` requires an explicit transient reason and hash-only metadata, and ACK/interrupt status is not completion evidence.
16
+ - If using ZPeer urgent/force/requireResponse, load `.pi/skills/zob-coms-v2-live/SKILL.md` and `.pi/skills/zob-coms-safety/SKILL.md`; `urgent` is steer-only, `force` requires an explicit transient reason and hash-only metadata, `requireResponse` needs exact msgId correlation or terminal expiration, explicit replies must use `zpeer_reply`/`/zpeer reply <msgId>`, and ACK/interrupt/reinjection status is not completion evidence.
17
17
  - No secrets. No destructive commands. No commits unless explicitly requested or governed autocommit is explicitly policy-authorized for this slice.
18
18
  - If commit/push work is authorized, load `.pi/skills/zob-commit/SKILL.md` and `.pi/git-policy.json`, then use only governed `/zcommit` commands or the agent-executable `zob_zcommit_run` tool when the user explicitly asks the agent to commit/push; no aliases and no direct git commit/push/tag/force-push/bulk-stage commands.
19
19
  - Verify with narrowest relevant checks first.
@@ -36,7 +36,7 @@ Orchestrator loop:
36
36
  - Keep `allowed_paths` repo-relative only; never pass external absolute/home paths to children. Use repo-local `reports/...` snapshots or `context_ref` artifacts for external context. Keep `forbidden_paths` deny-only.
37
37
  - Prefer `orchestrate_run` for multi-agent Lead/Worker decomposition and `delegate_task`/`delegate_agent` for bounded specialist work.
38
38
  - Use Goal Room as canonical for pool owner requests/decisions, blockers, and evidence. Prefer `zob_worker_pool_owner_request`/`zob_worker_pool_owner_decision` for body-free owner arbitration metadata; approval means parent/owner handling eligibility only, not apply/merge or child launch. For `--no-extensions` children, accept a final-output `OWNER_CHANGE_REQUEST.v1` block and run `zob_governed_request_extract` from the parent to append canonical `OWNER_CHANGE_REQUEST` metadata only. ZPeer is optional transient local clarification only; do not treat ZPeer/free chat as delivery, decision, merge evidence, or completion evidence.
39
- - For live ZPeer steering, choose the lowest sufficient priority: `normal` for ordinary async asks, `urgent` for steer-only priority injection, and `force` only for explicit parent/user-approved controlled interruption with a transient reason; durable records must remain hash-only and ACK/interrupt status is not read/digestion proof.
39
+ - For live ZPeer steering, choose the lowest sufficient priority: `normal` for ordinary async asks, opt-in `requireResponse`/`--require-response` only when a bounded msgId-correlated answer is required, use `zpeer_reply`/`/zpeer reply <msgId>` only for exact active inbound answers, `urgent` for steer-only priority injection, and `force` only for explicit parent/user-approved controlled interruption with a transient reason; durable records must remain hash-only and ACK/interrupt/reinjection status is not read/digestion proof.
40
40
  5. Evidence
41
41
  - Require concrete file refs, command names/results, reports, sentinels, or oracle verdicts.
42
42
  - Attach evidence to TODOs; planned orchestration is not completed implementation.
@@ -18,7 +18,7 @@ ZOB coms live transport may be transient, but ZOB audit must stay metadata-only.
18
18
  - Allow Shared Goal Room messages only when they are parent-visible, typed, metadata/hash-only, and not hidden worker-to-worker free chat.
19
19
  - For Goal TODO handoff, treat ZPeer as transient live delivery/clarification only and Goal Room hash-only metadata as canonical for TODO refs, receiver refs, custom-message hashes, result hashes, blockers, and evidence refs.
20
20
  - For ZPeer priorities, preserve `normal`/`urgent`/`force` semantics: `urgent` is steer-only and must not abort; `force` requires an explicit transient reason, stores only `reasonHash`/`interruptReasonHash`, and remains blocked unless topology, room, role, rate, lease, and local-socket guards pass.
21
- - Treat ZPeer ACK/interrupt status as delivery/control metadata only, not as read/digestion proof or TODO completion evidence.
21
+ - Treat ZPeer ACK/interrupt/reinjection status as delivery/control metadata only, not as read/digestion proof or TODO completion evidence; opt-in required responses must expire terminally unless an exact msgId-correlated reply arrives. Use `zpeer_reply` or `/zpeer reply <msgId> <response>` only when the `msgId` is active inbound; wrong, expired, or already-answered msgIds must block.
22
22
  - Treat governed requests (`DELEGATION_REQUEST.v1`, `ORACLE_REQUEST.v1`, `CONTEXT_REQUEST.v1`, `OWNER_CHANGE_REQUEST.v1`) as proposals only: parent/governor decides; extraction must not dispatch, mutate TODO state, apply owner changes, or store raw bodies.
23
23
  - Treat stale/offline as blockers, not completion evidence.
24
24
  - Keep Mission Control commands proposal-only and parent-owned.
@@ -18,7 +18,7 @@ Use this skill when:
18
18
  - Treat `zob_coms_send` as live-first when policy mode is `required_local` or `required_network`.
19
19
  - Include `transientBody` only for live transport delivery; it must never be stored in `.pi/coms`.
20
20
  - Use `taskHash`, `outputHash`, and `artifactRefs` for durable evidence.
21
- - For ZPeer direct asks, use governed `zpeer_ask` or `/zpeer`; supported priorities are `normal`, `urgent`, and `force`.
21
+ - For ZPeer direct asks, use governed `zpeer_ask` or `/zpeer`; supported priorities are `normal`, `urgent`, and `force`; use opt-in `requireResponse`/`--require-response` only when a real msgId-correlated answer is needed, and use `zpeer_reply` or `/zpeer reply <msgId> <response>` only for the exact active inbound `msgId`.
22
22
  - Use `urgent` only for priority steer delivery without aborting the receiver.
23
23
  - Use `force` only for explicit controlled interrupts: provide a transient reason, persist only its hash, require topology/room/role/rate/local-socket guards, and expect `force_accepted`, `force_downgraded`, or `force_blocked` metadata.
24
24
  - Treat missing ACK, stale peer, offline peer, timeout, or transport error as blocker evidence.
@@ -27,7 +27,7 @@ Use this skill when:
27
27
  - For children launched without harness extensions, owner-change coordination is via final-output `OWNER_CHANGE_REQUEST.v1` blocks extracted by the parent, not direct child Goal Room/ZPeer writes.
28
28
  - When a live answer changes ownership, scope, conflict, or merge readiness, mirror only typed hash/body-free metadata to Goal Room.
29
29
  - Let the runtime capture normal inbound responses when handling live prompts.
30
- - Prefer `zob_coms_get` / `zob_coms_await` with the `msgId` returned by your own send.
30
+ - Prefer `zob_coms_get` / `zob_coms_await` with the `msgId` returned by your own send; required-response success must come from exact msgId/replyToMsgId correlation, not ACK/reinjection/delivery metadata.
31
31
 
32
32
  ## MUST NOT
33
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zob-harness",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "description": "A governed Agent Factory for Pi: launch communicating agent teams, run tmux-backed factories, validate artifacts, and package repeatable workflows.",
6
6
  "license": "MIT",
@@ -102,6 +102,7 @@ async function main() {
102
102
  const toolsComs = await import(`${compiledSrc}/runtime/tools-coms.js`);
103
103
  const localTransport = await import(`${compiledComsV2}/local-transport.js`);
104
104
  const envelope = await import(`${compiledComsV2}/envelope.js`);
105
+ const pendingModule = await import(`${compiledComsV2}/pending-replies.js`);
105
106
  const hashing = await import(`${compiledSrc}/core/utils/hashing.js`);
106
107
 
107
108
  process.env.ZOB_ZPEER_PROFILE_ID = 'profile-alpha';
@@ -217,6 +218,10 @@ async function main() {
217
218
  }));
218
219
 
219
220
  servers.push(await localTransport.bindZobLocalEndpoint(betaEndpoint, async (incoming) => {
221
+ if (incoming.type === 'response') {
222
+ receivedResponses.push(incoming);
223
+ return envelope.buildZobLiveAckEnvelope(incoming);
224
+ }
220
225
  receivedPrompts.push(incoming);
221
226
  if (incoming.type === 'prompt' && incoming.replyEndpoint) {
222
227
  setTimeout(() => {
@@ -229,6 +234,8 @@ async function main() {
229
234
  team: incoming.team,
230
235
  taskHash: incoming.taskHash,
231
236
  outputHash: hashing.sha256(rawResponse),
237
+ replyToMsgId: incoming.msgId,
238
+ responseHash: hashing.sha256(rawResponse),
232
239
  transientResponse: rawResponse,
233
240
  });
234
241
  localTransport.sendZobLocalEnvelope(incoming.replyEndpoint, response, { timeoutMs: 5_000 }).catch((error) => {
@@ -357,6 +364,30 @@ async function main() {
357
364
  assert(ghostDuplicateSummary.onlineAliases.filter((alias) => alias === 'beta').length === 1, 'only the live beta lease alias should be listed as online');
358
365
  assert(!ghostDuplicateSummary.duplicateAliases.includes('beta'), 'card-only alias ghosts must not be reported as live duplicate aliases');
359
366
 
367
+ const requiredPromptCountBefore = receivedPrompts.length;
368
+ const requiredReplyResult = await zpeer.sendZpeerPrompt(repoRoot, alpha, 'beta', rawPrompt, waitForReply, { requireResponse: true, responseTimeoutMs: 1_000, maxReinjects: 1 });
369
+ assert(requiredReplyResult.status === 'reply' && requiredReplyResult.requireResponse === true && requiredReplyResult.responseReceived === true && requiredReplyResult.deliveryStatus === 'delivered', `requireResponse send expected correlated reply, got ${requiredReplyResult.status}`);
370
+ const requiredPromptEnvelope = receivedPrompts.at(-1);
371
+ assert(receivedPrompts.length === requiredPromptCountBefore + 1 && requiredPromptEnvelope.requireResponse === true && requiredPromptEnvelope.responseRequiredBy && requiredPromptEnvelope.maxReinjects === 1, 'requireResponse prompt envelope must carry bounded required-response metadata');
372
+ const requiredExpired = await zpeer.sendZpeerPrompt(repoRoot, adhocOne, 'adhoctwo', rawPrompt, (msgId) => new pendingModule.ZobPendingReplies().wait(msgId, 75, { requireResponse: true }), { mode: 'async', requireResponse: true, responseTimeoutMs: 75, maxReinjects: 1 });
373
+ assert(requiredExpired.status === 'required_response_expired' && requiredExpired.responseReceived === false && requiredExpired.deliveryStatus === 'delivered', `requireResponse no-reply send expected required_response_expired, got ${requiredExpired.status}`);
374
+ const wrongMsgPending = new pendingModule.ZobPendingReplies();
375
+ const wrongWait = wrongMsgPending.wait('right-msg', 75, { requireResponse: true });
376
+ const wrongAccepted = wrongMsgPending.complete('right-msg', envelope.buildZobLiveEnvelope({ type: 'response', msgId: 'right-msg', replyToMsgId: 'wrong-msg', transientResponse: rawResponse }));
377
+ const wrongResult = await wrongWait;
378
+ assert(wrongAccepted === false && wrongResult.status === 'required_response_expired', 'wrong replyToMsgId must not satisfy a required-response wait');
379
+ const missingMsgPending = new pendingModule.ZobPendingReplies();
380
+ const missingWait = missingMsgPending.wait('missing-reply-to-msg', 75, { requireResponse: true });
381
+ const missingAccepted = missingMsgPending.complete('missing-reply-to-msg', envelope.buildZobLiveEnvelope({ type: 'response', msgId: 'missing-reply-to-msg', transientResponse: rawResponse }));
382
+ const missingResult = await missingWait;
383
+ assert(missingAccepted === false && missingResult.status === 'required_response_expired', 'missing replyToMsgId must not satisfy a required-response wait');
384
+ const missingValidation = envelope.validateZobLiveEnvelope(envelope.buildZobLiveEnvelope({ type: 'response', msgId: 'missing-validation-msg', transientResponse: rawResponse }));
385
+ assert(missingValidation.some((error) => error.includes('replyToMsgId')), 'response envelope validation must reject missing replyToMsgId');
386
+ const latePending = new pendingModule.ZobPendingReplies();
387
+ const lateResult = await latePending.wait('late-msg', 75, { requireResponse: true });
388
+ const lateAccepted = latePending.complete('late-msg', envelope.buildZobLiveEnvelope({ type: 'response', msgId: 'late-msg', replyToMsgId: 'late-msg', transientResponse: rawResponse }));
389
+ assert(lateResult.status === 'required_response_expired' && lateAccepted === false, 'late response after required-response expiration must not retroactively satisfy the wait');
390
+
360
391
  const directPromptCountBefore = receivedPrompts.length;
361
392
  const directResponseCountBefore = receivedResponses.length;
362
393
  assert(alpha.team !== beta.team, 'same-room non-worker allowance fixture must cover cross-team peers');
@@ -387,6 +418,8 @@ async function main() {
387
418
  const forceWithoutReason = await zpeer.sendZpeerPrompt(repoRoot, alpha, 'beta', rawPrompt, waitForReply, { mode: 'async', priority: 'force', interruptMode: 'abort' });
388
419
  assert(forceWithoutReason.status === 'blocked' && forceWithoutReason.interruptStatus === 'force_blocked' && String(forceWithoutReason.reason).includes('reason hash'), 'force without reason hash must be blocked before delivery');
389
420
  const forceReasonHash = hashing.sha256('no-ship smoke reason');
421
+ const forceRequiredExpired = await zpeer.sendZpeerPrompt(repoRoot, adhocOne, 'adhoctwo', rawPrompt, (msgId) => new pendingModule.ZobPendingReplies().wait(msgId, 75, { requireResponse: true }), { mode: 'async', priority: 'force', interruptMode: 'abort', interruptReasonHash: forceReasonHash, requireResponse: true, responseTimeoutMs: 75, maxReinjects: 1 });
422
+ assert(forceRequiredExpired.status === 'blocked' || forceRequiredExpired.status === 'required_response_expired', 'force + requireResponse must either respect force guards or expire; reinjection must not be counted as success');
390
423
  const forcePromptCountBefore = receivedPrompts.length;
391
424
  const forceResult = await zpeer.sendZpeerPrompt(repoRoot, alpha, 'beta', rawPrompt, waitForReply, { mode: 'async', priority: 'force', interruptMode: 'abort', interruptReasonHash: forceReasonHash });
392
425
  assert(forceResult.status === 'waiting' && forceResult.priority === 'force' && forceResult.interruptMode === 'abort' && forceResult.interruptStatus === 'force_accepted' && forceResult.interruptReasonHash === forceReasonHash, `force send expected waiting/force_accepted, got ${forceResult.status}/${forceResult.interruptStatus}`);
@@ -430,8 +463,49 @@ async function main() {
430
463
  assert(forceToolBlocked?.details?.status === 'blocked' && forceToolBlocked?.details?.interruptStatus === 'force_blocked' && String(forceToolBlocked?.details?.reason).includes('reason'), 'zpeer_ask force without reason must be blocked');
431
464
  const forceToolResult = await zpeerAsk.execute('tool-call-zpeer-ask-force', { targetAlias: 'beta', message: 'force tool smoke prompt', force: true, reason: 'force smoke reason' }, undefined, undefined, { cwd: repoRoot });
432
465
  assert(forceToolResult?.details?.status === 'waiting' && forceToolResult?.details?.priority === 'force' && forceToolResult?.details?.interruptMode === 'abort' && forceToolResult?.details?.interruptStatus === 'force_accepted' && forceToolResult?.details?.interruptReasonHash === hashing.sha256('force smoke reason'), 'zpeer_ask force must require reason and deliver abort metadata');
433
-
434
- const duplicateGuard = await zpeerAsk.execute('tool-call-zpeer-ask-dup', { targetAlias: 'beta', message: 'force tool smoke prompt' }, undefined, undefined, { cwd: repoRoot });
466
+ const requireToolResult = await zpeerAsk.execute('tool-call-zpeer-ask-require-response', { targetAlias: 'beta', message: 'required response tool smoke prompt', requireResponse: true, timeoutMs: 1000 }, undefined, undefined, { cwd: repoRoot });
467
+ assert(requireToolResult?.details?.status === 'reply' && requireToolResult?.details?.requireResponse === true && requireToolResult?.details?.responseReceived === true, 'zpeer_ask requireResponse must wait for a msgId-correlated reply instead of returning delivery-only waiting');
468
+
469
+ const zpeerReply = registeredTools.get('zpeer_reply');
470
+ assert(zpeerReply, 'zpeer_reply tool must be registered by registerComsTools');
471
+ assert(zpeerReply.parameters, 'zpeer_reply tool must expose a schema');
472
+ const explicitReplyMsgId = 'explicit-reply-msg';
473
+ toolState.zobLive.inboundByMsgId = {
474
+ [explicitReplyMsgId]: {
475
+ envelope: envelope.buildZobLiveEnvelope({
476
+ type: 'prompt',
477
+ msgId: explicitReplyMsgId,
478
+ runId: 'zpeer:default',
479
+ sender: 'beta',
480
+ receiver: 'alpha',
481
+ taskHash: hashing.sha256(rawPrompt),
482
+ transientPrompt: rawPrompt,
483
+ replyEndpoint: betaEndpoint,
484
+ replyEndpointHash: hashing.sha256(betaEndpoint),
485
+ requireResponse: true,
486
+ }),
487
+ receivedAt: new Date().toISOString(),
488
+ responseSent: false,
489
+ priority: 'normal',
490
+ interruptMode: 'none',
491
+ repoRoot,
492
+ requireResponse: true,
493
+ requiredResponseStatus: 'owed',
494
+ },
495
+ };
496
+ toolState.zobLive.inboundQueue = [explicitReplyMsgId];
497
+ toolState.zobLive.activeInboundMsgId = explicitReplyMsgId;
498
+ const explicitReplyCountBefore = receivedResponses.length;
499
+ const explicitReplyResult = await zpeerReply.execute('tool-call-zpeer-reply', { msgId: explicitReplyMsgId, message: rawResponse }, undefined, undefined, { cwd: repoRoot });
500
+ assert(explicitReplyResult?.details?.status === 'response_sent' && explicitReplyResult?.details?.msgId === explicitReplyMsgId && explicitReplyResult?.details?.outputHash === hashing.sha256(rawResponse), 'zpeer_reply must send a msgId-bound response with outputHash metadata');
501
+ assert(!toolState.zobLive.inboundByMsgId?.[explicitReplyMsgId] && toolState.zobLive.activeInboundMsgId === undefined && toolState.zobLive.inboundQueue.length === 0, 'zpeer_reply must clear the answered inbound msgId from receiver state');
502
+ assert(receivedResponses.length > explicitReplyCountBefore && receivedResponses.at(-1)?.replyToMsgId === explicitReplyMsgId && receivedResponses.at(-1)?.responseHash === hashing.sha256(rawResponse), 'zpeer_reply must deliver a local response envelope bound to replyToMsgId');
503
+ const wrongExplicitReply = await zpeerReply.execute('tool-call-zpeer-reply-wrong', { msgId: 'missing-msgid', message: rawResponse }, undefined, undefined, { cwd: repoRoot });
504
+ assert(wrongExplicitReply?.details?.status === 'blocked' && String(wrongExplicitReply?.details?.reason).includes('no active inbound'), 'zpeer_reply must block wrong or non-active msgId replies');
505
+ assert(!containsRawBody(explicitReplyResult) && !containsRawBody(wrongExplicitReply), 'zpeer_reply tool results must not echo raw response body');
506
+ assert(appendEntries.some((item) => item.customType === 'zob-zpeer' && item.data?.schema === 'zob.zpeer-reply.v1' && item.data?.action === 'reply' && item.data?.bodyStored === false), 'zpeer_reply must append hash-only reply metadata');
507
+
508
+ const duplicateGuard = await zpeerAsk.execute('tool-call-zpeer-ask-dup', { targetAlias: 'beta', message: 'required response tool smoke prompt' }, undefined, undefined, { cwd: repoRoot });
435
509
  assert(duplicateGuard?.details?.status === 'blocked' && String(duplicateGuard?.details?.reason).includes('duplicate'), 'zpeer_ask must block duplicate target/message loop attempts');
436
510
  const selfGuard = await zpeerAsk.execute('tool-call-zpeer-ask-self', { targetAlias: 'alpha', message: 'different smoke prompt' }, undefined, undefined, { cwd: repoRoot });
437
511
  assert(selfGuard?.details?.status === 'blocked' && String(selfGuard?.details?.reason).includes('self'), 'zpeer_ask must block self-target attempts');
@@ -478,6 +552,7 @@ async function main() {
478
552
  }
479
553
  assert(messages.some((record) => record.event === 'ack' && record.status === 'delivered' && record.taskHash === hashing.sha256(rawPrompt)), 'peer ledger must include delivered ack hash record');
480
554
  assert(messages.some((record) => record.event === 'terminal' && record.status === 'reply' && record.outputHash === hashing.sha256(rawResponse)), 'peer ledger must include reply outputHash record');
555
+ assert(messages.some((record) => record.event === 'terminal' && record.status === 'required_response_expired' && record.requireResponse === true && record.responseReceived === false), 'peer ledger must include required-response expiration metadata');
481
556
  assert(messages.some((record) => record.event === 'terminal' && record.status === 'waiting' && record.taskHash === hashing.sha256(rawPrompt)), 'peer ledger must include async waiting hash record');
482
557
  assert(messages.some((record) => record.event === 'attempt' && record.status === 'blocked' && record.reasonHash), 'peer ledger must include hash-only blocked room-isolation record');
483
558
  assert(messages.some((record) => record.event === 'ack' && record.priority === 'urgent' && record.interruptMode === 'steer' && record.interruptStatus === 'urgent_delivered'), 'peer ledger must include urgent delivered interrupt metadata');
@@ -31,6 +31,9 @@ function readSurface(file) {
31
31
 
32
32
  const files = [
33
33
  '.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer.ts',
34
+ '.pi/extensions/zob-harness/src/domains/coms/coms-v2/envelope.ts',
35
+ '.pi/extensions/zob-harness/src/domains/coms/coms-v2/pending-replies.ts',
36
+ '.pi/extensions/zob-harness/src/domains/coms/coms-v2/response-capture.ts',
34
37
  '.pi/extensions/zob-harness/src/domains/coms/coms-v2/registry.ts',
35
38
  '.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer-profile.ts',
36
39
  '.pi/extensions/zob-harness/src/domains/coms/coms-v2/transcript-capture.ts',
@@ -54,26 +57,32 @@ if (commandMatches.length !== 1) failures.push(`expected exactly one zpeer comma
54
57
  const toolsComs = contents['.pi/extensions/zob-harness/src/runtime/tools-coms.ts'];
55
58
  const zpeerAskMatches = toolsComs.match(/name: "zpeer_ask"/g) ?? [];
56
59
  if (zpeerAskMatches.length !== 1) failures.push(`expected exactly one zpeer_ask tool, found ${zpeerAskMatches.length}`);
57
- for (const needle of ['parameters: ZpeerAskParams', 'sendZpeerPrompt(ctx.cwd', 'mode = params.mode ?? "async"', 'zpeerAskGuardBlock', 'ZPEER_AGENT_ASK_RATE_LIMIT_PER_MINUTE = 50', 'ZPEER_AGENT_URGENT_RATE_LIMIT_PER_MINUTE = 10', 'ZPEER_AGENT_FORCE_RATE_LIMIT_PER_MINUTE = 3', 'normalizeZpeerInterrupt', 'force interrupt requires reason', 'max ${ZPEER_AGENT_ASK_RATE_LIMIT_PER_MINUTE} agent-initiated ZPeer asks per 60s window', 'idle/passive wait: no follow-up turn queued', 'customType: "zob-zpeer-event"', 'source: "agent-request"', 'action: "agent_request"', 'feedbackEmittedTerminal', 'reasonInputHash', 'interruptReasonHash', 'bodyStored: false', 'promptBodiesStored: false', 'outputBodiesStored: false']) {
60
+ const zpeerReplyMatches = toolsComs.match(/name: "zpeer_reply"/g) ?? [];
61
+ if (zpeerReplyMatches.length !== 1) failures.push(`expected exactly one zpeer_reply tool, found ${zpeerReplyMatches.length}`);
62
+ for (const needle of ['parameters: ZpeerAskParams', 'sendZpeerPrompt(ctx.cwd', 'mode = params.mode ?? "async"', 'requireResponse = params.requireResponse === true', 'pendingReplies.wait(msgId, timeoutMs, { requireResponse })', 'maxReinjects', 'zpeerAskGuardBlock', 'ZPEER_AGENT_ASK_RATE_LIMIT_PER_MINUTE = 50', 'ZPEER_AGENT_URGENT_RATE_LIMIT_PER_MINUTE = 10', 'ZPEER_AGENT_FORCE_RATE_LIMIT_PER_MINUTE = 3', 'normalizeZpeerInterrupt', 'force interrupt requires reason', 'max ${ZPEER_AGENT_ASK_RATE_LIMIT_PER_MINUTE} agent-initiated ZPeer asks per 60s window', 'idle/passive wait: no follow-up turn queued', 'customType: "zob-zpeer-event"', 'source: "agent-request"', 'action: "agent_request"', 'feedbackEmittedTerminal', 'reasonInputHash', 'interruptReasonHash', 'bodyStored: false', 'promptBodiesStored: false', 'outputBodiesStored: false']) {
58
63
  if (!toolsComs.includes(needle)) failures.push(`zpeer_ask tool missing ${needle}`);
59
64
  }
65
+ for (const needle of ['parameters: ZpeerReplyParams', 'buildZobLiveResponseEnvelope', 'sendZobLocalEnvelope(replyEndpoint', 'replyToMsgId: inbound.envelope.msgId', 'action: "reply"', 'action: "reply_blocked"', 'status: "response_sent"', 'ZPeer msgId required response already expired']) {
66
+ if (!toolsComs.includes(needle)) failures.push(`zpeer_reply tool missing ${needle}`);
67
+ }
60
68
  if (toolsComs.includes('emitZpeerAskEvent({ kind: "attempt", status: "agent-request"')) failures.push('zpeer_ask async must not emit pre-ACK attempt feed noise');
61
69
  if (!toolsComs.includes('peerAliasInRoom(self, requestedRoomId)') || !toolsComs.includes('peerAliasInRoom(self, eventRoomId)')) failures.push('zpeer_ask feed events must use room-scoped sender alias for explicit roomId');
62
70
  const schemas = contents['.pi/extensions/zob-harness/src/runtime/schemas.ts'];
63
- for (const needle of ['const ZpeerAskParams', 'targetAlias', 'message', 'roomId', 'Default async', '["async", "await", "long"]', 'timeoutMs', 'ZpeerAskParams']) {
64
- if (!schemas.includes(needle)) failures.push(`zpeer_ask schema missing ${needle}`);
71
+ for (const needle of ['const ZpeerAskParams', 'targetAlias', 'message', 'roomId', 'Default async', '["async", "await", "long"]', 'timeoutMs', 'requireResponse', 'maxReinjects', 'ZpeerAskParams', 'const ZpeerReplyParams', 'msgId', 'ZpeerReplyParams']) {
72
+ if (!schemas.includes(needle)) failures.push(`zpeer schemas missing ${needle}`);
65
73
  }
66
74
  const registry = contents['.pi/capabilities/zob-public-runtime-capabilities.json'];
67
- if (!registry.includes('"name": "zpeer_ask"') || !registry.includes('rate/loop guarded')) failures.push('capability registry missing zpeer_ask visibility/safety notes');
75
+ if (!registry.includes('"name": "zpeer_ask"') || !registry.includes('rate/loop guarded') || !registry.includes('requireResponse tracks msgId-correlated replies')) failures.push('capability registry missing zpeer_ask visibility/safety notes');
76
+ if (!registry.includes('"name": "zpeer_reply"') || !registry.includes('wrong/expired/already-answered msgIds are blocked')) failures.push('capability registry missing zpeer_reply visibility/safety notes');
68
77
  const constants = contents['.pi/extensions/zob-harness/src/core/constants.ts'];
69
78
  const zpeerComsAllowlistBlock = constants.match(/export const ZOB_COMS_TOOLS = \[[^\n]*\] as const;/)?.[0] ?? '';
70
- if (!zpeerComsAllowlistBlock.includes('"zpeer_ask"')) failures.push('ZOB_COMS_TOOLS missing zpeer_ask active allowlist entry');
79
+ if (!zpeerComsAllowlistBlock.includes('"zpeer_ask"') || !zpeerComsAllowlistBlock.includes('"zpeer_reply"')) failures.push('ZOB_COMS_TOOLS missing zpeer_ask/zpeer_reply active allowlist entry');
71
80
  const modeBlocks = Object.fromEntries([...constants.matchAll(/\n (explore|plan|implement|oracle|orchestrator|factory): \[[^\n]*\],/g)].map((match) => [match[1], match[0]]));
72
81
  for (const mode of ['plan', 'implement', 'orchestrator', 'factory']) {
73
82
  if (!modeBlocks[mode]?.includes('...ZOB_COMS_TOOLS')) failures.push(`MODE_TOOLS.${mode} must expose zpeer_ask through ZOB_COMS_TOOLS`);
74
83
  }
75
84
  for (const mode of ['explore', 'oracle']) {
76
- if (!modeBlocks[mode]?.includes('"zpeer_ask"')) failures.push(`MODE_TOOLS.${mode} missing zpeer_ask explicit active allowlist entry`);
85
+ if (!modeBlocks[mode]?.includes('"zpeer_ask"') || !modeBlocks[mode]?.includes('"zpeer_reply"')) failures.push(`MODE_TOOLS.${mode} missing zpeer_ask/zpeer_reply explicit active allowlist entry`);
77
86
  }
78
87
  for (const forbidden of ['transientPrompt:', 'transientResponse:', 'prompt:', 'output:', 'content: params.message', 'message: params.message', 'text: params.message', 'diff:', 'patch:']) {
79
88
  const appendBlocks = [...toolsComs.matchAll(/appendEntry\("zob-zpeer",\s*\{[\s\S]*?\}\);/g)].map((m) => m[0]);
@@ -81,15 +90,27 @@ for (const forbidden of ['transientPrompt:', 'transientResponse:', 'prompt:', 'o
81
90
  }
82
91
 
83
92
  const zpeer = contents['.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer.ts'];
93
+ const envelope = contents['.pi/extensions/zob-harness/src/domains/coms/coms-v2/envelope.ts'];
94
+ const pendingReplies = contents['.pi/extensions/zob-harness/src/domains/coms/coms-v2/pending-replies.ts'];
95
+ const responseCapture = contents['.pi/extensions/zob-harness/src/domains/coms/coms-v2/response-capture.ts'];
84
96
  const liveRegistry = contents['.pi/extensions/zob-harness/src/domains/coms/coms-v2/registry.ts'];
85
97
  const zpeerProfile = contents['.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer-profile.ts'];
86
- for (const needle of ['networkEnabled: false', 'localOnly: true', 'bodyStored: false', 'sendZobLocalEnvelope', 'taskHash', 'outputHash', 'ZpeerSendMode', 'status: "waiting"', 'status: "reply"', 'zpeerMembershipsForPeer', 'joinZpeerRoom', 'leaveZpeerRoom', 'useZpeerRoom', 'clearZpeerRoom', 'preservedSelf: true', 'roomId?: string', 'buildZpeerPeerRoomSummaries', 'active: membership.roomId === activeRoomId', 'peerRespondsToAliasPing', 'activeAliasCollision', 'zpeer-alias-ping', 'zpeerAdhoc']) {
98
+ for (const needle of ['networkEnabled: false', 'localOnly: true', 'bodyStored: false', 'sendZobLocalEnvelope', 'taskHash', 'outputHash', 'ZpeerSendMode', 'status: "waiting"', 'status: "reply"', 'required_response_expired', 'requireResponse', 'responseRequiredBy', 'responseReceived', 'deliveryStatus', 'zpeerMembershipsForPeer', 'joinZpeerRoom', 'leaveZpeerRoom', 'useZpeerRoom', 'clearZpeerRoom', 'preservedSelf: true', 'roomId?: string', 'buildZpeerPeerRoomSummaries', 'active: membership.roomId === activeRoomId', 'peerRespondsToAliasPing', 'activeAliasCollision', 'zpeer-alias-ping', 'zpeerAdhoc']) {
87
99
  if (!zpeer.includes(needle)) failures.push(`zpeer missing ${needle}`);
88
100
  }
89
101
  for (const needle of ['peer-messages.jsonl', 'peer-status.jsonl', 'appendZpeerPeerRecords', 'reasonHash', 'priority', 'interruptMode', 'interruptStatus', 'interruptReasonHash', 'bodyStored: false']) {
90
102
  if (!zpeer.includes(needle)) failures.push(`zpeer hash-only peer ledger support missing ${needle}`);
91
103
  }
92
104
  if (/required_network|pi-vs-claude-code|sse/.test(zpeer)) failures.push('zpeer must not enable network/SSE transport');
105
+ for (const needle of ['value.type === "response" && value.replyToMsgId !== value.msgId', 'ZOB live response replyToMsgId must match msgId']) {
106
+ if (!envelope.includes(needle)) failures.push(`envelope missing strict response replyToMsgId validation ${needle}`);
107
+ }
108
+ for (const needle of ['requireResponse: options.requireResponse === true', 'item?.requireResponse && envelope.replyToMsgId !== msgId', 'completed.envelope?.replyToMsgId !== msgId']) {
109
+ if (!pendingReplies.includes(needle)) failures.push(`pending replies missing strict required-response correlation ${needle}`);
110
+ }
111
+ for (const needle of ['replyToMsgId: request.msgId', 'responseHash: capture.outputHash']) {
112
+ if (!responseCapture.includes(needle)) failures.push(`response capture missing msgId-safe response envelope field ${needle}`);
113
+ }
93
114
  const roomFirstTopologyIndex = zpeer.indexOf('if (selfInRequestedRoom && targetInRequestedRoom && !bothPeersAreWorkers) return undefined;');
94
115
  const hardTeamMismatchIndex = zpeer.indexOf('zpeer topology blocked: peers are in different teams');
95
116
  if (roomFirstTopologyIndex === -1) failures.push('zpeer topology must allow same-room non-worker peers before team/ZTeam policy');
@@ -138,7 +159,7 @@ for (const forbidden of ['transientPrompt:', 'transientResponse:', 'prompt:', 'r
138
159
  }
139
160
  if (!command.includes('zpeerCommandProfileId(ctx)') || !command.includes('writeZpeerLocalProfileFromPeer(ctx.cwd, result.peer, zpeerProfileId)')) failures.push('zpeer command must save profile under the current Pi session profile after successful name/room changes');
140
161
  if (!command.includes('customType: "zob-zpeer-response"') || !command.includes('result.transientResponse')) failures.push('zpeer command missing transient response display support');
141
- for (const needle of ['customType: "zob-zpeer-event"', 'if (sendMode.mode !== "async") emitZpeerEvent({ kind: "attempt"', 'feedbackEmittedTerminal', 'send_async', 'replyTimeoutMs', 'idle/passive wait; no follow-up turn queued', 'safety: local-only/hash-only/bodyStored=false', 'verb === "clear"', 'action: "clear"', 'verb === "join"', 'verb === "use"', 'verb === "leave"', 'verb === "rooms"', 'verb === "in"', 'tokenizeZpeerArgs', 'priorityToken', '--reason is required for force', 'interruptReasonHash', 'reasonInputHash', 'interrupt=${result.interruptStatus}', '/zpeer urgent @alias <prompt>', '/zpeer force @alias --reason <reason> <prompt>']) {
162
+ for (const needle of ['customType: "zob-zpeer-event"', 'if (sendMode.mode !== "async") emitZpeerEvent({ kind: "attempt"', 'feedbackEmittedTerminal', 'send_async', 'replyTimeoutMs', '--require-response', 'requireResponse', 'maxReinjects', 'idle/passive wait; no follow-up turn queued', 'safety: local-only/hash-only/bodyStored=false', 'verb === "clear"', 'action: "clear"', 'verb === "join"', 'verb === "use"', 'verb === "leave"', 'verb === "reply"', 'action: "reply"', 'action: "reply_blocked"', 'buildZobLiveResponseEnvelope', 'sendZobLocalEnvelope(replyEndpoint', 'replyToMsgId: inbound.envelope.msgId', '/zpeer reply <msgId> <response>', 'verb === "rooms"', 'verb === "in"', 'tokenizeZpeerArgs', 'priorityToken', '--reason is required for force', 'interruptReasonHash', 'reasonInputHash', 'interrupt=${result.interruptStatus}', '/zpeer urgent @alias <prompt>', '/zpeer force @alias --reason <reason> <prompt>']) {
142
163
  if (!command.includes(needle)) failures.push(`zpeer command missing UX event/status support ${needle}`);
143
164
  }
144
165
  if (!command.includes('const eventFromAlias = peerAliasInRoom(state.zobLive.peerCard, eventRoomId)') || !command.includes('peerAliasInRoom(state.zobLive.peerCard, resultRoomId)')) failures.push('/zpeer in <room> events must use room-scoped sender alias');
@@ -177,7 +198,7 @@ for (const needle of ['readZpeerNewCarryoverProfile(repoRoot)', 'const carryover
177
198
  }
178
199
  if (!events.includes('event.source === "extension" && !event.text.trim()') || !events.includes('action: "handled" as const')) failures.push('runtime must handle empty extension follow-ups without continuing the agent');
179
200
  if (!events.includes('ZPeer async reply received from @') || !events.includes('{ triggerTurn: true, deliverAs: "followUp" }')) failures.push('runtime must resume an idle agent with a follow-up when an async ZPeer reply arrives');
180
- for (const needle of ['forceAbortAllowedForCurrentState', 'interruptStatus = "force_blocked"', 'interruptStatus = "force_downgraded"', 'ctx.abort()', 'const deliverAs = priority === "normal" ? "followUp" : "steer"', 'inboundByMsgId', 'activeInboundMsgId', 'state.zobLive.inboundByMsgId && !activeInbound']) {
201
+ for (const needle of ['forceAbortAllowedForCurrentState', 'interruptStatus = "force_blocked"', 'interruptStatus = "force_downgraded"', 'ctx.abort()', 'const deliverAs = priority === "normal" ? "followUp" : "steer"', 'inboundByMsgId', 'activeInboundMsgId', 'state.zobLive.inboundByMsgId && !activeInbound', 'scheduleZpeerRequiredResponseWatchdog', 'required_response_reinject', 'required_response_expired', 'Original transient message:', 'zpeer_required_response_expired', 'pendingReplies.expire', 'forceAbortRepeated: false', 'replyToMsgId !== envelope.msgId', 'wrong or missing replyToMsgId']) {
181
202
  if (!events.includes(needle)) failures.push(`runtime missing urgent/force/msgId-safe inbound support ${needle}`);
182
203
  }
183
204
  for (const needle of ['ZPEER AWARENESS (transient, rebuilt each turn)', 'buildZpeerPeerRoomSummaries(repoRoot, state.zobLive.peerCard)', '- rooms:', 'explicit roomId', 'zpeer_ask with mode=\\"async\\"', 'Passive wait rule', 'stop the turn and remain idle', 'avoid spam', 'Raw ZPeer bodies are transient', 'registerMessageRenderer("zob-zpeer-event"', 'scheduleZpeerHeartbeat', 'clearZpeerHeartbeatTimer', 'refreshZpeerSelf(repoRoot', 'kind: "response_sent"', 'kind: "inbound"', 'zpeerStableTeamAgentLeaseRequired', 'withZpeerLeaseMode', 'leaseStatus = "unavailable"', 'runtime_adhoc']) {