zob-harness 0.15.1 → 0.15.2

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 },
1156
+ details: { ...recorded },
1154
1157
  }, { triggerTurn: false });
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 {
@@ -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;
@@ -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),
@@ -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;
@@ -275,16 +287,29 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
275
287
  const requestedFromAlias = peerAliasInRoom(self, requestedRoomId) ?? self.zpeerAlias;
276
288
  const interrupt = normalizeZpeerInterrupt(params);
277
289
  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 => {
290
+ const queuedZpeerEvents: Array<{ event: NonNullable<HarnessRuntimeState["zobLive"]["lastEvent"]>; content: string; details: Record<string, unknown> }> = [];
291
+ const flushZpeerAskEvents = async (): Promise<void> => {
292
+ for (const queued of queuedZpeerEvents) {
293
+ if (!isCurrentZpeerRuntimeEvent(state, queued.event)) continue;
294
+ await Promise.resolve(pi.sendMessage({
295
+ customType: "zob-zpeer-event",
296
+ content: queued.content,
297
+ display: true,
298
+ details: queued.details,
299
+ }, { triggerTurn: false }));
300
+ }
301
+ };
302
+ 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
303
  const eventRoomId = event.roomId ?? requestedRoomId;
280
304
  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",
305
+ 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 });
306
+ if (!recorded.accepted) return false;
307
+ queuedZpeerEvents.push({
308
+ event: recorded.event,
284
309
  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 });
310
+ details: { ...recorded.event, source: "agent-request", mode, priority: interrupt.priority, interruptMode: interrupt.interruptMode, bodyStored: false, localOnly: true, networkEnabled: false },
311
+ });
312
+ return true;
288
313
  };
289
314
  const taskHash = params.message.trim() ? sha256(params.message) : undefined;
290
315
  if (guardReason) {
@@ -293,6 +318,7 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
293
318
  const result = { schema: "zob.zpeer-ask-result.v1", status: "blocked", reason: guardReason, targetAlias, taskHash, priority: interrupt.priority, interruptMode: interrupt.interruptMode, interruptStatus, bodyStored: false };
294
319
  emitZpeerAskEvent({ kind: "blocked", status: "blocked", reason: guardReason, taskHash, interruptStatus });
295
320
  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() });
321
+ await flushZpeerAskEvents();
296
322
  return { content: [{ type: "text", text: `zpeer_ask blocked: ${guardReason}` }], details: result };
297
323
  }
298
324
  const timeoutMs = boundedZpeerAskTimeoutMs(mode, params.timeoutMs);
@@ -323,6 +349,7 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
323
349
  ? `\n\nTransient ZPeer reply (not stored in .pi/coms):\n${result.transientResponse}`
324
350
  : "";
325
351
  const interruptSuffix = result.interruptStatus ? ` interrupt=${result.interruptStatus}` : interrupt.priority !== "normal" ? ` priority=${interrupt.priority}` : "";
352
+ await flushZpeerAskEvents();
326
353
  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
354
  },
328
355
  });
@@ -338,7 +365,13 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
338
365
  const responseText = params.message ?? "";
339
366
  const outputHash = responseText.trim() ? sha256(responseText) : undefined;
340
367
  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;
368
+ 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
369
+ // now PERMITTED. The watchdog already stops reinjecting once reinjectCount hits
370
+ // maxReinjects (events.ts) and the sender's pendingReplies entry resolves to a
371
+ // no-op on a late envelope, so a late reply is harmless at the sender and
372
+ // recovers nudge/reply paths (e.g. a late oracle review). The 'replied' /
373
+ // responseSent guard above still prevents double-replies.
374
+ !inbound.envelope.replyEndpoint ? "ZPeer inbound msgId has no reply endpoint" : undefined;
342
375
  if (block) {
343
376
  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
377
  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 +390,8 @@ export function registerComsTools(pi: ExtensionAPI, state?: HarnessRuntimeState)
357
390
  state.zobLive.activeInboundMsgId = undefined;
358
391
  state.zobLive.inboundQueue = (state.zobLive.inboundQueue ?? []).filter((candidate) => candidate !== inbound.envelope.msgId);
359
392
  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 });
393
+ 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 });
394
+ if (recorded.accepted) void pi.sendMessage({ customType: "zob-zpeer-event", content: "ZPeer explicit reply sent", display: true, details: { ...recorded.event } }, { triggerTurn: false });
362
395
  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
396
  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
397
  } 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.2",
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",