zob-harness 0.9.1 → 0.10.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.
@@ -97,6 +97,21 @@ function readLeasesFromDir(dir: string, nowMs: number, teamName?: string): ZobLi
97
97
  .map((lease) => leaseToPeerCard(lease, nowMs));
98
98
  }
99
99
 
100
+ function peerRegistryKey(peer: Pick<ZobLivePeerCard, "projectId" | "roleId" | "sessionHash">): string {
101
+ return `${peer.projectId}:${peer.roleId}:${peer.sessionHash}`;
102
+ }
103
+
104
+ function mergeLeaseBackedAndAdhocPeers(leasePeers: ZobLivePeerCard[], agentPeers: ZobLivePeerCard[]): ZobLivePeerCard[] {
105
+ const byKey = new Map<string, ZobLivePeerCard>();
106
+ for (const peer of leasePeers) byKey.set(peerRegistryKey(peer), peer);
107
+ for (const peer of agentPeers) {
108
+ if (peer.zpeerAdhoc !== true) continue;
109
+ const key = peerRegistryKey(peer);
110
+ if (!byKey.has(key)) byKey.set(key, peer);
111
+ }
112
+ return [...byKey.values()];
113
+ }
114
+
100
115
  function boundedOfflinePeerRetentionMs(value: number | undefined): number {
101
116
  const env = Number.parseInt(process.env.ZOB_COMS_OFFLINE_PEER_RETENTION_MS ?? "", 10);
102
117
  const raw = typeof value === "number" && Number.isFinite(value) ? value : Number.isFinite(env) ? env : DEFAULT_OFFLINE_PEER_RETENTION_MS;
@@ -514,8 +529,13 @@ export function unregisterCurrentZobLivePeer(repoRoot: string, teamName = "zob-c
514
529
  export function readZobLiveRegistrySnapshot(repoRoot: string, teamName?: string): ZobLiveRegistrySnapshot {
515
530
  const { dir, projectId, kind } = projectLeasesDir(repoRoot);
516
531
  const nowMs = Date.now();
517
- if (existsSync(dir)) return buildSnapshot(projectId, kind, readLeasesFromDir(dir, nowMs, teamName), teamName);
518
532
  const agents = projectAgentsDir(repoRoot);
533
+ if (existsSync(dir)) {
534
+ return buildSnapshot(projectId, kind, mergeLeaseBackedAndAdhocPeers(
535
+ readLeasesFromDir(dir, nowMs, teamName),
536
+ readPeerCardsFromAgentsDir(agents.dir, nowMs, teamName),
537
+ ), teamName);
538
+ }
519
539
  return buildSnapshot(agents.projectId, agents.kind, readPeerCardsFromAgentsDir(agents.dir, nowMs, teamName), teamName);
520
540
  }
521
541
 
@@ -523,9 +543,10 @@ export function readZobLiveRegistryAllProjectsSnapshot(repoRoot: string, teamNam
523
543
  const { projectId, kind } = projectAgentsDir(repoRoot);
524
544
  const nowMs = Date.now();
525
545
  const leaseDirs = allProjectLeasesDirs();
546
+ const agentPeers = allProjectAgentsDirs().flatMap((dir) => readPeerCardsFromAgentsDir(dir, nowMs, teamName));
526
547
  const hasLeaseDomain = leaseDirs.some((dir) => existsSync(dir));
527
548
  const peers = hasLeaseDomain
528
- ? leaseDirs.flatMap((dir) => readLeasesFromDir(dir, nowMs, teamName))
529
- : allProjectAgentsDirs().flatMap((dir) => readPeerCardsFromAgentsDir(dir, nowMs, teamName));
549
+ ? mergeLeaseBackedAndAdhocPeers(leaseDirs.flatMap((dir) => readLeasesFromDir(dir, nowMs, teamName)), agentPeers)
550
+ : agentPeers;
530
551
  return buildSnapshot(projectId, kind, peers, teamName);
531
552
  }
@@ -109,6 +109,7 @@ export interface ZobLivePeerCard {
109
109
  zpeerActiveRoomId?: string;
110
110
  zpeerMemberships?: ZpeerRoomMembership[];
111
111
  zpeerLocalOnly?: true;
112
+ zpeerAdhoc?: true;
112
113
  staleAfterMs: number;
113
114
  offlineAfterMs: number;
114
115
  bodyStored: false;
@@ -234,7 +234,7 @@ export function refreshZpeerSelf(repoRoot: string, peer: ZobLivePeerCard, roomId
234
234
  const ensured = ensureZpeerFields(repoRoot, peer, roomId, alias, restoredMemberships);
235
235
  if (!hasLocalSocketEndpointEvidence(ensured)) return ensured;
236
236
  const refreshed = writeZobLivePeerCard(repoRoot, { ...ensured, heartbeatAt: new Date().toISOString(), status: "online" });
237
- writeZobLiveTeamAgentLease(repoRoot, refreshed, { reason: "zpeer_refresh" });
237
+ if (refreshed.zpeerAdhoc !== true) writeZobLiveTeamAgentLease(repoRoot, refreshed, { reason: "zpeer_refresh" });
238
238
  return refreshed;
239
239
  }
240
240
 
@@ -479,8 +479,10 @@ export async function sendZpeerPrompt(repoRoot: string, self: ZobLivePeerCard, t
479
479
  };
480
480
 
481
481
  if (!selfMembership) return finish("attempt", { status: "blocked", reason: `current peer is not a member of room '${roomId}'`, targetAlias: targetAlias ?? undefined, taskHash, bodyStored: false });
482
- const leaseOwnership = ownsZobLiveTeamAgentLease(repoRoot, self);
483
- if (!leaseOwnership.owned) return finish("attempt", { status: "blocked", reason: `current peer does not own stable team-agent lease (${leaseOwnership.reason})`, targetAlias: targetAlias ?? undefined, taskHash, bodyStored: false });
482
+ if (self.zpeerAdhoc !== true) {
483
+ const leaseOwnership = ownsZobLiveTeamAgentLease(repoRoot, self);
484
+ if (!leaseOwnership.owned) return finish("attempt", { status: "blocked", reason: `current peer does not own stable team-agent lease (${leaseOwnership.reason})`, targetAlias: targetAlias ?? undefined, taskHash, bodyStored: false });
485
+ }
484
486
  if (selfMembership.role === "observer") return finish("attempt", { status: "blocked", reason: `current peer is observer-only in room '${roomId}'`, targetAlias: targetAlias ?? undefined, taskHash, bodyStored: false });
485
487
  if (!targetAlias) return finish("attempt", { status: "blocked", reason: "invalid target alias", bodyStored: false });
486
488
  if (!transientPrompt.trim()) return finish("attempt", { status: "blocked", reason: "empty peer prompt", targetAlias, bodyStored: false });
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync } from "node:fs";
3
3
  import { mkdtemp, rm } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
- import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { getAgentDir, type ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
 
8
8
  import { discoverAgents } from "./agents.js";
9
9
  import { SUPERVISED_READONLY_CHILD_TOOLS } from "../../core/constants.js";
@@ -24,6 +24,28 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
24
24
  return { command: "pi", args };
25
25
  }
26
26
 
27
+ function childModelPattern(ctx: ExtensionContext, agent: HarnessAgent, modelOverride: string | undefined): string | undefined {
28
+ if (modelOverride?.trim()) return modelOverride.trim();
29
+ if (agent.model?.trim()) return agent.model.trim();
30
+ const model = ctx.model;
31
+ if (!model?.provider || !model.id) return undefined;
32
+ return `${model.provider}/${model.id}`;
33
+ }
34
+
35
+ function providerFromModelPattern(model: string | undefined): string | undefined {
36
+ if (!model) return undefined;
37
+ const [provider] = model.split("/");
38
+ return provider && provider !== model ? provider : undefined;
39
+ }
40
+
41
+ function resolveCodexFastModeExtension(ctx: ExtensionContext, model: string | undefined): string | undefined {
42
+ const provider = ctx.model?.provider ?? providerFromModelPattern(model);
43
+ const usesCodexProvider = provider === "openai-codex" || provider === "codex-auto" || provider?.startsWith("codex-") === true;
44
+ if (!usesCodexProvider) return undefined;
45
+ const extensionPath = join(getAgentDir(), "extensions", "codex-fast-mode.ts");
46
+ return existsSync(extensionPath) ? extensionPath : undefined;
47
+ }
48
+
27
49
  const CHILD_THINKING_LEVELS = new Set<string>(["low", "medium", "high", "xhigh"]);
28
50
 
29
51
  function validateChildThinkingOverride(thinking: string | undefined, fieldName = "thinking"): string[] {
@@ -90,13 +112,14 @@ async function runChildAgent(
90
112
  pathPolicy?: { allowedPaths?: string[]; forbiddenPaths?: string[]; sandboxRoot?: string },
91
113
  thinkingOverride?: ChildThinkingLevel,
92
114
  ): Promise<ChildResult> {
115
+ const resolvedModel = childModelPattern(ctx, agent, modelOverride);
93
116
  const result: ChildResult = {
94
117
  agent: agent.name,
95
118
  task,
96
119
  exitCode: 0,
97
120
  output: "",
98
121
  stderr: "",
99
- model: modelOverride ?? agent.model,
122
+ model: resolvedModel,
100
123
  usage: usageEmpty(),
101
124
  };
102
125
 
@@ -123,10 +146,12 @@ async function runChildAgent(
123
146
 
124
147
  try {
125
148
  const childSafetyExtension = join(ctx.cwd, ".pi", "extensions", "zob-child-safety", "index.ts");
149
+ const childCodexFastModeExtension = resolveCodexFastModeExtension(ctx, resolvedModel);
126
150
  const args = ["--mode", "json", "-p", "--no-extensions"];
151
+ if (childCodexFastModeExtension) args.push("-e", childCodexFastModeExtension);
127
152
  if (existsSync(childSafetyExtension)) args.push("-e", childSafetyExtension);
128
153
  args.push("--session", sessionPath);
129
- const model = modelOverride ?? agent.model;
154
+ const model = resolvedModel;
130
155
  if (model) args.push("--model", model);
131
156
  const thinking = resolveChildThinking(agent, thinkingOverride);
132
157
  if (thinking) args.push("--thinking", thinking);
@@ -0,0 +1,289 @@
1
+ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
2
+ import { resolve, sep } from "node:path";
3
+
4
+ import { isRecord } from "../core/utils/records.js";
5
+ import { sanitizeDelegationText } from "./delegation-markdown.js";
6
+
7
+ const ACTIVITY_MAX_BYTES = 180_000;
8
+ const DEFAULT_RECENT_LIMIT = 5;
9
+
10
+ export type DelegationActivityStatus = "running" | "success" | "error";
11
+
12
+ export interface DelegationActivity {
13
+ toolCallId: string;
14
+ toolName: string;
15
+ status: DelegationActivityStatus;
16
+ startedAtMs?: number;
17
+ endedAtMs?: number;
18
+ elapsedMs?: number;
19
+ command?: string;
20
+ timeoutMs?: number;
21
+ target?: string;
22
+ summary?: string;
23
+ outputSummary?: string;
24
+ errorSummary?: string;
25
+ }
26
+
27
+ export interface DelegationActivitySnapshot {
28
+ status: "ok" | "missing" | "blocked" | "empty";
29
+ note?: string;
30
+ fingerprint: string;
31
+ current?: DelegationActivity;
32
+ recent: DelegationActivity[];
33
+ lastUpdatedMs?: number;
34
+ quietMs?: number;
35
+ }
36
+
37
+ function normalizePath(path: string): string {
38
+ return resolve(path);
39
+ }
40
+
41
+ function isInside(root: string, candidate: string): boolean {
42
+ const normalizedRoot = normalizePath(root);
43
+ const normalizedCandidate = normalizePath(candidate);
44
+ return normalizedCandidate === normalizedRoot || normalizedCandidate.startsWith(`${normalizedRoot}${sep}`);
45
+ }
46
+
47
+ function readTail(path: string, size: number, maxBytes: number): string {
48
+ if (size <= maxBytes) {
49
+ const buffer = Buffer.alloc(size);
50
+ const fd = openSync(path, "r");
51
+ try {
52
+ readSync(fd, buffer, 0, size, 0);
53
+ } finally {
54
+ closeSync(fd);
55
+ }
56
+ return buffer.toString("utf8");
57
+ }
58
+ const buffer = Buffer.alloc(maxBytes);
59
+ const fd = openSync(path, "r");
60
+ try {
61
+ readSync(fd, buffer, 0, maxBytes, Math.max(0, size - maxBytes));
62
+ } finally {
63
+ closeSync(fd);
64
+ }
65
+ const text = buffer.toString("utf8");
66
+ const firstNewline = text.indexOf("\n");
67
+ const safeTail = firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
68
+ return `{"type":"custom","customType":"zob-activity-tail-capped","data":{"omittedBytes":${size - maxBytes}}}\n${safeTail}`;
69
+ }
70
+
71
+ function timestampMs(entry: Record<string, unknown>): number | undefined {
72
+ if (typeof entry.timestamp !== "string") return undefined;
73
+ const parsed = Date.parse(entry.timestamp);
74
+ return Number.isFinite(parsed) ? parsed : undefined;
75
+ }
76
+
77
+ function compactWhitespace(value: string): string {
78
+ return value.replace(/\s+/g, " ").trim();
79
+ }
80
+
81
+ export function redactDelegationCommand(command: string): string {
82
+ return sanitizeDelegationText(command)
83
+ .replace(/\b([A-Z0-9_]*(?:DATABASE_URL|TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|ACCESS_KEY|PRIVATE_KEY)[A-Z0-9_]*)=("[^"]*"|'[^']*'|\S+)/gi, "$1=<redacted>")
84
+ .replace(/(Authorization\s*:\s*Bearer\s+)("[^"]*"|'[^']*'|\S+)/gi, "$1<redacted>")
85
+ .replace(/\b(--(?:password|passwd|token|secret|api-key|access-key|private-key|database-url)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi, "$1<redacted>")
86
+ .replace(/\b(-H\s+["']?Authorization\s*:\s*Bearer\s+)([^"'\s]+)/gi, "$1<redacted>");
87
+ }
88
+
89
+ function capInline(value: string, limit: number): string {
90
+ const compact = compactWhitespace(sanitizeDelegationText(value));
91
+ if (compact.length <= limit) return compact;
92
+ const head = Math.max(8, Math.floor(limit * 0.65));
93
+ const tail = Math.max(4, limit - head - 1);
94
+ return `${compact.slice(0, head)}…${compact.slice(-tail)}`;
95
+ }
96
+
97
+ function contentText(content: unknown): string {
98
+ if (typeof content === "string") return sanitizeDelegationText(content);
99
+ if (!Array.isArray(content)) return "";
100
+ const parts: string[] = [];
101
+ for (const part of content) {
102
+ if (!isRecord(part) || typeof part.type !== "string") continue;
103
+ if (part.type === "text" && typeof part.text === "string") parts.push(sanitizeDelegationText(part.text));
104
+ else if (part.type === "image") parts.push("[image]");
105
+ }
106
+ return parts.join("\n");
107
+ }
108
+
109
+ function toolArguments(part: Record<string, unknown>): Record<string, unknown> {
110
+ const direct = part.arguments ?? part.args ?? part.input;
111
+ return isRecord(direct) ? direct : {};
112
+ }
113
+
114
+ function toolName(part: Record<string, unknown>): string {
115
+ return typeof part.name === "string" ? part.name : typeof part.toolName === "string" ? part.toolName : "tool";
116
+ }
117
+
118
+ function timeoutMsFromArgs(args: Record<string, unknown>): number | undefined {
119
+ const raw = args.timeout ?? args.timeout_ms ?? args.timeoutMs;
120
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return undefined;
121
+ // Pi bash tool accepts timeout in seconds; keep millisecond values if callers already use them.
122
+ return raw > 10_000 ? Math.floor(raw) : Math.floor(raw * 1000);
123
+ }
124
+
125
+ function activityFromToolCall(entry: Record<string, unknown>, part: Record<string, unknown>): DelegationActivity | undefined {
126
+ const id = typeof part.id === "string" ? part.id : undefined;
127
+ if (!id) return undefined;
128
+ const name = toolName(part);
129
+ const lower = name.toLowerCase();
130
+ const args = toolArguments(part);
131
+ const startedAtMs = timestampMs(entry);
132
+ const activity: DelegationActivity = {
133
+ toolCallId: id,
134
+ toolName: name,
135
+ status: "running",
136
+ startedAtMs,
137
+ timeoutMs: timeoutMsFromArgs(args),
138
+ };
139
+
140
+ const path = typeof args.path === "string" ? args.path : typeof args.file === "string" ? args.file : undefined;
141
+ if (lower === "bash") {
142
+ const command = typeof args.command === "string" ? redactDelegationCommand(args.command) : undefined;
143
+ activity.command = command;
144
+ activity.summary = command ? capInline(command, 140) : "shell command";
145
+ } else if (lower === "read") {
146
+ activity.target = path;
147
+ activity.summary = path ? `read ${path}` : "read file";
148
+ } else if (lower === "grep") {
149
+ const pattern = typeof args.pattern === "string" ? args.pattern : typeof args.query === "string" ? args.query : undefined;
150
+ activity.target = path;
151
+ activity.summary = `grep ${pattern ? capInline(pattern, 60) : "pattern"}${path ? ` in ${path}` : ""}`;
152
+ } else if (lower === "find") {
153
+ const pattern = typeof args.pattern === "string" ? args.pattern : "files";
154
+ activity.target = path;
155
+ activity.summary = `find ${capInline(pattern, 60)}${path ? ` in ${path}` : ""}`;
156
+ } else if (lower === "edit" || lower === "write") {
157
+ activity.target = path;
158
+ const edits = Array.isArray(args.edits) ? ` · ${args.edits.length} edit${args.edits.length === 1 ? "" : "s"}` : "";
159
+ activity.summary = `${lower} ${path ?? "file"}${edits}`;
160
+ } else if (lower === "delegate_task" || lower === "delegate_agent") {
161
+ const agent = typeof args.agent === "string" ? args.agent : undefined;
162
+ activity.summary = `${lower}${agent ? ` → ${agent}` : ""}`;
163
+ } else {
164
+ activity.summary = Object.entries(args).length > 0
165
+ ? `${name} ${Object.entries(args).slice(0, 3).map(([key, value]) => `${key}=${typeof value === "string" ? capInline(value, 40) : Array.isArray(value) ? `${value.length} items` : String(value)}`).join(" ")}`
166
+ : name;
167
+ }
168
+
169
+ return activity;
170
+ }
171
+
172
+ function resultSummary(text: string): string | undefined {
173
+ const trimmed = text.trim();
174
+ if (!trimmed) return undefined;
175
+ const compact = compactWhitespace(trimmed);
176
+ const timeout = compact.match(/Command timed out after\s+([0-9.]+)\s+seconds/i);
177
+ if (timeout) return `timed out after ${timeout[1]}s`;
178
+ const lineCount = trimmed.split("\n").length;
179
+ if (lineCount > 1 || trimmed.length > 120) return `${lineCount} line${lineCount === 1 ? "" : "s"} · ${trimmed.length} chars`;
180
+ return capInline(trimmed, 140);
181
+ }
182
+
183
+ function applyToolResult(entry: Record<string, unknown>, message: Record<string, unknown>, byId: Map<string, DelegationActivity>, order: DelegationActivity[]): void {
184
+ const id = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
185
+ const name = typeof message.toolName === "string" ? message.toolName : "tool";
186
+ if (!id) return;
187
+ let activity = byId.get(id);
188
+ if (!activity) {
189
+ activity = { toolCallId: id, toolName: name, status: "running" };
190
+ byId.set(id, activity);
191
+ order.push(activity);
192
+ }
193
+ const endedAtMs = timestampMs(entry);
194
+ activity.endedAtMs = endedAtMs;
195
+ activity.elapsedMs = activity.startedAtMs && endedAtMs ? Math.max(0, endedAtMs - activity.startedAtMs) : undefined;
196
+ activity.status = message.isError === true ? "error" : "success";
197
+ const text = contentText(message.content);
198
+ const summary = resultSummary(text);
199
+ if (message.isError === true) activity.errorSummary = summary ?? "tool error";
200
+ else activity.outputSummary = summary ?? "completed";
201
+ }
202
+
203
+ function parseActivityText(text: string, nowMs: number): { recent: DelegationActivity[]; current?: DelegationActivity; lastUpdatedMs?: number } {
204
+ const byId = new Map<string, DelegationActivity>();
205
+ const order: DelegationActivity[] = [];
206
+ let lastUpdatedMs: number | undefined;
207
+
208
+ for (const rawLine of text.split("\n")) {
209
+ if (!rawLine.trim()) continue;
210
+ let parsed: unknown;
211
+ try {
212
+ parsed = JSON.parse(rawLine);
213
+ } catch {
214
+ continue;
215
+ }
216
+ if (!isRecord(parsed)) continue;
217
+ const at = timestampMs(parsed);
218
+ if (at !== undefined) lastUpdatedMs = lastUpdatedMs === undefined ? at : Math.max(lastUpdatedMs, at);
219
+
220
+ if (parsed.type === "message" && isRecord(parsed.message)) {
221
+ const message = parsed.message;
222
+ if (message.role === "assistant" && Array.isArray(message.content)) {
223
+ for (const part of message.content) {
224
+ if (!isRecord(part) || part.type !== "toolCall") continue;
225
+ const activity = activityFromToolCall(parsed, part);
226
+ if (!activity) continue;
227
+ byId.set(activity.toolCallId, activity);
228
+ order.push(activity);
229
+ }
230
+ } else if (message.role === "toolResult") {
231
+ applyToolResult(parsed, message, byId, order);
232
+ }
233
+ }
234
+ }
235
+
236
+ for (const activity of order) {
237
+ if (activity.status === "running" && activity.startedAtMs !== undefined) activity.elapsedMs = Math.max(0, nowMs - activity.startedAtMs);
238
+ }
239
+ const current = [...order].reverse().find((activity) => activity.status === "running");
240
+ return { recent: order.slice(-DEFAULT_RECENT_LIMIT), current, lastUpdatedMs };
241
+ }
242
+
243
+ export function readDelegationActivitySnapshot(sessionPath: string | undefined, repoRoot: string, nowMs = Date.now()): DelegationActivitySnapshot {
244
+ if (!sessionPath) return { status: "missing", note: "Child session is not captured yet.", fingerprint: "no-session", recent: [] };
245
+ const resolved = resolve(sessionPath);
246
+ if (!isInside(repoRoot, resolved)) {
247
+ return { status: "blocked", note: `Child session path is outside repo root: ${sessionPath}`, fingerprint: `blocked:${sessionPath}`, recent: [] };
248
+ }
249
+ if (!existsSync(resolved)) return { status: "missing", note: `Child session file not found yet: ${sessionPath}`, fingerprint: `missing:${sessionPath}`, recent: [] };
250
+ const stat = statSync(resolved);
251
+ if (stat.size === 0) return { status: "empty", note: "Child session file is empty.", fingerprint: `${resolved}:0:${Math.floor(stat.mtimeMs)}`, recent: [], lastUpdatedMs: Math.floor(stat.mtimeMs), quietMs: Math.max(0, nowMs - stat.mtimeMs) };
252
+ const text = readTail(resolved, stat.size, ACTIVITY_MAX_BYTES);
253
+ const parsed = parseActivityText(text, nowMs);
254
+ const lastUpdatedMs = parsed.lastUpdatedMs ?? Math.floor(stat.mtimeMs);
255
+ const cappedNote = stat.size > ACTIVITY_MAX_BYTES ? `Activity feed read from last ${ACTIVITY_MAX_BYTES} bytes of ${stat.size} bytes.` : undefined;
256
+ return {
257
+ status: "ok",
258
+ note: cappedNote,
259
+ fingerprint: `${resolved}:${stat.size}:${Math.floor(stat.mtimeMs)}`,
260
+ recent: parsed.recent,
261
+ current: parsed.current,
262
+ lastUpdatedMs,
263
+ quietMs: Math.max(0, nowMs - Math.max(lastUpdatedMs, Math.floor(stat.mtimeMs))),
264
+ };
265
+ }
266
+
267
+ export function formatActivityDuration(ms: number | undefined): string | undefined {
268
+ if (typeof ms !== "number" || !Number.isFinite(ms)) return undefined;
269
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
270
+ const seconds = totalSeconds % 60;
271
+ const minutes = Math.floor(totalSeconds / 60) % 60;
272
+ const hours = Math.floor(totalSeconds / 3600);
273
+ const pad2 = (value: number): string => String(value).padStart(2, "0");
274
+ if (hours > 0) return `${hours}:${pad2(minutes)}:${pad2(seconds)}`;
275
+ return `${pad2(minutes)}:${pad2(seconds)}`;
276
+ }
277
+
278
+ export function formatActivitySummary(activity: DelegationActivity, options: { includeCommand?: boolean; nowMs?: number } = {}): string[] {
279
+ const icon = activity.status === "running" ? "◉" : activity.status === "success" ? "✓" : "✗";
280
+ const duration = formatActivityDuration(activity.elapsedMs ?? (activity.status === "running" && activity.startedAtMs ? (options.nowMs ?? Date.now()) - activity.startedAtMs : undefined));
281
+ const timeout = formatActivityDuration(activity.timeoutMs);
282
+ const detail = activity.status === "running"
283
+ ? [duration ? `running ${duration}` : "running", timeout ? `timeout ${timeout}` : undefined].filter(Boolean).join(" / ")
284
+ : activity.errorSummary ?? activity.outputSummary ?? (activity.status === "success" ? "done" : "error");
285
+ const summary = activity.summary ?? activity.command ?? activity.target ?? activity.toolName;
286
+ const lines = [`${icon} ${activity.toolName}${detail ? ` · ${detail}` : ""}${summary && activity.toolName.toLowerCase() !== summary.toLowerCase() ? ` · ${summary}` : ""}`];
287
+ if (options.includeCommand && activity.command) lines.push(`$ ${activity.command}`);
288
+ return lines;
289
+ }
@@ -4,6 +4,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
4
4
  import { Markdown, truncateToWidth, visibleWidth, type MarkdownTheme } from "@earendil-works/pi-tui";
5
5
 
6
6
  import { delegationDurationMs, delegationSignalBadge, delegationSignalColor, formatDelegationContextLabel, formatDelegationCostLabel, formatDelegationModelLabel, formatDelegationSignalBadge, formatDuration, statusIcon, type DelegationRunView } from "./delegation-monitor.js";
7
+ import { formatActivityDuration, formatActivitySummary, readDelegationActivitySnapshot } from "./delegation-activity.js";
7
8
  import { sanitizeDelegationText } from "./delegation-markdown.js";
8
9
  import { isRecord } from "../core/utils/records.js";
9
10
 
@@ -180,6 +181,31 @@ function actionCard(lines: string[], title: string, details: string[], width: nu
180
181
  }
181
182
  }
182
183
 
184
+ function renderLiveActivityCard(run: DelegationRunView, repoRoot: string, lines: string[], width: number, theme: Theme, nowMs = Date.now()): void {
185
+ const snapshot = readDelegationActivitySnapshot(run.sessionPath, repoRoot, nowMs);
186
+ if (snapshot.recent.length === 0) return;
187
+ pushSpacer(lines);
188
+ const bgKey = snapshot.current ? "toolPendingBg" : "toolSuccessBg";
189
+ lines.push(theme.bg(bgKey, padToWidth(theme.fg(snapshot.current ? "toolTitle" : "success", theme.bold(" Live activity")), width)));
190
+ if (snapshot.current) {
191
+ for (const line of formatActivitySummary(snapshot.current, { includeCommand: true, nowMs })) {
192
+ lines.push(theme.bg(bgKey, padToWidth(theme.fg(line.startsWith("$") ? "toolOutput" : "warning", ` ${line}`), width)));
193
+ }
194
+ if (typeof snapshot.quietMs === "number" && snapshot.quietMs >= 120_000) {
195
+ const quiet = `quiet ${formatActivityDuration(snapshot.quietMs)} since last transcript update`;
196
+ lines.push(theme.bg(bgKey, padToWidth(theme.fg(snapshot.quietMs >= 300_000 ? "warning" : "muted", ` ${quiet}`), width)));
197
+ }
198
+ }
199
+ const recent = snapshot.recent.slice(-5).filter((activity) => activity.toolCallId !== snapshot.current?.toolCallId);
200
+ if (recent.length > 0) lines.push(theme.bg(bgKey, padToWidth(theme.fg("muted", " last actions"), width)));
201
+ for (const activity of recent) {
202
+ const colorKey = activity.status === "error" ? "error" : activity.status === "running" ? "warning" : "success";
203
+ const first = formatActivitySummary(activity, { nowMs })[0];
204
+ if (first) lines.push(theme.bg(bgKey, padToWidth(theme.fg(colorKey, ` ${first}`), width)));
205
+ }
206
+ if (snapshot.note) lines.push(theme.bg(bgKey, padToWidth(theme.fg("muted", ` ${snapshot.note}`), width)));
207
+ }
208
+
183
209
  function failureDetails(run: DelegationRunView, fallback: string): string[] {
184
210
  const details: string[] = [];
185
211
  if (run.errorMessage) details.push(`message: ${sanitizeDelegationText(run.errorMessage)}`);
@@ -372,6 +398,7 @@ export function renderDelegationFeedLines(run: DelegationRunView | undefined, re
372
398
  lines.push(theme.fg("dim", `${statusIcon(run.status)} ${run.agent}${signalText ? ` · ${theme.fg(delegationSignalColor(signalBadge), signalText)}` : ""}${modelLabel ? ` · ${theme.fg("muted", `(${modelLabel})`)}` : ""} · ${run.status}${run.failureKind ? ` · ${run.failureKind}` : ""} · ${formatDuration(delegationDurationMs(run))} · ${formatDelegationCostLabel(run)} · ${formatDelegationContextLabel(run)}`));
373
399
  if (run.usage) lines.push(theme.fg("muted", `usage · in ${run.usage.input} · out ${run.usage.output} · cache ${run.usage.cacheRead}/${run.usage.cacheWrite} · context ${run.usage.contextTokens}`));
374
400
  if (run.taskPreview) lines.push(theme.fg("muted", `task · ${sanitizeDelegationText(run.taskPreview)}`));
401
+ renderLiveActivityCard(run, repoRoot, lines, safeWidth, theme);
375
402
  let renderedFailureSummary = false;
376
403
  if (run.failureKind === "preflight" || run.failureKind === "config") {
377
404
  renderedFailureSummary = true;
@@ -40,6 +40,7 @@ export interface DelegationRunView {
40
40
  childChangedPaths?: ChildResult["childChangedPaths"];
41
41
  usage?: ChildResult["usage"];
42
42
  model?: string;
43
+ background?: boolean;
43
44
  }
44
45
 
45
46
  export interface DelegationMonitorState {
@@ -73,6 +74,8 @@ const TRANSCRIPT_MAX_BYTES = 240_000;
73
74
  const TRANSCRIPT_MAX_LINES = 1_200;
74
75
  const WIDGET_COMPLETE_TTL_MS = 30_000;
75
76
  const WIDGET_FAILURE_TTL_MS = 120_000;
77
+ const WIDGET_BACKGROUND_COMPLETE_TTL_MS = 10 * 60_000;
78
+ const WIDGET_BACKGROUND_FAILURE_TTL_MS = 30 * 60_000;
76
79
 
77
80
  function capPreview(text: string | undefined, limit = PREVIEW_LIMIT): string {
78
81
  const value = text ?? "";
@@ -362,6 +365,10 @@ export function shouldShowRunInWidget(run: DelegationRunView, nowMs = Date.now()
362
365
  if (run.status === "queued" || run.status === "running") return true;
363
366
  if (!run.endedAtMs) return false;
364
367
  const ageMs = Math.max(0, nowMs - run.endedAtMs);
368
+ if (run.background) {
369
+ if (run.status === "complete") return ageMs <= WIDGET_BACKGROUND_COMPLETE_TTL_MS;
370
+ return ageMs <= WIDGET_BACKGROUND_FAILURE_TTL_MS;
371
+ }
365
372
  if (run.status === "complete") return ageMs <= WIDGET_COMPLETE_TTL_MS;
366
373
  return ageMs <= WIDGET_FAILURE_TTL_MS;
367
374
  }
@@ -466,6 +473,7 @@ export function updateDelegationRun(state: DelegationMonitorState, id: string, p
466
473
  if (patch.errorMessage !== undefined) run.errorMessage = patch.errorMessage;
467
474
  if (patch.usage !== undefined) run.usage = patch.usage;
468
475
  if (patch.model !== undefined) run.model = patch.model;
476
+ if (patch.background !== undefined) run.background = patch.background;
469
477
  return run;
470
478
  }
471
479
 
@@ -132,6 +132,19 @@ function activeZagentState(state: HarnessRuntimeState): ActiveZagentState | unde
132
132
  return state.zagent.id ? state.zagent as ActiveZagentState : undefined;
133
133
  }
134
134
 
135
+ function zpeerStableTeamAgentLeaseRequired(zagent: ActiveZagentState | undefined): boolean {
136
+ return Boolean(
137
+ zagent?.id
138
+ || process.env.ZOB_ZAGENT_ID?.trim()
139
+ || process.env.ZOB_ZTEAM_ID?.trim()
140
+ || process.env.ZOB_COMS_ROLE_ID?.trim(),
141
+ );
142
+ }
143
+
144
+ function withZpeerLeaseMode<T extends NonNullable<HarnessRuntimeState["zobLive"]["peerCard"]>>(peer: T, useStableTeamAgentLease: boolean): T {
145
+ return { ...peer, zpeerAdhoc: useStableTeamAgentLease ? undefined : true } as T;
146
+ }
147
+
135
148
  function formatScopedZteamModeLabel(scopedMode: ActiveZagentState["scopedMode"]): string | undefined {
136
149
  if (!scopedMode?.active || !scopedMode.teamId || !scopedMode.modeId || !scopedMode.baseMode) return undefined;
137
150
  return `zob:${scopedMode.baseMode}@${scopedMode.teamId}/${scopedMode.modeId}`;
@@ -511,6 +524,7 @@ async function startOrRefreshZobLiveRuntime(pi: ExtensionAPI, state: HarnessRunt
511
524
  if (carryoverProfile?.zagentId && state.zagent.id !== carryoverProfile.zagentId) loadActiveZagentById(state, repoRoot, carryoverProfile.zagentId);
512
525
  const sharedZpeerProfile = zpeerProfile ? zpeerProfileIdIsSharedFallback(zpeerProfile.profileId) : false;
513
526
  const zagent = activeZagentState(state);
527
+ const useStableTeamAgentLease = zpeerStableTeamAgentLeaseRequired(zagent);
514
528
  const zagentMemberships = zagent ? zagentRoomMemberships(zagent) : undefined;
515
529
  const zagentActiveRoomId = zagent?.activeRoom ?? zagentMemberships?.find((membership) => membership.roomId)?.roomId;
516
530
  const zpeerProfileRoomId = zagentActiveRoomId ?? zpeerProfile?.activeRoomId ?? zpeerProfile?.roomId ?? carryoverProfile?.activeRoomId ?? carryoverProfile?.roomId;
@@ -579,7 +593,7 @@ async function startOrRefreshZobLiveRuntime(pi: ExtensionAPI, state: HarnessRunt
579
593
  }, { triggerTurn: true, deliverAs: "followUp" });
580
594
  return buildZobLiveAckEnvelope(envelope);
581
595
  });
582
- const peerCard = ensureZpeerFields(repoRoot, {
596
+ const peerCard = withZpeerLeaseMode(ensureZpeerFields(repoRoot, {
583
597
  ...basePeer,
584
598
  team: zagent?.team ?? basePeer.team,
585
599
  roleId: zagent?.id ?? basePeer.roleId,
@@ -588,58 +602,78 @@ async function startOrRefreshZobLiveRuntime(pi: ExtensionAPI, state: HarnessRunt
588
602
  endpoint,
589
603
  endpointHash: sha256(endpoint),
590
604
  status: "online" as const,
591
- }, zpeerProfileRoomId, zpeerProfileAlias, zpeerProfileMemberships);
605
+ }, zpeerProfileRoomId, zpeerProfileAlias, zpeerProfileMemberships), useStableTeamAgentLease);
592
606
  state.zobLive.server = server;
593
- const leaseClaim = await claimZobLiveTeamAgentLease(repoRoot, peerCard, { reason: "runtime_start" });
594
- if (leaseClaim.ok) {
607
+ if (!useStableTeamAgentLease) {
608
+ releaseZobLiveTeamAgentLease(repoRoot, peerCard, { reason: "runtime_adhoc" });
595
609
  state.zobLive.peerCard = refreshZpeerSelf(repoRoot, peerCard);
596
- state.zobLive.leaseOwned = true;
597
- state.zobLive.leaseStatus = "owned";
610
+ state.zobLive.leaseOwned = false;
611
+ state.zobLive.leaseStatus = "unavailable";
598
612
  state.zobLive.leaseBlockReason = undefined;
599
613
  state.zobLive.lastHeartbeatMs = Date.now();
600
614
  scheduleZpeerHeartbeat(pi, state, ctx);
601
615
  } else {
602
- state.zobLive.peerCard = await stopLeaseBlockedLocalEndpoint(state, repoRoot, peerCard, server);
603
- setZpeerLastEvent(state, {
604
- kind: "blocked",
605
- roomId: state.zobLive.peerCard.zpeerActiveRoomId ?? state.zobLive.peerCard.zpeerRoomId,
606
- fromAlias: state.zobLive.peerCard.zpeerAlias,
607
- status: "lease_blocked",
608
- reason: "stable team-agent lease is held by a responsive live endpoint; duplicate local endpoint stopped and peer marked offline",
609
- });
616
+ const leaseClaim = await claimZobLiveTeamAgentLease(repoRoot, peerCard, { reason: "runtime_start" });
617
+ if (leaseClaim.ok) {
618
+ state.zobLive.peerCard = refreshZpeerSelf(repoRoot, peerCard);
619
+ state.zobLive.leaseOwned = true;
620
+ state.zobLive.leaseStatus = "owned";
621
+ state.zobLive.leaseBlockReason = undefined;
622
+ state.zobLive.lastHeartbeatMs = Date.now();
623
+ scheduleZpeerHeartbeat(pi, state, ctx);
624
+ } else {
625
+ state.zobLive.peerCard = await stopLeaseBlockedLocalEndpoint(state, repoRoot, peerCard, server);
626
+ setZpeerLastEvent(state, {
627
+ kind: "blocked",
628
+ roomId: state.zobLive.peerCard.zpeerActiveRoomId ?? state.zobLive.peerCard.zpeerRoomId,
629
+ fromAlias: state.zobLive.peerCard.zpeerAlias,
630
+ status: "lease_blocked",
631
+ reason: "stable team-agent lease is held by a responsive live endpoint; duplicate local endpoint stopped and peer marked offline",
632
+ });
633
+ }
610
634
  }
611
635
  try { writeZpeerLocalProfileFromPeer(repoRoot, state.zobLive.peerCard, profileId); } catch { /* best-effort reload continuity; live runtime must remain available */ }
612
636
  } else {
613
- const peerCard = ensureZpeerFields(repoRoot, {
637
+ const peerCard = withZpeerLeaseMode(ensureZpeerFields(repoRoot, {
614
638
  ...state.zobLive.peerCard,
615
639
  team: zagent?.team ?? state.zobLive.peerCard.team,
616
640
  roleId: zagent?.id ?? state.zobLive.peerCard.roleId,
617
641
  agent: zagent?.id ?? state.zobLive.peerCard.agent,
618
642
  heartbeatAt: new Date().toISOString(),
619
643
  status: "online",
620
- }, zpeerProfileRoomId, zpeerProfileAlias, zpeerProfileMemberships);
621
- const leaseClaim = await claimZobLiveTeamAgentLease(repoRoot, peerCard, { reason: "runtime_refresh" });
622
- if (leaseClaim.ok) {
644
+ }, zpeerProfileRoomId, zpeerProfileAlias, zpeerProfileMemberships), useStableTeamAgentLease);
645
+ if (!useStableTeamAgentLease) {
646
+ releaseZobLiveTeamAgentLease(repoRoot, peerCard, { reason: "runtime_adhoc" });
623
647
  state.zobLive.peerCard = refreshZpeerSelf(repoRoot, peerCard);
624
- state.zobLive.leaseOwned = true;
625
- state.zobLive.leaseStatus = "owned";
648
+ state.zobLive.leaseOwned = false;
649
+ state.zobLive.leaseStatus = "unavailable";
626
650
  state.zobLive.leaseBlockReason = undefined;
627
651
  state.zobLive.lastHeartbeatMs = Date.now();
628
652
  scheduleZpeerHeartbeat(pi, state, ctx);
629
- } else if (state.zobLive.server) {
630
- state.zobLive.peerCard = await stopLeaseBlockedLocalEndpoint(state, repoRoot, peerCard, state.zobLive.server);
631
- setZpeerLastEvent(state, {
632
- kind: "blocked",
633
- roomId: state.zobLive.peerCard.zpeerActiveRoomId ?? state.zobLive.peerCard.zpeerRoomId,
634
- fromAlias: state.zobLive.peerCard.zpeerAlias,
635
- status: "lease_blocked",
636
- reason: "stable team-agent lease is held by a responsive live endpoint; duplicate local endpoint stopped and peer marked offline",
637
- });
638
653
  } else {
639
- state.zobLive.peerCard = writeZobLivePeerCard(repoRoot, { ...peerCard, heartbeatAt: new Date().toISOString(), status: "offline" });
640
- state.zobLive.leaseOwned = false;
641
- state.zobLive.leaseStatus = "blocked";
642
- state.zobLive.leaseBlockReason = "blocked_live_owner";
654
+ const leaseClaim = await claimZobLiveTeamAgentLease(repoRoot, peerCard, { reason: "runtime_refresh" });
655
+ if (leaseClaim.ok) {
656
+ state.zobLive.peerCard = refreshZpeerSelf(repoRoot, peerCard);
657
+ state.zobLive.leaseOwned = true;
658
+ state.zobLive.leaseStatus = "owned";
659
+ state.zobLive.leaseBlockReason = undefined;
660
+ state.zobLive.lastHeartbeatMs = Date.now();
661
+ scheduleZpeerHeartbeat(pi, state, ctx);
662
+ } else if (state.zobLive.server) {
663
+ state.zobLive.peerCard = await stopLeaseBlockedLocalEndpoint(state, repoRoot, peerCard, state.zobLive.server);
664
+ setZpeerLastEvent(state, {
665
+ kind: "blocked",
666
+ roomId: state.zobLive.peerCard.zpeerActiveRoomId ?? state.zobLive.peerCard.zpeerRoomId,
667
+ fromAlias: state.zobLive.peerCard.zpeerAlias,
668
+ status: "lease_blocked",
669
+ reason: "stable team-agent lease is held by a responsive live endpoint; duplicate local endpoint stopped and peer marked offline",
670
+ });
671
+ } else {
672
+ state.zobLive.peerCard = writeZobLivePeerCard(repoRoot, { ...peerCard, heartbeatAt: new Date().toISOString(), status: "offline" });
673
+ state.zobLive.leaseOwned = false;
674
+ state.zobLive.leaseStatus = "blocked";
675
+ state.zobLive.leaseBlockReason = "blocked_live_owner";
676
+ }
643
677
  }
644
678
  try { writeZpeerLocalProfileFromPeer(repoRoot, state.zobLive.peerCard, profileId); } catch { /* best-effort reload continuity; live runtime must remain available */ }
645
679
  }
@@ -414,6 +414,8 @@ function restoreDelegationRunsFromEntries(state: HarnessRuntimeState, entries: u
414
414
  errorMessage: Array.isArray(data.errors) ? data.errors.map(String).join("; ") : existing?.errorMessage,
415
415
  childChangedPaths: normalizeZcommitChildChangedPathRefs(data.childChangedPathRefs) ?? existing?.childChangedPaths,
416
416
  usage: usageField(data) ?? existing?.usage,
417
+ model: stringField(data, "model") ?? existing?.model,
418
+ background: (typeof data.background === "boolean" ? data.background : undefined) ?? existing?.background,
417
419
  };
418
420
  if (event === "start") restoredRun.endedAtMs = existing?.endedAtMs;
419
421
  restored.set(runId, restoredRun);
@@ -40,7 +40,9 @@ import {
40
40
  statusIcon,
41
41
  updateDelegationRun,
42
42
  type DelegationRunMode,
43
+ type DelegationRunView,
43
44
  } from "../delegation-monitor.js";
45
+ import { formatActivityDuration, formatActivitySummary, readDelegationActivitySnapshot } from "../delegation-activity.js";
44
46
  import { delegateViewLink } from "../delegation-mouse.js";
45
47
  import type { BackgroundDelegationRuntimeRun, HarnessRuntimeState } from "../state.js";
46
48
  import { strictGoalErrors, strictGoalSpecErrors } from "../state.js";
@@ -728,6 +730,32 @@ export function formatDelegationCatalogSummary(catalog: Record<string, unknown>)
728
730
  return lines.join("\n");
729
731
  }
730
732
 
733
+ function renderMiniActivityLines(run: DelegationRunView, nowMs: number, expanded: boolean, theme: { fg: (color: any, text: string) => string }): string[] {
734
+ const lines: string[] = [];
735
+ const snapshot = readDelegationActivitySnapshot(run.sessionPath, process.cwd(), nowMs);
736
+ const recent = snapshot.recent.slice(-5);
737
+ if (recent.length === 0) {
738
+ if ((run.status === "running" || run.status === "queued") && snapshot.note && snapshot.status !== "missing") {
739
+ lines.push(` ${theme.fg("muted", snapshot.note)}`);
740
+ }
741
+ return lines;
742
+ }
743
+ const label = expanded ? "last actions" : "last";
744
+ lines.push(` ${theme.fg("dim", `${label} ${recent.length}`)}`);
745
+ for (const activity of recent) {
746
+ const color = activity.status === "running" ? "warning" : activity.status === "error" ? "error" : "success";
747
+ const includeCommand = activity.status === "running" && activity.toolName.toLowerCase() === "bash";
748
+ const rendered = formatActivitySummary(activity, { includeCommand, nowMs });
749
+ const [first, ...rest] = rendered;
750
+ if (first) lines.push(` ${theme.fg(color, first)}`);
751
+ for (const extra of rest.slice(0, expanded ? 2 : 1)) lines.push(` ${theme.fg("muted", extra)}`);
752
+ }
753
+ if (snapshot.current && typeof snapshot.quietMs === "number" && snapshot.quietMs >= 120_000) {
754
+ lines.push(` ${theme.fg(snapshot.quietMs >= 300_000 ? "warning" : "muted", `quiet ${formatActivityDuration(snapshot.quietMs)} since last transcript update`)}`);
755
+ }
756
+ return lines;
757
+ }
758
+
731
759
  export function renderDelegationToolResultText(source: "delegate_agent" | "delegate_task", details: DelegationDetails | undefined, state: HarnessRuntimeState, toolCallId: string | undefined, isPartial: boolean, expanded: boolean, theme: { fg: (color: any, text: string) => string; bold: (text: string) => string }): string {
732
760
  hydrateDelegationRunsFromDetails(source, details, state, toolCallId);
733
761
  const nowMs = Date.now();
@@ -754,11 +782,13 @@ export function renderDelegationToolResultText(source: "delegate_agent" | "deleg
754
782
  const prefix = index === visibleRuns.length - 1 && !hasMore ? "└─" : "├─";
755
783
  const viewHint = delegateViewLink(run.id);
756
784
  const kind = run.failureKind ? ` ${run.failureKind}` : "";
785
+ const background = run.background ? " bg" : "";
757
786
  const badge = delegationSignalBadge(run);
758
787
  const badgeText = formatDelegationSignalBadge(badge);
759
788
  const modelLabel = formatDelegationModelLabel(run);
760
789
  const modelSuffix = modelLabel ? ` ${theme.fg("muted", `(${modelLabel})`)}` : "";
761
- lines.push(`${theme.fg("dim", prefix)} ${theme.fg(color, `${statusIcon(run.status)} ${run.agent}${kind}`)}${badgeText ? ` ${theme.fg(delegationSignalColor(badge), badgeText)}` : ""}${modelSuffix} ${theme.fg("dim", formatDuration(delegationDurationMs(run, nowMs)))} ${theme.fg("muted", viewHint)}`);
790
+ lines.push(`${theme.fg("dim", prefix)} ${theme.fg(color, `${statusIcon(run.status)} ${run.agent}${kind}${background}`)}${badgeText ? ` ${theme.fg(delegationSignalColor(badge), badgeText)}` : ""}${modelSuffix} ${theme.fg("dim", formatDuration(delegationDurationMs(run, nowMs)))} ${theme.fg("muted", viewHint)}`);
791
+ lines.push(...renderMiniActivityLines(run, nowMs, expanded, theme));
762
792
  if (expanded && (run.errorMessage || run.stopReason || run.gateErrors?.length)) lines.push(` ${theme.fg("dim", run.errorMessage ?? run.gateErrors?.join("; ") ?? run.stopReason ?? "")}`);
763
793
  }
764
794
  if (hasMore) lines.push(theme.fg("dim", `└─ … ${monitoredRuns.length - maxRows} more child run(s)`));
@@ -827,6 +827,7 @@ export function registerDelegationTools(pi: ExtensionAPI, state: HarnessRuntimeS
827
827
  taskHash: sha256(structuredTask),
828
828
  originalUserAskHash: sha256(params.original_user_ask ?? state.activeGoal?.originalUserAsk ?? ""),
829
829
  outputContract,
830
+ background: params.run_in_background === true,
830
831
  startedAt,
831
832
  });
832
833
 
@@ -975,6 +976,8 @@ export function registerDelegationTools(pi: ExtensionAPI, state: HarnessRuntimeS
975
976
  };
976
977
 
977
978
  if (params.run_in_background) {
979
+ updateDelegationRun(state.delegations, runId, { background: true });
980
+ renderDelegationMonitor();
978
981
  const backgroundController = new AbortController();
979
982
  const backgroundPromise = runDelegateTaskChild(backgroundController.signal, false);
980
983
  const backgroundRun: BackgroundDelegationRuntimeRun = { runId, startedAtMs, promise: backgroundPromise, abortController: backgroundController };
@@ -8,7 +8,8 @@ import { isRecord } from "../core/utils/records.js";
8
8
  import type { AssistantLikeMessage, ModeName } from "../types.js";
9
9
  import { readHarnessReadinessWidgetData } from "../domains/orchestration/widget-readers.js";
10
10
  import { buildZpeerPeerRoomSummaries, type ZpeerPeerRoomSummary } from "../domains/coms/coms-v2/zpeer.js";
11
- import { delegationCost, delegationDurationMs, formatDelegationCost, formatDuration, summarizeDelegations } from "./delegation-monitor.js";
11
+ import { delegationCost, delegationDurationMs, formatDelegationCost, formatDuration, listWidgetDelegationRuns, summarizeDelegations } from "./delegation-monitor.js";
12
+ import { formatActivitySummary, readDelegationActivitySnapshot } from "./delegation-activity.js";
12
13
  import { disposeDelegationMouseSupport } from "./delegation-mouse.js";
13
14
  import { formatZcompactHudLine } from "./auto-compaction.js";
14
15
  import type { HarnessRuntimeState } from "./state.js";
@@ -339,9 +340,18 @@ export function renderHarnessWidget(pi: ExtensionAPI, state: HarnessRuntimeState
339
340
  ? "working"
340
341
  : "idle";
341
342
  const assistantsCount = delegationSummary.running + delegationSummary.queued + (runtimeTodoSummary?.delegated ?? 0);
342
- const assistantsState = assistantsCount > 0
343
- ? `${assistantsCount} active`
344
- : "none";
343
+ const widgetDelegationRuns = listWidgetDelegationRuns(state.delegations, renderNowMs);
344
+ const activeDelegationRun = widgetDelegationRuns.find((run) => run.status === "running" || run.status === "queued") ?? widgetDelegationRuns[0];
345
+ const activeDelegationSnapshot = activeDelegationRun ? readDelegationActivitySnapshot(activeDelegationRun.sessionPath, ctx.cwd, renderNowMs) : undefined;
346
+ const activeDelegationActivity = activeDelegationSnapshot?.current ?? activeDelegationSnapshot?.recent.at(-1);
347
+ const activeDelegationLine = activeDelegationRun && activeDelegationActivity
348
+ ? `${activeDelegationRun.agent}: ${formatActivitySummary(activeDelegationActivity, { nowMs: renderNowMs })[0] ?? activeDelegationActivity.toolName}`
349
+ : undefined;
350
+ const assistantsState = activeDelegationLine
351
+ ? truncateToWidth(`${assistantsCount > 0 ? `${assistantsCount} active` : "recent"} · ${activeDelegationLine}`, 96, "…")
352
+ : assistantsCount > 0
353
+ ? `${assistantsCount} active`
354
+ : "none";
345
355
  const zpeerRoomSummaries = cachedZpeerRoomSummaries(state, ctx, renderNowMs);
346
356
  const zpeerLast = state.zobLive.lastEvent
347
357
  ? `${state.zobLive.lastEvent.kind} ${state.zobLive.lastEvent.fromAlias ? `@${state.zobLive.lastEvent.fromAlias}` : "?"}→${state.zobLive.lastEvent.toAlias ? `@${state.zobLive.lastEvent.toAlias}` : "?"} ${state.zobLive.lastEvent.status}`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zob-harness",
3
- "version": "0.9.1",
3
+ "version": "0.10.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",
@@ -200,6 +200,8 @@ async function main() {
200
200
  const gammaEndpoint = join(root, 'gamma.sock');
201
201
  const workerOneEndpoint = join(root, 'worker-one.sock');
202
202
  const workerTwoEndpoint = join(root, 'worker-two.sock');
203
+ const adhocOneEndpoint = join(root, 'adhoc-one.sock');
204
+ const adhocTwoEndpoint = join(root, 'adhoc-two.sock');
203
205
  const pendingReplies = new Map();
204
206
  const receivedPrompts = [];
205
207
  const receivedResponses = [];
@@ -244,6 +246,11 @@ async function main() {
244
246
  receivedPrompts.push(incoming);
245
247
  return envelope.buildZobLiveAckEnvelope(incoming);
246
248
  }));
249
+ servers.push(await localTransport.bindZobLocalEndpoint(adhocOneEndpoint, async (incoming) => envelope.buildZobLiveAckEnvelope(incoming)));
250
+ servers.push(await localTransport.bindZobLocalEndpoint(adhocTwoEndpoint, async (incoming) => {
251
+ receivedPrompts.push(incoming);
252
+ return envelope.buildZobLiveAckEnvelope(incoming);
253
+ }));
247
254
 
248
255
  const oldHeartbeatAt = new Date(Date.now() - 180_000).toISOString();
249
256
  let alpha = zpeer.ensureZpeerFields(repoRoot, makePeer({ alias: 'alpha', roomId: 'room-one', endpoint: alphaEndpoint, endpointHash: hashing.sha256(alphaEndpoint), sha256: hashing.sha256, heartbeatAt: oldHeartbeatAt }), 'room-one', 'alpha');
@@ -279,6 +286,17 @@ async function main() {
279
286
  });
280
287
  });
281
288
 
289
+ const adhocOne = zpeer.refreshZpeerSelf(repoRoot, { ...zpeer.ensureZpeerFields(repoRoot, makePeer({ alias: 'adhocone', roomId: 'adhoc-room', endpoint: adhocOneEndpoint, endpointHash: hashing.sha256(adhocOneEndpoint), sha256: hashing.sha256 }), 'adhoc-room', 'adhocone'), zpeerAdhoc: true });
290
+ const adhocTwo = zpeer.refreshZpeerSelf(repoRoot, { ...zpeer.ensureZpeerFields(repoRoot, makePeer({ alias: 'adhoctwo', roomId: 'adhoc-room', endpoint: adhocTwoEndpoint, endpointHash: hashing.sha256(adhocTwoEndpoint), sha256: hashing.sha256 }), 'adhoc-room', 'adhoctwo'), zpeerAdhoc: true });
291
+ assert(adhocOne.zpeerAdhoc === true && adhocTwo.zpeerAdhoc === true, 'direct/ad-hoc zpeer peers must carry the ad-hoc marker');
292
+ const adhocSummary = zpeer.buildZpeerRoomSummary(repoRoot, adhocOne, 'adhoc-room');
293
+ assert(adhocSummary.peerCount === 2 && adhocSummary.online === 2, `ad-hoc room summary expected 2 online peers even with lease domain present, got ${adhocSummary.online}/${adhocSummary.peerCount}`);
294
+ assert(adhocSummary.aliases.includes('adhocone') && adhocSummary.aliases.includes('adhoctwo'), 'ad-hoc room summary must include both direct peers');
295
+ const adhocPromptCountBefore = receivedPrompts.length;
296
+ const adhocAsync = await zpeer.sendZpeerPrompt(repoRoot, adhocOne, 'adhoctwo', rawPrompt, waitForReply, { mode: 'async' });
297
+ assert(adhocAsync.status === 'waiting', `ad-hoc same-room send expected waiting after ACK, got ${adhocAsync.status}${adhocAsync.reason ? `: ${adhocAsync.reason}` : ''}`);
298
+ assert(receivedPrompts.length === adhocPromptCountBefore + 1 && receivedPrompts.at(-1).receiver === 'adhoctwo', 'ad-hoc same-room send must deliver to the target without a stable team-agent lease');
299
+
282
300
  const joinedAlpha = await zpeer.joinZpeerRoom(repoRoot, alpha, 'shared-room', 'sharedalpha', 'bridge');
283
301
  assert(joinedAlpha.ok === true, `alpha multi-room join expected ok, got ${joinedAlpha.reason ?? 'not ok'}`);
284
302
  alpha = joinedAlpha.peer;
@@ -34,6 +34,7 @@ const files = [
34
34
  '.pi/extensions/zob-harness/src/domains/coms/coms-v2/registry.ts',
35
35
  '.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer-profile.ts',
36
36
  '.pi/extensions/zob-harness/src/domains/coms/coms-v2/transcript-capture.ts',
37
+ '.pi/extensions/zob-harness/src/domains/delegation/child-runner.ts',
37
38
  '.pi/extensions/zob-harness/src/runtime/commands.ts',
38
39
  '.pi/extensions/zob-harness/src/runtime/tools-coms.ts',
39
40
  '.pi/extensions/zob-harness/src/runtime/events.ts',
@@ -82,7 +83,7 @@ for (const forbidden of ['transientPrompt:', 'transientResponse:', 'prompt:', 'o
82
83
  const zpeer = contents['.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer.ts'];
83
84
  const liveRegistry = contents['.pi/extensions/zob-harness/src/domains/coms/coms-v2/registry.ts'];
84
85
  const zpeerProfile = contents['.pi/extensions/zob-harness/src/domains/coms/coms-v2/zpeer-profile.ts'];
85
- 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']) {
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']) {
86
87
  if (!zpeer.includes(needle)) failures.push(`zpeer missing ${needle}`);
87
88
  }
88
89
  for (const needle of ['peer-messages.jsonl', 'peer-status.jsonl', 'appendZpeerPeerRecords', 'reasonHash', 'bodyStored: false']) {
@@ -96,7 +97,8 @@ if (!zpeer.includes('const bothPeersAreWorkers = self.roleType === "worker" && t
96
97
  if (hardTeamMismatchIndex !== -1 && (roomFirstTopologyIndex === -1 || hardTeamMismatchIndex < roomFirstTopologyIndex)) failures.push('zpeer topology must not hard-block cross-team peers before same-room allowance');
97
98
  if (!zpeer.includes('const candidates = peersInRoom(repoRoot, roomId).filter') || !zpeer.includes('if (!selfMembership)') || !zpeer.includes('current peer is observer-only in room')) failures.push('zpeer same-room allowance must preserve room candidate, self membership, and observer guards');
98
99
  if (!liveRegistry.includes('readZobLiveRegistryAllProjectsSnapshot') || !liveRegistry.includes('join(projectsDir, entry.name, "agents")')) failures.push('live registry must expose all-project agents room discovery helper');
99
- for (const needle of ['ZobLiveTeamAgentLease', 'zob.live-team-agent-lease.v1', 'stableLease: true', 'exclusiveBy: "teamId+agentId"', 'claimZobLiveTeamAgentLease', 'leaseRespondsToPing', 'pingZobLocalEndpoint', 'releaseZobLiveTeamAgentLease', 'ownsZobLiveTeamAgentLease', 'sameLeaseOwner', 'retireInactiveZobLiveTeamAgentLeases', 'readLeasesFromDir', 'hasLeaseDomain']) {
100
+ if (!liveRegistry.includes('peer.zpeerAdhoc !== true') || !liveRegistry.includes('mergeLeaseBackedAndAdhocPeers(leaseDirs.flatMap')) failures.push('live registry must merge explicit ad-hoc room peer cards into lease-backed summaries');
101
+ for (const needle of ['ZobLiveTeamAgentLease', 'zob.live-team-agent-lease.v1', 'stableLease: true', 'exclusiveBy: "teamId+agentId"', 'claimZobLiveTeamAgentLease', 'leaseRespondsToPing', 'pingZobLocalEndpoint', 'releaseZobLiveTeamAgentLease', 'ownsZobLiveTeamAgentLease', 'sameLeaseOwner', 'retireInactiveZobLiveTeamAgentLeases', 'readLeasesFromDir', 'hasLeaseDomain', 'mergeLeaseBackedAndAdhocPeers']) {
100
102
  if (!liveRegistry.includes(needle)) failures.push(`live registry stable lease support missing ${needle}`);
101
103
  }
102
104
  if (!zpeer.includes('readZobLiveRegistryAllProjectsSnapshot(repoRoot)')) failures.push('zpeer room discovery must use all-project registry snapshots');
@@ -172,7 +174,7 @@ for (const needle of ['readZpeerNewCarryoverProfile(repoRoot)', 'const carryover
172
174
  }
173
175
  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');
174
176
  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');
175
- 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"']) {
177
+ 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']) {
176
178
  if (!events.includes(needle)) failures.push(`runtime missing zpeer awareness/event support ${needle}`);
177
179
  }
178
180
  const responseSentBlock = events.match(/setZpeerLastEvent\(state, \{\s*kind: "response_sent"[\s\S]*?customType: "zob-zpeer-event"[\s\S]*?triggerTurn: false[\s\S]*?\}\);/)?.[0] ?? '';
@@ -212,6 +214,11 @@ const goalRuntime = contents['.pi/extensions/zob-harness/src/runtime/goal-runtim
212
214
  for (const needle of ['state.zobLive.passivePeerWait?.suppressGoalContinuation === true', 'clearRuntimeGoalContinuationTimer(state)', 'return;']) {
213
215
  if (!goalRuntime.includes(needle)) failures.push(`goal runtime missing passive peer continuation suppression ${needle}`);
214
216
  }
217
+
218
+ const childRunner = contents['.pi/extensions/zob-harness/src/domains/delegation/child-runner.ts'];
219
+ for (const needle of ['childModelPattern', 'ctx.model', 'resolveCodexFastModeExtension', 'getAgentDir()', 'codex-fast-mode.ts', 'childCodexFastModeExtension', 'args.push("-e", childCodexFastModeExtension)', 'const model = resolvedModel']) {
220
+ if (!childRunner.includes(needle)) failures.push(`delegation child runner missing Codex auto/model inheritance support ${needle}`);
221
+ }
215
222
  for (const needle of ['updatePassivePeerWaitState(state, result', 'result.status !== "waiting"', 'state.zobLive.passivePeerWait = undefined', 'schema: "zob.passive-peer-wait.v1"', 'source: "zpeer_ask"', 'suppressGoalContinuation: true', 'bodyStored: false', 'localOnly: true', 'networkEnabled: false']) {
216
223
  if (!toolsComs.includes(needle)) failures.push(`zpeer_ask missing passive peer wait state handling ${needle}`);
217
224
  }