zob-harness 0.15.1 → 0.15.3

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.
@@ -617,6 +617,7 @@ export { ZobPendingReplies } from "./src/domains/coms/coms-v2/pending-replies.js
617
617
  export { buildZobLiveResponseCapture, buildZobLiveResponseEnvelope } from "./src/domains/coms/coms-v2/response-capture.js";
618
618
  export { appendLiveSendRequestedRef, appendLiveDeliveredStatus, appendLiveRunningStatus, appendLiveCompletedRef, appendLiveErrorStatus, appendPeerStaleStatus } from "./src/domains/coms/coms-v2/ledger-bridge.js";
619
619
  export { redactZobComsText, writeZobComsRedactedCapture } from "./src/domains/coms/coms-v2/transcript-capture.js";
620
+ export { annotateZpeerStatus, isZpeerTerminalStatus, shouldAcceptZpeerStatusUpdate, zpeerStatusRank } from "./src/domains/coms/coms-v2/zpeer-status.js";
620
621
  export type { ZobLiveEnvelope, ZobLiveEnvelopeType } from "./src/domains/coms/coms-v2/envelope.js";
621
622
  export type { ZobComsTranscriptCapturePolicy, ZobComsTranscriptMode, ZobComsTranscriptRetentionClass, ZobComsTransportMode, ZobComsV2Policy, ZobLivePeerCard, ZobLivePeerStatus, ZobLivePresenceSummary, ZobLiveRegistrySnapshot } from "./src/domains/coms/coms-v2/types.js";
622
623
 
@@ -0,0 +1,92 @@
1
+ export interface ZpeerStatusComparable {
2
+ status?: string;
3
+ kind?: string;
4
+ at?: string;
5
+ }
6
+
7
+ const ZPEER_TERMINAL_STATUSES = new Set([
8
+ "reply",
9
+ "completed",
10
+ "response_sent",
11
+ "blocked",
12
+ "error",
13
+ "timeout",
14
+ "expired",
15
+ "required_response_expired",
16
+ ]);
17
+
18
+ const ZPEER_STATUS_RANKS: Record<string, number> = {
19
+ status: 0,
20
+ attempt: 5,
21
+ heartbeat: 5,
22
+ sent: 10,
23
+ delivered: 20,
24
+ urgent_delivered: 25,
25
+ force_accepted: 25,
26
+ force_downgraded: 25,
27
+ required_response_reinject: 30,
28
+ inbound: 35,
29
+ prompt_received: 35,
30
+ waiting: 40,
31
+ response_sent: 90,
32
+ reply: 100,
33
+ completed: 100,
34
+ blocked: 100,
35
+ force_blocked: 100,
36
+ error: 100,
37
+ timeout: 100,
38
+ expired: 100,
39
+ required_response_expired: 100,
40
+ };
41
+
42
+ function zpeerStatusKey(input: ZpeerStatusComparable): string | undefined {
43
+ return input.status ?? input.kind;
44
+ }
45
+
46
+ function parsedTime(value: string | undefined): number | undefined {
47
+ if (!value) return undefined;
48
+ const parsed = Date.parse(value);
49
+ return Number.isFinite(parsed) ? parsed : undefined;
50
+ }
51
+
52
+ export function isZpeerTerminalStatus(status: string | undefined, kind?: string): boolean {
53
+ const primary = status ?? kind;
54
+ return Boolean((primary && ZPEER_TERMINAL_STATUSES.has(primary)) || (kind && ZPEER_TERMINAL_STATUSES.has(kind)));
55
+ }
56
+
57
+ export function zpeerStatusRank(status: string | undefined, kind?: string): number {
58
+ const primary = status ?? kind;
59
+ if (primary && ZPEER_STATUS_RANKS[primary] !== undefined) return ZPEER_STATUS_RANKS[primary];
60
+ if (kind && ZPEER_STATUS_RANKS[kind] !== undefined) return ZPEER_STATUS_RANKS[kind];
61
+ return 0;
62
+ }
63
+
64
+ export function shouldAcceptZpeerStatusUpdate(current: ZpeerStatusComparable | undefined, incoming: ZpeerStatusComparable): boolean {
65
+ if (!current) return true;
66
+
67
+ const currentTerminal = isZpeerTerminalStatus(current.status, current.kind);
68
+ const incomingTerminal = isZpeerTerminalStatus(incoming.status, incoming.kind);
69
+ if (currentTerminal && !incomingTerminal) return false;
70
+ if (!currentTerminal && incomingTerminal) return true;
71
+
72
+ const currentRank = zpeerStatusRank(current.status, current.kind);
73
+ const incomingRank = zpeerStatusRank(incoming.status, incoming.kind);
74
+ if (incomingRank > currentRank) return true;
75
+ if (incomingRank < currentRank) return false;
76
+
77
+ const currentAt = parsedTime(current.at);
78
+ const incomingAt = parsedTime(incoming.at);
79
+ if (currentAt !== undefined && incomingAt !== undefined) return incomingAt >= currentAt;
80
+
81
+ const currentKey = zpeerStatusKey(current);
82
+ const incomingKey = zpeerStatusKey(incoming);
83
+ return currentKey === incomingKey;
84
+ }
85
+
86
+ export function annotateZpeerStatus<T extends ZpeerStatusComparable>(event: T): T & { terminal: boolean; statusRank: number } {
87
+ return {
88
+ ...event,
89
+ terminal: isZpeerTerminalStatus(event.status, event.kind),
90
+ statusRank: zpeerStatusRank(event.status, event.kind),
91
+ };
92
+ }
@@ -10,6 +10,7 @@ import { validateZobComsEdge } from "../../topology/coms.js";
10
10
  import { loadTeamDefinition, validateTeamDefinition } from "../../topology/teams.js";
11
11
  import { loadZteamManifest, zteamAllowsZpeerContact } from "../zagents.js";
12
12
  import { sha256 } from "../../../core/utils/hashing.js";
13
+ import { annotateZpeerStatus } from "./zpeer-status.js";
13
14
 
14
15
  const DEFAULT_ROOM_ID = "default";
15
16
  const ALIAS_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{1,31}$/;
@@ -67,6 +68,8 @@ export interface ZpeerSendResult {
67
68
  deliveryMethod?: "local_socket" | "tmux_sendkeys";
68
69
  fallback_delivery?: boolean;
69
70
  best_effort?: boolean;
71
+ terminal?: boolean;
72
+ statusRank?: number;
70
73
  bodyStored: false;
71
74
  }
72
75
 
