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.
- package/.pi/capabilities/zob-public-runtime-capabilities.json +22 -2
- package/.pi/extensions/zob-harness/src/core/constants.ts +3 -3
- package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/envelope.ts +23 -0
- package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/pending-replies.ts +33 -5
- package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/response-capture.ts +2 -0
- package/.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer.ts +50 -10
- package/.pi/extensions/zob-harness/src/runtime/commands/zlive.ts +76 -7
- package/.pi/extensions/zob-harness/src/runtime/events.ts +96 -5
- package/.pi/extensions/zob-harness/src/runtime/schemas.ts +9 -1
- package/.pi/extensions/zob-harness/src/runtime/state.ts +8 -1
- package/.pi/extensions/zob-harness/src/runtime/tools-coms.ts +61 -5
- package/.pi/prompts/implement.md +1 -1
- package/.pi/prompts/orchestrator.md +1 -1
- package/.pi/skills/zob-coms-safety/SKILL.md +1 -1
- package/.pi/skills/zob-coms-v2-live/SKILL.md +2 -2
- package/package.json +1 -1
- package/scripts/zpeer-local-e2e-smoke.mjs +77 -2
- package/scripts/zpeer-static-smoke.mjs +30 -9
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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",
|
package/.pi/prompts/implement.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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(`
|
|
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']) {
|