pi-orchestrator-config 4.12.0 → 4.14.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/README.md +20 -3
- package/agents/git-expert.md +3 -1
- package/extensions/coms/coms-history.ts +193 -0
- package/extensions/coms/coms-p2p.ts +621 -154
- package/extensions/coms/coms-shared.ts +89 -0
- package/extensions/orchestrator/enforcement-helpers.ts +20 -0
- package/extensions/orchestrator/enforcement.ts +21 -1
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -106,13 +106,30 @@ Two systems for Pi agents to communicate with each other, activated on-demand vi
|
|
|
106
106
|
|
|
107
107
|
| Tool | Description |
|
|
108
108
|
| -------------------- | --------------------------------------------------------------- |
|
|
109
|
-
| `*_list` | List peer agents with names, models, context usage, queue depth |
|
|
110
|
-
| `*_send` | Send a prompt to a peer agent
|
|
111
|
-
| `*
|
|
109
|
+
| `*_list` | List peer agents with names, models, context usage, queue depth, busy state |
|
|
110
|
+
| `*_send` | Send a prompt to a peer agent (supports `steer`, `priority`) |
|
|
111
|
+
| `*_broadcast` | Send one prompt to multiple peers at once (`*` = whole project) |
|
|
112
|
+
| `*_history` | Query past coms traffic (peer, direction, time, status filters) |
|
|
113
|
+
| `*_get` | Non-blocking poll for a response (surfaces peer progress notes) |
|
|
112
114
|
| `*_await` | Block until response arrives |
|
|
113
115
|
| `coms_queue_inspect` | Preview owned queued P2P messages before recovery |
|
|
114
116
|
| `coms_queue_clear` | Irreversibly clear only an inspect preview |
|
|
115
117
|
|
|
118
|
+
Messaging semantics (#924):
|
|
119
|
+
|
|
120
|
+
- **Steer** (`coms_send steer: true`) injects a message into a busy peer's
|
|
121
|
+
in-flight turn — course correction only, ack-only, capped at 5 per turn.
|
|
122
|
+
- **Priority** (`priority: true`) jumps the receiver's queued backlog; it never
|
|
123
|
+
interrupts an in-flight turn.
|
|
124
|
+
- **Broadcast** (`coms_broadcast`) fans one prompt out to several peers with a
|
|
125
|
+
per-peer ✓/✗ report; partial failures are reported, never swallowed.
|
|
126
|
+
- **Busy/progress** — peers show `⚙busy` while processing; a peer working on
|
|
127
|
+
your message sends ~60s progress signals that annotate your pending reply.
|
|
128
|
+
- **History** — every prompt, steer, and reply (both directions) is recorded
|
|
129
|
+
in `~/.pi/coms/history.jsonl` (fail-soft, size-rotated) and queryable via
|
|
130
|
+
`coms_history`; defaults to this session's traffic, `all_sessions: true`
|
|
131
|
+
shows the whole machine's trail.
|
|
132
|
+
|
|
116
133
|
Queue recovery is explicit. Run `coms_queue_inspect` first and review its
|
|
117
134
|
body-free preview. Then pass its `preview_id` to `coms_queue_clear`.
|
|
118
135
|
Reconnects and timeouts never clear queues. This recovers the local P2P coms
|
package/agents/git-expert.md
CHANGED
|
@@ -45,7 +45,9 @@ Before ANY `git commit`, if `review_loop_enforcement` is `true`:
|
|
|
45
45
|
|
|
46
46
|
- If `dco` is `true`: add `--signoff` to `git commit`. Skip if already present.
|
|
47
47
|
- If `commit_trailer` is a string (e.g. `"Assisted-by"`): append trailer to the commit message.
|
|
48
|
-
Format: `TRAILER: PI (MODEL) <noreply@pi.dev>`
|
|
48
|
+
Format: `TRAILER: PI (MODEL) <noreply@pi.dev>` — replace MODEL with your actual model id
|
|
49
|
+
(e.g. `litellm/claude-opus-4-6`). NEVER write the literal string `$PI_MODEL`; quoted heredocs
|
|
50
|
+
and single-quoted messages do not expand it and the trailer lands broken. Skip if already present.
|
|
49
51
|
If the value contains commas, use the first name.
|
|
50
52
|
- If `use_worktrees` is `true`: BLOCK `git checkout` (except `git checkout -- <file>`) and `git switch`.
|
|
51
53
|
Use `git worktree add .worktrees/NAME -b BRANCH DEFAULT_BRANCH` instead
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-peer message history (#924 §5): a local JSONL audit trail of coms
|
|
3
|
+
* traffic — outbound prompts, inbound prompts (incl. steers), and responses
|
|
4
|
+
* in both directions — queryable via the coms_history tool.
|
|
5
|
+
*
|
|
6
|
+
* Append-only and fail-soft: a history failure is logged and swallowed and
|
|
7
|
+
* must never break message delivery. Previews are flattened and capped, so
|
|
8
|
+
* the trail stays small; full prompts remain in the session transcript.
|
|
9
|
+
*
|
|
10
|
+
* The file is machine-wide (all coms sessions on the host share it); each
|
|
11
|
+
* record carries the appending session's id in `owner` so queries can scope
|
|
12
|
+
* to one session's traffic by default (all_sessions opts into the full trail).
|
|
13
|
+
* Size-rotation keeps the file bounded: past HISTORY_MAX_FILE_BYTES the
|
|
14
|
+
* oldest lines are dropped to the last HISTORY_KEEP_BYTES of complete lines.
|
|
15
|
+
*/
|
|
16
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { dirname } from "node:path";
|
|
18
|
+
import { createLogger } from "../shared/logger.js";
|
|
19
|
+
|
|
20
|
+
const log = createLogger("coms-history");
|
|
21
|
+
|
|
22
|
+
export type HistoryDirection = "out" | "in" | "steer" | "response_out" | "response_in";
|
|
23
|
+
|
|
24
|
+
export interface HistoryRecord {
|
|
25
|
+
ts: string;
|
|
26
|
+
msg_id: string;
|
|
27
|
+
/** out = prompt we sent; in = prompt we received; steer = mid-turn steer we received;
|
|
28
|
+
* response_out = reply we sent; response_in = reply we received. */
|
|
29
|
+
direction: HistoryDirection;
|
|
30
|
+
peer: string;
|
|
31
|
+
/** Appending session's coms session id — scopes queries on the shared file. */
|
|
32
|
+
owner?: string;
|
|
33
|
+
session?: string;
|
|
34
|
+
preview: string;
|
|
35
|
+
status?: string;
|
|
36
|
+
error?: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface HistoryFilter {
|
|
40
|
+
peer?: string;
|
|
41
|
+
direction?: HistoryDirection;
|
|
42
|
+
msg_id?: string;
|
|
43
|
+
/** Inclusive lower bound (ISO timestamp): drop records older than this. */
|
|
44
|
+
since?: string;
|
|
45
|
+
/** Case-insensitive substring match on status. */
|
|
46
|
+
status?: string;
|
|
47
|
+
/** Exact owner-session match; omit to include every session's records. */
|
|
48
|
+
owner?: string;
|
|
49
|
+
limit?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Preview cap — flattened whitespace, hard-truncated with an ellipsis. */
|
|
53
|
+
export const HISTORY_PREVIEW_MAX = 120;
|
|
54
|
+
/** Query result cap — newest-last window over the matching records. */
|
|
55
|
+
export const HISTORY_LIMIT_MAX = 500;
|
|
56
|
+
/** Rotation threshold — past this size the file is trimmed on the next append. */
|
|
57
|
+
export const HISTORY_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
|
58
|
+
/** Post-rotation size — the newest complete lines up to roughly this size survive. */
|
|
59
|
+
export const HISTORY_KEEP_BYTES = 2 * 1024 * 1024;
|
|
60
|
+
|
|
61
|
+
export function truncatePreview(text: string, max: number = HISTORY_PREVIEW_MAX): string {
|
|
62
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
63
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Shape check for parsed lines: JSON `null`/primitives/arrays are not records. */
|
|
67
|
+
function isHistoryRecord(value: unknown): value is HistoryRecord {
|
|
68
|
+
return (
|
|
69
|
+
typeof value === "object" && value !== null && !Array.isArray(value) &&
|
|
70
|
+
typeof (value as HistoryRecord).msg_id === "string" &&
|
|
71
|
+
typeof (value as HistoryRecord).ts === "string"
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Trim an over-large trail in place: keep the newest complete lines that fit
|
|
76
|
+
* HISTORY_KEEP_BYTES. Fail-soft — rotation failure never blocks the append. */
|
|
77
|
+
function rotateIfNeeded(filePath: string): void {
|
|
78
|
+
try {
|
|
79
|
+
if (!existsSync(filePath)) return;
|
|
80
|
+
const size = statSync(filePath).size;
|
|
81
|
+
if (size <= HISTORY_MAX_FILE_BYTES) return;
|
|
82
|
+
const content = readFileSync(filePath, "utf-8");
|
|
83
|
+
const lines = content.split("\n").filter(Boolean);
|
|
84
|
+
const kept: string[] = [];
|
|
85
|
+
let keptBytes = 0;
|
|
86
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
87
|
+
const lineBytes = Buffer.byteLength(lines[i]) + 1;
|
|
88
|
+
if (keptBytes + lineBytes > HISTORY_KEEP_BYTES && kept.length > 0) break;
|
|
89
|
+
kept.unshift(lines[i]);
|
|
90
|
+
keptBytes += lineBytes;
|
|
91
|
+
}
|
|
92
|
+
writeFileSync(filePath, kept.length > 0 ? `${kept.join("\n")}\n` : "", "utf-8");
|
|
93
|
+
log.info(`history rotated: ${size} bytes → ${keptBytes} bytes, ${lines.length - kept.length} old record(s) dropped`);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
log.warn(`history rotation failed (swallowed): ${error instanceof Error ? error.message : String(error)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Synchronous sleep (Atomics.wait on a fresh SharedArrayBuffer). */
|
|
100
|
+
function syncSleepMs(ms: number): void {
|
|
101
|
+
try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* best-effort */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const HISTORY_LOCK_STALE_MS = 10_000;
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Cross-process mutual exclusion for history writes (#924 §5, Qodo ade011fc):
|
|
108
|
+
* the file is shared by every coms session on the machine, and rotation is a
|
|
109
|
+
* read-modify-write — an append landing between another process's read and
|
|
110
|
+
* rewrite would be erased. mkdir is atomic, so a lock directory next to the
|
|
111
|
+
* file serializes rotate+append across processes. Best-effort by contract:
|
|
112
|
+
* bounded wait, stale locks (crashed holder) are stolen, and failure to
|
|
113
|
+
* acquire falls back to a plain append — the worst case is a deferred
|
|
114
|
+
* rotation, never a lost delivery.
|
|
115
|
+
*/
|
|
116
|
+
function withHistoryLock<T>(filePath: string, fn: (locked: boolean) => T): T {
|
|
117
|
+
const lockDir = `${filePath}.lock`;
|
|
118
|
+
const deadline = Date.now() + 200;
|
|
119
|
+
for (;;) {
|
|
120
|
+
try {
|
|
121
|
+
mkdirSync(lockDir);
|
|
122
|
+
try {
|
|
123
|
+
return fn(true);
|
|
124
|
+
} finally {
|
|
125
|
+
try { rmSync(lockDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
126
|
+
}
|
|
127
|
+
} catch (error: any) {
|
|
128
|
+
if (error?.code !== "EEXIST") break;
|
|
129
|
+
// Steal a stale lock left behind by a crashed holder.
|
|
130
|
+
try {
|
|
131
|
+
const lockAge = Date.now() - statSync(lockDir).mtimeMs;
|
|
132
|
+
if (lockAge > HISTORY_LOCK_STALE_MS) {
|
|
133
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
134
|
+
log.warn("history lock stale — stolen");
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
} catch { /* lock vanished — retry immediately */ }
|
|
138
|
+
if (Date.now() >= deadline) break;
|
|
139
|
+
syncSleepMs(10);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Lock unavailable — run unlocked. Rotation is skipped (the caller checks
|
|
143
|
+
// the locked flag) so the read-modify-write race cannot recur; only the
|
|
144
|
+
// rotation is deferred, never the append.
|
|
145
|
+
return fn(false);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Append one record. Fail-soft by contract — delivery never waits on history. */
|
|
149
|
+
export function appendHistoryRecord(filePath: string, record: HistoryRecord): boolean {
|
|
150
|
+
try {
|
|
151
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
152
|
+
withHistoryLock(filePath, (locked) => {
|
|
153
|
+
if (locked) rotateIfNeeded(filePath);
|
|
154
|
+
appendFileSync(filePath, `${JSON.stringify(record)}\n`, "utf-8");
|
|
155
|
+
});
|
|
156
|
+
return true;
|
|
157
|
+
} catch (error) {
|
|
158
|
+
log.warn(`history append failed (swallowed): ${error instanceof Error ? error.message : String(error)}`);
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Query the trail. Malformed lines are skipped; missing file is empty. */
|
|
164
|
+
export function queryHistoryRecords(filePath: string, filter: HistoryFilter = {}): HistoryRecord[] {
|
|
165
|
+
const limit = Math.max(1, Math.min(filter.limit ?? 50, HISTORY_LIMIT_MAX));
|
|
166
|
+
let lines: string[];
|
|
167
|
+
try {
|
|
168
|
+
if (!existsSync(filePath)) return [];
|
|
169
|
+
lines = readFileSync(filePath, "utf-8").split("\n").filter(Boolean);
|
|
170
|
+
} catch (error) {
|
|
171
|
+
log.warn(`history read failed (swallowed): ${error instanceof Error ? error.message : String(error)}`);
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
const records: HistoryRecord[] = [];
|
|
175
|
+
for (const line of lines) {
|
|
176
|
+
try {
|
|
177
|
+
const parsed: unknown = JSON.parse(line);
|
|
178
|
+
if (isHistoryRecord(parsed)) records.push(parsed);
|
|
179
|
+
} catch {
|
|
180
|
+
// skip malformed line — history is best-effort
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const filtered = records.filter(
|
|
184
|
+
(r) =>
|
|
185
|
+
(!filter.peer || r.peer === filter.peer) &&
|
|
186
|
+
(!filter.direction || r.direction === filter.direction) &&
|
|
187
|
+
(!filter.msg_id || r.msg_id === filter.msg_id) &&
|
|
188
|
+
(!filter.since || r.ts >= filter.since) &&
|
|
189
|
+
(!filter.status || (r.status ?? "").toLowerCase().includes(filter.status.toLowerCase())) &&
|
|
190
|
+
(!filter.owner || r.owner === filter.owner),
|
|
191
|
+
);
|
|
192
|
+
return filtered.slice(-limit);
|
|
193
|
+
}
|