@@ -620,7 +623,7 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
620
623
  deliveryMethod: result.deliveryMethod,
621
624
  fallbackDelivery: result.fallback_delivery,
622
625
  });
623
- return { roomId, priority, interruptMode, interruptReasonHash, requireResponse: requireResponse || undefined, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined, ...result };
626
+ return annotateZpeerStatus({ roomId, priority, interruptMode, interruptReasonHash, requireResponse: requireResponse || undefined, responseTimeoutMs: requireResponse ? responseTimeoutMs : undefined, maxReinjects: requireResponse ? maxReinjects : undefined, ...result });
624
627
  };
625
628
 
626
629
  if (priority === "force" && !interruptReasonHash) return finish("attempt", { status: "blocked", reason: "force interrupt requires reason hash", targetAlias: targetAlias ?? undefined, taskHash, interruptStatus: "force_blocked", bodyStored: false });
@@ -17,6 +17,7 @@ import { loadActiveZagentScopedMode } from "../events.js";
17
17
  import { resolveRuleProfile } from "../../domains/governance/rules.js";
18
18
  import type { HarnessRuntimeState } from "../state.js";
19
19
  import { applyMode, renderHarnessWidget } from "../widget.js";
20
+ import { recordZpeerRuntimeEvent } from "../zpeer-events.js";
20
21
 
21
22
  function zpeerCommandProfileId(ctx: ExtensionCommandContext): string {
22
23
  const sessionIdentity = ctx.sessionManager.getSessionFile() ?? ctx.sessionManager.getSessionId();
@@ -1140,18 +1141,21 @@ async function executeZteamTmuxActionPlan(_repoRoot: string, plan: ZteamTmuxActi
1140
1141
  }
1141
1142
 
1142
1143
  export function registerZliveCommands(pi: ExtensionAPI, state: HarnessRuntimeState): void {
1143
- const rememberZpeerEvent = (event: { kind: NonNullable<typeof state.zobLive.lastEvent>["kind"]; roomId?: string; fromAlias?: string; toAlias?: string; status: string; reason?: string; msgId?: string; taskHash?: string; outputHash?: string; priority?: ZpeerInterruptPriority; interruptMode?: ZpeerInterruptMode; interruptStatus?: ZpeerInterruptStatus }): void => {
1144
- state.zobLive.lastEvent = { ...event, at: new Date().toISOString(), localOnly: true, networkEnabled: false, bodyStored: false };
1144
+ const rememberZpeerEvent = (event: { kind: NonNullable<typeof state.zobLive.lastEvent>["kind"]; roomId?: string; fromAlias?: string; toAlias?: string; status: string; reason?: string; msgId?: string; taskHash?: string; outputHash?: string; priority?: ZpeerInterruptPriority; interruptMode?: ZpeerInterruptMode; interruptStatus?: ZpeerInterruptStatus }): NonNullable<typeof state.zobLive.lastEvent> | undefined => {
1145
+ const recorded = recordZpeerRuntimeEvent(state, event);
1146
+ return recorded.accepted ? recorded.event : undefined;
1145
1147
  };
1146
1148
 
1147
- const emitZpeerEvent = (event: Parameters<typeof rememberZpeerEvent>[0]): void => {
1148
- rememberZpeerEvent(event);
1149
+ const emitZpeerEvent = (event: Parameters<typeof rememberZpeerEvent>[0]): boolean => {
1150
+ const recorded = rememberZpeerEvent(event);
1151
+ if (!recorded) return false;
1149
1152
  void pi.sendMessage({
1150
1153
  customType: "zob-zpeer-event",
1151
1154
  content: `ZPeer ${event.kind} ${event.fromAlias ? `@${event.fromAlias}` : "?"} → ${event.toAlias ? `@${event.toAlias}` : "?"} ${event.status}`,
1152
1155
  display: true,
1153
- details: { ...state.zobLive.lastEvent },
1154
- }, { triggerTurn: false });
1156
+ details: { ...recorded },
1157
+ }, { triggerTurn: false, deliverAs: "nextTurn" });
1158
+ return true;
1155
1159
  };
1156
1160
 
1157
1161
  pi.registerCommand("zagent", {
@@ -1682,6 +1686,7 @@ export function registerZliveCommands(pi: ExtensionAPI, state: HarnessRuntimeSta
1682
1686
  maxReinjects: sendMode.maxReinjects,
1683
1687
  onFeedback: (feedback) => {
1684
1688
  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";
1689
+ if (feedback.kind === "waiting" && (sendMode.requireResponse === true || sendMode.mode !== "async")) return;
1685
1690
  const feedbackRoomId = feedback.result.roomId ?? eventRoomId;
1686
1691
  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 });
1687
1692
  },
@@ -40,6 +40,7 @@ import { capturePlanArtifact } from "./plan-capture.js";
40
40
  import { redactPlanTodosBlockForDisplay } from "../domains/plan/plan-todos.js";
41
41
  import { applyMode, renderHarnessWidget } from "./widget.js";
42
42
  import { createStopRestoreCandidate, markStopRestoreAssistantMessage, markStopRestoreToolVisible } from "./stop-restore.js";
43
+ import { recordZpeerRuntimeEvent } from "./zpeer-events.js";
43
44
 
44
45
  function safelyUpdateZobLivePeer(repoRoot: string, action: "register" | "touch" | "unregister"): void {
45
46
  try {
@@ -51,14 +52,8 @@ function safelyUpdateZobLivePeer(repoRoot: string, action: "register" | "touch"
51
52
  }
52
53
  }
53
54
 
54
- function setZpeerLastEvent(state: HarnessRuntimeState, event: Omit<ZobLiveLastEvent, "at" | "localOnly" | "networkEnabled" | "bodyStored"> & { at?: string }): void {
55
- state.zobLive.lastEvent = {
56
- ...event,
57
- at: event.at ?? new Date().toISOString(),
58
- localOnly: true,
59
- networkEnabled: false,
60
- bodyStored: false,
61
- };
55
+ function setZpeerLastEvent(state: HarnessRuntimeState, event: Omit<ZobLiveLastEvent, "at" | "localOnly" | "networkEnabled" | "bodyStored" | "terminal" | "statusRank" | "superseded" | "supersededByStatus"> & { at?: string }): ZobLiveLastEvent {
56
+ return recordZpeerRuntimeEvent(state, event).event;
62
57
  }
63
58
 
64
59
  function clearPassivePeerWaitForResponse(state: HarnessRuntimeState, envelope: { msgId?: string; runId?: string; sender?: string; type?: string }): void {
@@ -244,7 +239,7 @@ function handleInboundZpeerPrompt(pi: ExtensionAPI, state: HarnessRuntimeState,
244
239
  content: priority === "normal" ? "ZPeer inbound prompt received (transient body delivered only to agent turn)" : `ZPeer ${priority} inbound prompt received (transient body delivered only to agent turn)`,
245
240
  display: true,
246
241
  details: { kind: "inbound", roomId, fromAlias: envelope.sender, toAlias: envelope.receiver, status: interruptStatus ?? "prompt_received", msgId: envelope.msgId, taskHash: envelope.taskHash, priority, interruptMode, interruptStatus, bodyStored: false, localOnly: true, networkEnabled: false },
247
- }, { triggerTurn: false });
242
+ }, { triggerTurn: false, deliverAs: "nextTurn" });
248
243
  void pi.sendMessage({
249
244
  customType: "zob-coms-inbound",
250
245
  content: envelope.transientPrompt ?? "",
@@ -620,7 +615,7 @@ function formatZpeerLastEvent(event: ZobLiveLastEvent | undefined): string {
620
615
 
621
616
  function buildZpeerAwarenessPrompt(state: HarnessRuntimeState, repoRoot: string): string {
622
617
  if (!state.zobLive.peerCard) {
623
- return "\n\nZPEER AWARENESS\n- local peer endpoint: unavailable this turn\n- Use zpeer_ask with mode=\"async\" or /zpeer only when useful or user-requested for peer coordination; avoid spam/loops and do not invent hidden worker-to-worker chat.";
618
+ return "\n\nZPEER AWARENESS\n- local peer endpoint: unavailable this turn\n- Use zpeer_ask with mode=\"async\" or /zpeer only when useful or user-requested for peer coordination; avoid spam/loops and do not invent hidden worker-to-worker chat.\n- If the user explicitly asks for a reply, status, or confirmation, use requireResponse=true with mode=\"await\" or mode=\"long\"; status=waiting is delivery-only, not evidence that the peer is working or done.";
624
619
  }
625
620
  const summaries = buildZpeerPeerRoomSummaries(repoRoot, state.zobLive.peerCard);
626
621
  const activeSummary = summaries.find((summary) => summary.active) ?? summaries[0];
@@ -637,7 +632,7 @@ function buildZpeerAwarenessPrompt(state: HarnessRuntimeState, repoRoot: string)
637
632
  const activePeerAliases = (activeSummary?.onlineAliases ?? []).filter((alias) => alias !== activeSelfAlias).slice(0, 8).map((alias) => `@${alias}`);
638
633
  const activeUnavailable = (activeSummary?.stale ?? 0) + (activeSummary?.offline ?? 0);
639
634
  const activeDuplicateLine = activeSummary && activeSummary.duplicateAliases.length > 0 ? `\n- duplicate live aliases: ${activeSummary.duplicateAliases.map((alias) => `@${alias}`).join(", ")}` : "";
640
- return `\n\nZPEER AWARENESS (transient, rebuilt each turn)\n- active room: ${activeSummary?.roomId ?? "default"}\n- memberships: ${memberships}\n- self: @${activeSelfAlias}\n- online peers: ${activePeerAliases.join(", ") || "none"}\n- unavailable peers: ${activeUnavailable} (stale=${activeSummary?.stale ?? 0}, offline=${activeSummary?.offline ?? 0})${activeDuplicateLine}\n- rooms:\n${roomLines.join("\n") || " - none"}\n- Use zpeer_ask with explicit roomId when targeting a non-active room.\n- posture: local_socket-only, room-scoped, hash-only durable ledgers, bodyStored=false, networkEnabled=false\n- For non-trivial review/debug/planning peer coordination, agents may use zpeer_ask with mode=\"async\" so the request is visible, governed, and non-blocking; /zpeer remains the interactive command path.\n- Passive wait rule: if the only remaining action is waiting for ZPeer/coms replies, stop the turn and remain idle; do not poll, call tools, or continue just to wait.\n- Use ZPeer only when useful or user-requested; avoid spam, duplicate asks, and reply loops; do not use it for hidden free chat or to bypass topology/safety gates.\n- Raw ZPeer bodies are transient; durable records must remain hash-only/bodyStored=false.\n- last ZPeer event: ${formatZpeerLastEvent(state.zobLive.lastEvent)}`;
635
+ return `\n\nZPEER AWARENESS (transient, rebuilt each turn)\n- active room: ${activeSummary?.roomId ?? "default"}\n- memberships: ${memberships}\n- self: @${activeSelfAlias}\n- online peers: ${activePeerAliases.join(", ") || "none"}\n- unavailable peers: ${activeUnavailable} (stale=${activeSummary?.stale ?? 0}, offline=${activeSummary?.offline ?? 0})${activeDuplicateLine}\n- rooms:\n${roomLines.join("\n") || " - none"}\n- Use zpeer_ask with explicit roomId when targeting a non-active room.\n- posture: local_socket-only, room-scoped, hash-only durable ledgers, bodyStored=false, networkEnabled=false\n- For non-trivial review/debug/planning peer coordination, agents may use zpeer_ask with mode=\"async\" so the request is visible, governed, and non-blocking; /zpeer remains the interactive command path.\n- If the user explicitly asks for a reply, status, or confirmation, use requireResponse=true with mode=\"await\" or mode=\"long\"; status=waiting is delivery-only, not evidence that the peer is working or done.\n- Passive wait rule: if the only remaining action is waiting for ZPeer/coms replies, stop the turn and remain idle; do not poll, call tools, or continue just to wait.\n- Use ZPeer only when useful or user-requested; avoid spam, duplicate asks, and reply loops; do not use it for hidden free chat or to bypass topology/safety gates.\n- Raw ZPeer bodies are transient; durable records must remain hash-only/bodyStored=false.\n- last ZPeer event: ${formatZpeerLastEvent(state.zobLive.lastEvent)}`;
641
636
  }
642
637
 
643
638
  const SAME_AGENT_MODE_INTENT_PROMPT = [
@@ -785,7 +780,7 @@ async function startOrRefreshZobLiveRuntime(pi: ExtensionAPI, state: HarnessRunt
785
780
  content: envelope.errorCode === "zpeer_required_response_expired" ? "ZPeer required response expired" : "ZPeer error received",
786
781
  display: true,
787
782
  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 });
783
+ }, { triggerTurn: false, deliverAs: "nextTurn" });
789
784
  return buildZobLiveAckEnvelope(envelope);
790
785
  }
791
786
  if (envelope.type === "response") {
@@ -807,7 +802,7 @@ async function startOrRefreshZobLiveRuntime(pi: ExtensionAPI, state: HarnessRunt
807
802
  content: "ZPeer reply received (transient response available; durable records remain hash-only)",
808
803
  display: true,
809
804
  details: { kind: "reply", roomId: state.zobLive.lastEvent?.roomId, fromAlias: envelope.sender, toAlias: envelope.receiver, status: "reply", msgId: envelope.msgId, taskHash: envelope.taskHash, outputHash: envelope.outputHash, bodyStored: false, localOnly: true, networkEnabled: false },
810
- }, { triggerTurn: false });
805
+ }, { triggerTurn: false, deliverAs: "nextTurn" });
811
806
  if (!completedActiveWait && envelope.transientResponse) {
812
807
  void pi.sendMessage({
813
808
  customType: "zob-zpeer-response",
@@ -1060,7 +1055,10 @@ async function sendInboundZobLiveResponse(pi: ExtensionAPI, state: HarnessRuntim
1060
1055
  const activeInbound = activeZpeerInboundForResponse(state);
1061
1056
  const inbound = activeInbound ?? state.zobLive.inbound;
1062
1057
  if (!inbound || inbound.responseSent || !inbound.envelope.replyEndpoint) return;
1063
- if (activeInbound?.requiredResponseStatus === "expired") return;
1058
+ // ZOB-COMS-GUARDS: a late reply after expiry is now permitted (the watchdog
1059
+ // already stopped reinjecting; sender-side pendingReplies resolves to no-op).
1060
+ // Dropping this early-out recovers nudge/reply paths (e.g. a late oracle
1061
+ // review) without weakening the real anti-abuse (reinject cap + sender expire).
1064
1062
  if (state.zobLive.inboundByMsgId && !activeInbound) return;
1065
1063
  const responseText = latestAssistantText(event);
1066
1064
  if (!responseText.trim()) return;
@@ -1103,7 +1101,7 @@ async function sendInboundZobLiveResponse(pi: ExtensionAPI, state: HarnessRuntim
1103
1101
  content: "ZPeer response sent (transient response delivered over local socket; durable records remain hash-only)",
1104
1102
  display: true,
1105
1103
  details: { kind: "response_sent", roomId: state.zobLive.lastEvent?.roomId, fromAlias: inbound.envelope.receiver, toAlias: inbound.envelope.sender, status: "response_sent", msgId: inbound.envelope.msgId, taskHash: inbound.envelope.taskHash, outputHash: responseEnvelope.outputHash, priority: activeInbound?.priority, interruptMode: activeInbound?.interruptMode, bodyStored: false, localOnly: true, networkEnabled: false },
1106
- }, { triggerTurn: false });
1104
+ }, { triggerTurn: false, deliverAs: "nextTurn" });
1107
1105
  }
1108
1106
 
1109
1107
  export function registerHarnessEvents(pi: ExtensionAPI, state: HarnessRuntimeState): void {
@@ -1118,7 +1116,7 @@ export function registerHarnessEvents(pi: ExtensionAPI, state: HarnessRuntimeSta
1118
1116
  const taskHash = typeof details.taskHash === "string" ? details.taskHash : undefined;
1119
1117
  const outputHash = typeof details.outputHash === "string" ? details.outputHash : undefined;
1120
1118
  const route = fromAlias || toAlias ? `${fromAlias ? `@${fromAlias}` : "?"} → ${toAlias ? `@${toAlias}` : "?"}` : "room status";
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";
1119
+ const statusColor = status === "reply" || status === "completed" || status === "sent" || status === "delivered" || status === "prompt_received" || status === "response_sent" ? "success" : status === "blocked" || status === "timeout" || status === "error" || status === "expired" || status === "required_response_expired" ? "warning" : "muted";
1122
1120
  const line = [
1123
1121
  theme.fg("accent", "◆ ZPeer"),
1124
1122
  theme.fg("muted", kind),
@@ -354,13 +354,13 @@ const ZpeerAskParams = Type.Object({
354
354
  targetAlias: Type.String({ description: "ZPeer target alias in the current local room. May include or omit the leading @." }),
355
355
  message: Type.String({ description: "Transient peer request body. Used only for local live delivery; never persisted in durable ledgers or reports." }),
356
356
  roomId: Type.Optional(Type.String({ description: "Optional ZPeer room id. Defaults to the current active local room." })),
357
- mode: Type.Optional(StringEnum(["async", "await", "long"] as const, { description: "Send mode. Default async for agent-initiated coordination.", default: "async" })),
357
+ mode: Type.Optional(StringEnum(["async", "await", "long"] as const, { description: "Send mode. Default async for non-blocking coordination; when an actual reply/status is required, use await or long with requireResponse=true.", default: "async" })),
358
358
  reason: Type.Optional(Type.String({ description: "Optional transient coordination/interrupt reason. Required for force; hashed only in visible metadata; raw value is not persisted." })),
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
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." })),
363
+ requireResponse: Type.Optional(Type.Boolean({ description: "Opt in to a real msgId-correlated required response. Set true whenever the user asks for a reply, status, or confirmation; the sender waits only for the exact msgId reply and expires explicitly." })),
364
364
  maxReinjects: Type.Optional(Type.Number({ description: "Bounded receiver reminder budget for requireResponse. Default 1, capped 0..3." })),
365
365
  });
366
366
 
@@ -49,6 +49,10 @@ export interface ZobLiveLastEvent {
49
49
  priority?: ZpeerInterruptPriority;
50
50
  interruptMode?: ZpeerInterruptMode;
51
51
  interruptStatus?: ZpeerInterruptStatus;
52
+ terminal?: boolean;
53
+ statusRank?: number;
54
+ superseded?: boolean;
55
+ supersededByStatus?: string;
52
56
  at: string;
53
57
  localOnly: true;
54
58
  networkEnabled: false;
@@ -98,6 +102,7 @@ export interface ZobLiveRuntimeState {
98
102
  inboundQueue?: string[];
99
103
  activeInboundMsgId?: string;
100
104
  lastEvent?: ZobLiveLastEvent;
105
+ latestZpeerEventByMsgId?: Record<string, ZobLiveLastEvent>;
101
106
  passivePeerWait?: ZobPassivePeerWaitState;
102
107
  leaseOwned?: boolean;
103
108
  leaseStatus?: "owned" | "blocked" | "unavailable";
@@ -34,11 +34,23 @@ import {
34
34
  } from "../domains/topology/coms.js";
35
35
  import { loadTeamDefinition, validateTeamDefinition } from "../domains/topology/teams.js";
36
36
  import type { HarnessRuntimeState } from "./state.js";
37
+ import { isCurrentZpeerRuntimeEvent, recordZpeerRuntimeEvent } from "./zpeer-events.js";
37
38
 
38
39
  const SHA256_HEX = /^[a-f0-9]{64}$/i;
39
- const ZPEER_AGENT_ASK_RATE_LIMIT_PER_MINUTE = 50;
40
- const ZPEER_AGENT_URGENT_RATE_LIMIT_PER_MINUTE = 10;
41
- const ZPEER_AGENT_FORCE_RATE_LIMIT_PER_MINUTE = 3;
40
+ // ZOB-COMS-GUARDS: per-agent zpeer rate caps are env-configurable with a finite
41
+ // ceiling (never Infinity) so trusted closed topologies (e.g. project-transposer's
42
+ // 64-agent fan-out) don't trip the default 50/10/3 while real floods are still
43
+ // bounded. Defaults unchanged for open/hostile topologies.
44
+ const clampRate = (v: string | undefined, fallback: number): number => Math.max(1, Math.min(1000, Number.parseInt(v ?? "") || fallback));
45
+ const ZPEER_AGENT_ASK_RATE_LIMIT_PER_MINUTE = clampRate(process.env.ZOB_ZPEER_ASK_RATE_LIMIT_PER_MINUTE, 50);
46
+ const ZPEER_AGENT_URGENT_RATE_LIMIT_PER_MINUTE = clampRate(process.env.ZOB_ZPEER_URGENT_RATE_LIMIT_PER_MINUTE, 10);
47
+ const ZPEER_AGENT_FORCE_RATE_LIMIT_PER_MINUTE = clampRate(process.env.ZOB_ZPEER_FORCE_RATE_LIMIT_PER_MINUTE, 3);
48
+ // ZOB-COMS-GUARDS: the loop-keyword block (reject any zpeer_ask whose message
49
+ // text contains 'zpeer_ask'/'/zpeer') over-fires on trusted topologies whose
50
+ // prompts legitimately quote these tokens. Env-bypassed for trusted topologies;
51
+ // default (unset) keeps current behavior for open/hostile use. Genuine anti-loop
52
+ // (duplicate guard below + reinject cap + sender-side expire) is preserved.
53
+ const ZPEER_RECURSION_KEYWORD_GUARD_DISABLED = /^(1|true|yes|on)$/i.test(process.env.ZOB_ZPEER_DISABLE_RECURSION_KEYWORD_GUARD ?? "");
42
54
 
43
55
  type ZpeerFallbackHookFn = ZpeerFallbackDelivery;
44
56
 
@@ -141,7 +153,7 @@ function zpeerAskGuardBlock(state: HarnessRuntimeState, params: ZpeerAskToolPara
141
153
  if (selfAlias && zpeerAliasesEquivalent(targetAlias, selfAlias)) return "cannot send to self";
142
154
  const roomId = safeZpeerRoomId(params.roomId) ?? currentRoomId;
143
155
  if (params.roomId && !safeZpeerRoomId(params.roomId)) return "invalid room id";
144
- if (/\b(zpeer_ask|\/zpeer)\b/i.test(params.message)) return "loop guard blocked recursive ZPeer instruction";
156
+ if (!ZPEER_RECURSION_KEYWORD_GUARD_DISABLED && /\b(zpeer_ask|\/zpeer)\b/i.test(params.message)) return "loop guard blocked recursive ZPeer instruction";
145
157
  const messageHash = sha256(params.message);
146
158
  const now = Date.now();
147
159
  const windowMs = 60_000;
@@ -263,7 +275,11 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
263
275
  name: "zpeer_ask",
264
276
  label: "ZPeer Ask",
265
277
  description: "Ask a visible local ZPeer via the governed room-scoped local_socket path. Defaults to mode=async; raw message/reply bodies are transient and durable metadata is hash-only.",
266
- promptSnippet: "Use zpeer_ask with mode=\"async\" for useful non-trivial peer review/debug/planning coordination; if it returns waiting and nothing else is actionable, stop/idle instead of polling.",
278
+ promptSnippet: "Use zpeer_ask with mode=\"async\" for non-blocking coordination; when an actual reply/status is required, set requireResponse=true and use mode=\"await\" or mode=\"long\".",
279
+ promptGuidelines: [
280
+ "When the user asks for an actual reply, current status, confirmation, or a response requirement, call zpeer_ask with requireResponse=true and mode=\"await\" or mode=\"long\".",
281
+ "Treat zpeer_ask status=waiting as delivery-only: it does not prove the peer read the request, is working, or finished. If nothing else is actionable, stop/idle instead of polling.",
282
+ ],
267
283
  parameters: ZpeerAskParams,
268
284
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
269
285
  if (!state?.zobLive.peerCard) return { content: [{ type: "text", text: "zpeer_ask blocked: current session has not registered a local peer endpoint" }], details: { schema: "zob.zpeer-ask-result.v1", status: "blocked", reason: "local_peer_unavailable", bodyStored: false } };
@@ -275,16 +291,29 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
275
291
  const requestedFromAlias = peerAliasInRoom(self, requestedRoomId) ?? self.zpeerAlias;
276
292
  const interrupt = normalizeZpeerInterrupt(params);
277
293
  const guardReason = interrupt.error ?? zpeerAskGuardBlock(state, params, requestedFromAlias, requestedRoomId, interrupt.priority);
278
- const emitZpeerAskEvent = (event: { kind: NonNullable<HarnessRuntimeState["zobLive"]["lastEvent"]>["kind"]; status: string; reason?: string; msgId?: string; roomId?: string; taskHash?: string; outputHash?: string; interruptStatus?: ZpeerInterruptStatus }): void => {
294
+ const queuedZpeerEvents: Array<{ event: NonNullable<HarnessRuntimeState["zobLive"]["lastEvent"]>; content: string; details: Record<string, unknown> }> = [];
295
+ const flushZpeerAskEvents = async (): Promise<void> => {
296
+ for (const queued of queuedZpeerEvents) {
297
+ if (!isCurrentZpeerRuntimeEvent(state, queued.event)) continue;
298
+ await Promise.resolve(pi.sendMessage({
299
+ customType: "zob-zpeer-event",
300
+ content: queued.content,
301
+ display: true,
302
+ details: queued.details,
303
+ }, { triggerTurn: false, deliverAs: "nextTurn" }));
304
+ }
305
+ };
306
+ const emitZpeerAskEvent = (event: { kind: NonNullable<HarnessRuntimeState["zobLive"]["lastEvent"]>["kind"]; status: string; reason?: string; msgId?: string; roomId?: string; taskHash?: string; outputHash?: string; interruptStatus?: ZpeerInterruptStatus }): boolean => {
279
307
  const eventRoomId = event.roomId ?? requestedRoomId;
280
308
  const fromAlias = peerAliasInRoom(self, eventRoomId) ?? requestedFromAlias;
281
- state.zobLive.lastEvent = { kind: event.kind, roomId: eventRoomId, fromAlias, toAlias: targetAlias, status: event.status, reason: event.reason, msgId: event.msgId, taskHash: event.taskHash, outputHash: event.outputHash, priority: interrupt.priority, interruptMode: interrupt.interruptMode, interruptStatus: event.interruptStatus, at: new Date().toISOString(), localOnly: true, networkEnabled: false, bodyStored: false };
282
- void pi.sendMessage({
283
- customType: "zob-zpeer-event",
309
+ const recorded = recordZpeerRuntimeEvent(state, { kind: event.kind, roomId: eventRoomId, fromAlias, toAlias: targetAlias, status: event.status, reason: event.reason, msgId: event.msgId, taskHash: event.taskHash, outputHash: event.outputHash, priority: interrupt.priority, interruptMode: interrupt.interruptMode, interruptStatus: event.interruptStatus });
310
+ if (!recorded.accepted) return false;
311
+ queuedZpeerEvents.push({
312
+ event: recorded.event,
284
313
  content: `ZPeer agent-request @${fromAlias ?? "?"} → @${targetAlias} ${event.status}`,
285
- display: true,
286
- details: { ...state.zobLive.lastEvent, source: "agent-request", mode, priority: interrupt.priority, interruptMode: interrupt.interruptMode, bodyStored: false, localOnly: true, networkEnabled: false },
287
- }, { triggerTurn: false });
314
+ details: { ...recorded.event, source: "agent-request", mode, priority: interrupt.priority, interruptMode: interrupt.interruptMode, bodyStored: false, localOnly: true, networkEnabled: false },
315
+ });
316
+ return true;
288
317
  };
289
318
  const taskHash = params.message.trim() ? sha256(params.message) : undefined;
290
319
  if (guardReason) {
@@ -293,6 +322,7 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
293
322
  const result = { schema: "zob.zpeer-ask-result.v1", status: "blocked", reason: guardReason, targetAlias, taskHash, priority: interrupt.priority, interruptMode: interrupt.interruptMode, interruptStatus, bodyStored: false };
294
323
  emitZpeerAskEvent({ kind: "blocked", status: "blocked", reason: guardReason, taskHash, interruptStatus });
295
324
  pi.appendEntry("zob-zpeer", { schema: "zob.zpeer-ask.v1", action: "agent_request_blocked", mode, status: "blocked", priority: interrupt.priority, interruptMode: interrupt.interruptMode, interruptStatus, reasonHash: sha256(guardReason), targetAliasHash: sha256(targetAlias), roomIdHash: sha256(requestedRoomId), taskHash, reasonInputHash: params.reason ? sha256(params.reason) : undefined, interruptReasonHash: interrupt.interruptReasonHash, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
325
+ await flushZpeerAskEvents();
296
326
  return { content: [{ type: "text", text: `zpeer_ask blocked: ${guardReason}` }], details: result };
297
327
  }
298
328
  const timeoutMs = boundedZpeerAskTimeoutMs(mode, params.timeoutMs);
@@ -318,11 +348,12 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
318
348
  updatePassivePeerWaitState(state, result, { roomId: requestedRoomId, targetAlias });
319
349
  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, deliveryMethod: result.deliveryMethod, fallbackDelivery: result.fallback_delivery, bestEffort: result.best_effort, localOnly: true, networkEnabled: false, bodyStored: false, promptBodiesStored: false, outputBodiesStored: false, generatedAt: new Date().toISOString() });
320
350
  const ok = result.status === "reply" || result.status === "completed" || result.status === "waiting" || result.status === "delivered";
321
- const passiveWaitSuffix = result.status === "waiting" ? " · idle/passive wait: no follow-up turn queued; stop if no other action is actionable" : "";
351
+ const passiveWaitSuffix = result.status === "waiting" ? " · delivery-only: no reply received; not evidence the target is working or done · idle/passive wait: no follow-up turn queued; stop if no other action is actionable" : "";
322
352
  const transientReplyText = (result.status === "reply" || result.status === "completed") && result.transientResponse
323
353
  ? `\n\nTransient ZPeer reply (not stored in .pi/coms):\n${result.transientResponse}`
324
354
  : "";
325
355
  const interruptSuffix = result.interruptStatus ? ` interrupt=${result.interruptStatus}` : interrupt.priority !== "normal" ? ` priority=${interrupt.priority}` : "";
356
+ await flushZpeerAskEvents();
326
357
  return { content: [{ type: "text", text: ok ? `zpeer_ask ${result.status}: @${result.targetAlias ?? targetAlias}${interruptSuffix}${result.outputHash ? ` outputHash=${result.outputHash}` : ""}${passiveWaitSuffix}${transientReplyText}` : `zpeer_ask ${result.status}: ${result.reason ?? "see metadata"}${interruptSuffix}` }], details: { schema: "zob.zpeer-ask-result.v1", mode, ...result } };
327
358
  },
328
359
  });
@@ -338,7 +369,13 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
338
369
  const responseText = params.message ?? "";
339
370
  const outputHash = responseText.trim() ? sha256(responseText) : undefined;
340
371
  const inbound = msgId ? state?.zobLive.inboundByMsgId?.[msgId] : undefined;
341
- 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;
372
+ 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" : // ZOB-COMS-GUARDS: a late reply after the requireResponse watchdog expired is
373
+ // now PERMITTED. The watchdog already stops reinjecting once reinjectCount hits
374
+ // maxReinjects (events.ts) and the sender's pendingReplies entry resolves to a
375
+ // no-op on a late envelope, so a late reply is harmless at the sender and
376
+ // recovers nudge/reply paths (e.g. a late oracle review). The 'replied' /
377
+ // responseSent guard above still prevents double-replies.
378
+ !inbound.envelope.replyEndpoint ? "ZPeer inbound msgId has no reply endpoint" : undefined;
342
379
  if (block) {
343
380
  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() });
344
381
  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 } };
@@ -357,8 +394,8 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
357
394
  state.zobLive.activeInboundMsgId = undefined;
358
395
  state.zobLive.inboundQueue = (state.zobLive.inboundQueue ?? []).filter((candidate) => candidate !== inbound.envelope.msgId);
359
396
  const roomId = inbound.envelope.runId?.startsWith("zpeer:") ? inbound.envelope.runId.slice("zpeer:".length) : undefined;
360
- 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 };
361
- void pi.sendMessage({ customType: "zob-zpeer-event", content: "ZPeer explicit reply sent", display: true, details: { ...state.zobLive.lastEvent } }, { triggerTurn: false });
397
+ const recorded = recordZpeerRuntimeEvent(state, { 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 });
398
+ if (recorded.accepted) void pi.sendMessage({ customType: "zob-zpeer-event", content: "ZPeer explicit reply sent", display: true, details: { ...recorded.event } }, { triggerTurn: false, deliverAs: "nextTurn" });
362
399
  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() });
363
400
  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 } };
364
401
  } catch (error) {
@@ -0,0 +1,53 @@
1
+ import { annotateZpeerStatus, shouldAcceptZpeerStatusUpdate } from "../domains/coms/coms-v2/zpeer-status.js";
2
+ import type { HarnessRuntimeState, ZobLiveLastEvent } from "./state.js";
3
+
4
+ export type ZpeerRuntimeEventInput = Omit<ZobLiveLastEvent, "at" | "localOnly" | "networkEnabled" | "bodyStored" | "terminal" | "statusRank" | "superseded" | "supersededByStatus"> & { at?: string };
5
+
6
+ export interface ZpeerRuntimeEventRecordResult {
7
+ accepted: boolean;
8
+ event: ZobLiveLastEvent;
9
+ current?: ZobLiveLastEvent;
10
+ }
11
+
12
+ function materializeZpeerRuntimeEvent(event: ZpeerRuntimeEventInput): ZobLiveLastEvent {
13
+ return annotateZpeerStatus({
14
+ ...event,
15
+ at: event.at ?? new Date().toISOString(),
16
+ localOnly: true as const,
17
+ networkEnabled: false as const,
18
+ bodyStored: false as const,
19
+ });
20
+ }
21
+
22
+ export function recordZpeerRuntimeEvent(state: HarnessRuntimeState, event: ZpeerRuntimeEventInput): ZpeerRuntimeEventRecordResult {
23
+ const next = materializeZpeerRuntimeEvent(event);
24
+ if (!next.msgId) {
25
+ state.zobLive.lastEvent = next;
26
+ return { accepted: true, event: next };
27
+ }
28
+
29
+ state.zobLive.latestZpeerEventByMsgId ??= {};
30
+ const current = state.zobLive.latestZpeerEventByMsgId[next.msgId];
31
+ if (!shouldAcceptZpeerStatusUpdate(current, next)) {
32
+ return {
33
+ accepted: false,
34
+ current,
35
+ event: {
36
+ ...next,
37
+ superseded: true,
38
+ supersededByStatus: current?.status,
39
+ },
40
+ };
41
+ }
42
+
43
+ state.zobLive.latestZpeerEventByMsgId[next.msgId] = next;
44
+ state.zobLive.lastEvent = next;
45
+ return { accepted: true, event: next, current: next };
46
+ }
47
+
48
+ export function isCurrentZpeerRuntimeEvent(state: HarnessRuntimeState, event: ZobLiveLastEvent): boolean {
49
+ if (!event.msgId) return true;
50
+ const current = state.zobLive.latestZpeerEventByMsgId?.[event.msgId];
51
+ if (!current) return true;
52
+ return current.at === event.at && current.status === event.status && current.kind === event.kind;
53
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zob-harness",
3
- "version": "0.15.1",
3
+ "version": "0.15.3",
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",
@@ -471,10 +471,11 @@ async function main() {
471
471
  const registeredTools = new Map();
472
472
  const appendEntries = [];
473
473
  const feedMessages = [];
474
+ const feedDeliveries = [];
474
475
  const mockPi = {
475
476
  registerTool: (tool) => registeredTools.set(tool.name, tool),
476
477
  appendEntry: (customType, data) => appendEntries.push({ customType, data }),
477
- sendMessage: (message) => { feedMessages.push(message); return Promise.resolve(); },
478
+ sendMessage: (message, options) => { feedMessages.push(message); feedDeliveries.push({ message, options }); return Promise.resolve(); },
478
479
  };
479
480
  const toolState = { zobLive: { peerCard: alpha, pendingReplies: { wait: waitForReply } } };
480
481
  toolsComs.registerComsTools(mockPi, toolState);
@@ -488,6 +489,9 @@ async function main() {
488
489
  assert(JSON.stringify(toolResult?.content ?? '').includes('idle/passive wait: no follow-up turn queued'), 'zpeer_ask async waiting result must tell the agent to idle instead of polling/continuing just to wait');
489
490
  const asyncWaitingFeed = feedMessages.filter((item) => item.customType === 'zob-zpeer-event' && item.details?.source === 'agent-request' && item.details?.status === 'waiting' && item.details?.msgId === toolResult?.details?.msgId);
490
491
  assert(asyncWaitingFeed.length === 1, `zpeer_ask async must emit exactly one compact waiting feed event, got ${asyncWaitingFeed.length}`);
492
+ const asyncWaitingDelivery = feedDeliveries.find((item) => item.message.details?.source === 'agent-request' && item.message.details?.msgId === toolResult?.details?.msgId);
493
+ assert(asyncWaitingDelivery?.options?.triggerTurn === false && asyncWaitingDelivery?.options?.deliverAs === 'nextTurn', 'zpeer_ask waiting feed must use nextTurn delivery and never create an extra steering/follow-up turn');
494
+ assert(JSON.stringify(toolResult?.content ?? '').includes('delivery-only: no reply received; not evidence the target is working or done'), 'zpeer_ask waiting result must not imply peer progress from delivery-only status');
491
495
  assert(!feedMessages.some((item) => item.customType === 'zob-zpeer-event' && item.details?.source === 'agent-request' && item.details?.kind === 'attempt'), 'zpeer_ask async must not emit a pre-ACK attempt feed event');
492
496
  assert(!containsRawBody(toolResult), 'zpeer_ask async tool result must not echo the full prompt/response body');
493
497
  assert(appendEntries.some((item) => item.customType === 'zob-zpeer' && item.data?.schema === 'zob.zpeer-ask.v1' && item.data?.action === 'agent_request' && item.data?.mode === 'async' && item.data?.bodyStored === false), 'zpeer_ask must append hash-only visible command metadata');
@@ -538,6 +542,8 @@ async function main() {
538
542
  const explicitReplyCountBefore = receivedResponses.length;
539
543
  const explicitReplyResult = await zpeerReply.execute('tool-call-zpeer-reply', { msgId: explicitReplyMsgId, message: rawResponse }, undefined, undefined, { cwd: repoRoot });
540
544
  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');
545
+ const explicitReplyFeedDelivery = feedDeliveries.find((item) => item.message.details?.kind === 'response_sent' && item.message.details?.msgId === explicitReplyMsgId);
546
+ assert(explicitReplyFeedDelivery?.options?.triggerTurn === false && explicitReplyFeedDelivery?.options?.deliverAs === 'nextTurn', 'zpeer_reply feed must use nextTurn delivery and never create an extra steering/follow-up turn');
541
547
  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');
542
548
  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');
543
549
  const wrongExplicitReply = await zpeerReply.execute('tool-call-zpeer-reply-wrong', { msgId: 'missing-msgid', message: rawResponse }, undefined, undefined, { cwd: repoRoot });
@@ -61,10 +61,11 @@ const zpeerAskMatches = toolsComs.match(/name: "zpeer_ask"/g) ?? [];
61
61
  if (zpeerAskMatches.length !== 1) failures.push(`expected exactly one zpeer_ask tool, found ${zpeerAskMatches.length}`);
62
62
  const zpeerReplyMatches = toolsComs.match(/name: "zpeer_reply"/g) ?? [];
63
63
  if (zpeerReplyMatches.length !== 1) failures.push(`expected exactly one zpeer_reply tool, found ${zpeerReplyMatches.length}`);
64
- 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', 'buildProjectTransposerFallbackDelivery(ctx.cwd)', 'PROJECT_TRANSPOSER_ZPEER_FALLBACK_HOOK', 'fallbackDelivery: result.fallback_delivery', 'bestEffort: result.best_effort', 'bodyStored: false', 'promptBodiesStored: false', 'outputBodiesStored: false']) {
64
+ for (const needle of ['parameters: ZpeerAskParams', 'sendZpeerPrompt(ctx.cwd', 'mode = params.mode ?? "async"', 'requireResponse = params.requireResponse === true', 'pendingReplies.wait(msgId, timeoutMs, { requireResponse })', 'maxReinjects', 'zpeerAskGuardBlock', 'clampRate(process.env.ZOB_ZPEER_ASK_RATE_LIMIT_PER_MINUTE, 50)', 'clampRate(process.env.ZOB_ZPEER_URGENT_RATE_LIMIT_PER_MINUTE, 10)', 'clampRate(process.env.ZOB_ZPEER_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', 'delivery-only: no reply received; not evidence the target is working or done', 'promptGuidelines:', 'requireResponse=true and mode=\\"await\\" or mode=\\"long\\"', 'customType: "zob-zpeer-event"', 'source: "agent-request"', 'action: "agent_request"', 'feedbackEmittedTerminal', 'reasonInputHash', 'interruptReasonHash', 'buildProjectTransposerFallbackDelivery(ctx.cwd)', 'PROJECT_TRANSPOSER_ZPEER_FALLBACK_HOOK', 'fallbackDelivery: result.fallback_delivery', 'bestEffort: result.best_effort', 'bodyStored: false', 'promptBodiesStored: false', 'outputBodiesStored: false']) {
65
65
  if (!toolsComs.includes(needle)) failures.push(`zpeer_ask tool missing ${needle}`);
66
66
  }
67
- 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']) {
67
+ if (!toolsComs.includes('}, { triggerTurn: false, deliverAs: "nextTurn" }')) failures.push('zpeer tool feed cards must use nextTurn delivery so metadata-only UI cannot create steering turns');
68
+ for (const needle of ['parameters: ZpeerReplyParams', 'buildZobLiveResponseEnvelope', 'sendZobLocalEnvelope(replyEndpoint', 'replyToMsgId: inbound.envelope.msgId', 'action: "reply"', 'action: "reply_blocked"', 'status: "response_sent"', 'late reply after the requireResponse watchdog expired']) {
68
69
  if (!toolsComs.includes(needle)) failures.push(`zpeer_reply tool missing ${needle}`);
69
70
  }
70
71
  if (toolsComs.includes('emitZpeerAskEvent({ kind: "attempt", status: "agent-request"')) failures.push('zpeer_ask async must not emit pre-ACK attempt feed noise');
@@ -178,6 +179,7 @@ for (const needle of ['customType: "zob-zpeer-event"', 'if (sendMode.mode !== "a
178
179
  if (!command.includes(needle)) failures.push(`zpeer command missing UX event/status support ${needle}`);
179
180
  }
180
181
  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');
182
+ if (!command.includes('}, { triggerTurn: false, deliverAs: "nextTurn" });')) failures.push('/zpeer feed cards must use nextTurn delivery so metadata-only UI cannot create steering turns');
181
183
  for (const forbidden of ['transientPrompt:', 'transientResponse:', 'prompt:', 'output:', 'content:', 'message:', 'text:', 'diff:', 'patch:']) {
182
184
  const appendBlocks = [...command.matchAll(/appendEntry\("zob-zpeer",\s*\{[\s\S]*?\}\);/g)].map((m) => m[0]);
183
185
  if (appendBlocks.some((block) => block.includes(forbidden))) failures.push(`zpeer appendEntry contains forbidden key ${forbidden}`);
@@ -215,6 +217,9 @@ for (const needle of ['readZpeerNewCarryoverProfile(repoRoot)', 'const carryover
215
217
  }
216
218
  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');
217
219
  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');
220
+ const eventZpeerFeedFragments = events.split('customType: "zob-zpeer-event"').slice(1);
221
+ if (eventZpeerFeedFragments.length === 0 || eventZpeerFeedFragments.some((fragment) => !fragment.slice(0, 800).includes('}, { triggerTurn: false, deliverAs: "nextTurn" });'))) failures.push('every runtime ZPeer feed card must use nextTurn delivery so it cannot race ahead of actionable prompt/response bodies');
222
+ if (!events.includes('status=waiting is delivery-only, not evidence that the peer is working or done')) failures.push('runtime awareness must prevent interpreting waiting as peer progress/completion evidence');
218
223
  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']) {
219
224
  if (!events.includes(needle)) failures.push(`runtime missing urgent/force/msgId-safe inbound support ${needle}`);
220
225
  }