@cydm/pie 2.7.13 → 2.7.14

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.
@@ -0,0 +1,1671 @@
1
+ import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);
2
+ import {
3
+ Type
4
+ } from "../../../chunks/chunk-XXGYUYXI.js";
5
+ import "../../../chunks/chunk-LZQXQOPJ.js";
6
+
7
+ // builtin/extensions/pie-ipc/index.ts
8
+ import * as fs2 from "node:fs";
9
+ import * as path2 from "node:path";
10
+
11
+ // builtin/extensions/pie-ipc/pure.ts
12
+ import { randomBytes } from "node:crypto";
13
+ var IPC_PROTOCOL_VERSION = 1;
14
+ var INSTANCE_ID_PATTERN = /^pie-[a-z0-9]{12}$/;
15
+ var MAX_STATUS_FILE_BYTES = 128 * 1024;
16
+ var MAX_DISPLAY_NAME_CHARS = 64;
17
+ var MAX_STATUS_TEXT_FIELD_CHARS = 4096;
18
+ var MAX_BODY_CHARS = 8e3;
19
+ var MAX_STATUS_BODY_CHARS = 2e3;
20
+ var STATUS_FILE_STALE_MS = 12e4;
21
+ var HEARTBEAT_INTERVAL_MS = 2e4;
22
+ var DEFAULT_SEND_WAIT_MS = 12e4;
23
+ var MAX_SEEN_ENVELOPE_IDS = 1e3;
24
+ var MAX_INBOX_MESSAGES = 100;
25
+ var MAX_INFLIGHT_SENDS = 8;
26
+ var INSTANCE_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
27
+ function generateInstanceId(randomSource = defaultRandomBytes) {
28
+ const bytes = randomSource(12);
29
+ let id = "";
30
+ for (const byte of bytes) {
31
+ id += INSTANCE_ID_ALPHABET[byte % INSTANCE_ID_ALPHABET.length];
32
+ }
33
+ return `pie-${id}`;
34
+ }
35
+ function defaultRandomBytes(count) {
36
+ return randomBytes(count);
37
+ }
38
+ function isValidInstanceId(id) {
39
+ return INSTANCE_ID_PATTERN.test(id);
40
+ }
41
+ function peerLabel(instanceId, displayName) {
42
+ const name = displayName?.trim();
43
+ if (name) return name;
44
+ return instanceId.length > 12 ? instanceId.slice(0, 12) : instanceId;
45
+ }
46
+ var ENVELOPE_KINDS = /* @__PURE__ */ new Set([
47
+ "hello",
48
+ "bye",
49
+ "ping",
50
+ "message",
51
+ "ack",
52
+ "result",
53
+ "state.get",
54
+ "state"
55
+ ]);
56
+ var LIFECYCLE_STATES = /* @__PURE__ */ new Set(["working", "idle", "blocked"]);
57
+ function parseEnvelope(raw) {
58
+ let parsed;
59
+ try {
60
+ parsed = JSON.parse(raw);
61
+ } catch {
62
+ return null;
63
+ }
64
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
65
+ const record = parsed;
66
+ if (typeof record.v !== "number" || record.v !== IPC_PROTOCOL_VERSION) return null;
67
+ if (typeof record.id !== "string" || !/^[a-zA-Z0-9:-]{1,128}$/.test(record.id)) return null;
68
+ if (typeof record.from !== "string" || !isValidInstanceId(record.from)) return null;
69
+ if (typeof record.to !== "string" || !isValidInstanceId(record.to)) return null;
70
+ if (record.text !== void 0 && (typeof record.text !== "string" || record.text.length > MAX_BODY_CHARS + 100)) return null;
71
+ if (record.sessionId !== void 0 && (typeof record.sessionId !== "string" || record.sessionId.length > 256)) return null;
72
+ if (record.replyTo !== void 0 && (typeof record.replyTo !== "string" || !/^[a-zA-Z0-9:-]{1,128}$/.test(record.replyTo))) return null;
73
+ if (record.waitMs !== void 0 && (typeof record.waitMs !== "number" || !Number.isFinite(record.waitMs) || record.waitMs < 0 || record.waitMs > 6e5)) return null;
74
+ if (typeof record.kind !== "string" || !ENVELOPE_KINDS.has(record.kind)) return null;
75
+ if (typeof record.createdAt !== "number" || !Number.isSafeInteger(record.createdAt) || record.createdAt < 0) return null;
76
+ const envelope = {
77
+ v: record.v,
78
+ id: record.id,
79
+ from: record.from,
80
+ to: record.to,
81
+ kind: record.kind,
82
+ createdAt: record.createdAt
83
+ };
84
+ if (typeof record.auth === "string" && /^[a-f0-9]{64}$/.test(record.auth)) envelope.auth = record.auth;
85
+ if (typeof record.sessionId === "string") envelope.sessionId = record.sessionId;
86
+ if (typeof record.text === "string") envelope.text = record.text;
87
+ if (typeof record.replyTo === "string") envelope.replyTo = record.replyTo;
88
+ if (typeof record.waitMs === "number" && Number.isFinite(record.waitMs)) envelope.waitMs = record.waitMs;
89
+ return envelope;
90
+ }
91
+ var FrameTooLargeError = class extends Error {
92
+ constructor() {
93
+ super("pie-ipc frame exceeds byte cap");
94
+ this.name = "FrameTooLargeError";
95
+ }
96
+ };
97
+ var FrameDecoder = class {
98
+ buffer = "";
99
+ get partial() {
100
+ return this.buffer.length > 0;
101
+ }
102
+ push(chunk) {
103
+ this.buffer += chunk;
104
+ if (this.buffer.split("\n").some((line) => Buffer.byteLength(line, "utf8") > 64 * 1024)) {
105
+ this.buffer = "";
106
+ throw new FrameTooLargeError();
107
+ }
108
+ const envelopes = [];
109
+ let newline = this.buffer.indexOf("\n");
110
+ while (newline !== -1) {
111
+ const line = this.buffer.slice(0, newline).trim();
112
+ this.buffer = this.buffer.slice(newline + 1);
113
+ if (line) {
114
+ const envelope = parseEnvelope(line);
115
+ if (envelope) envelopes.push(envelope);
116
+ }
117
+ newline = this.buffer.indexOf("\n");
118
+ }
119
+ return envelopes;
120
+ }
121
+ };
122
+ var WRAP_PREFIX = "[pie-ipc]";
123
+ function wrapInboundText(fromLabel, text, at = Date.now(), messageId) {
124
+ const stamp = new Date(at).toISOString().slice(11, 19);
125
+ const marker = messageId ? ` #${clipMarker(messageId)} ` : " ";
126
+ return `${WRAP_PREFIX} ${stamp}${marker}peer "${clipLabel(fromLabel)}" says:
127
+ ${clipBody(text, MAX_BODY_CHARS)}`;
128
+ }
129
+ function isWrappedInboundText(text) {
130
+ return text.startsWith(`${WRAP_PREFIX} `);
131
+ }
132
+ function wrappedMessageMarker(text) {
133
+ if (!isWrappedInboundText(text)) return null;
134
+ const firstLine = text.split("\n", 1)[0];
135
+ const match = / #(\S+) peer "/.exec(firstLine);
136
+ return match ? match[1] : null;
137
+ }
138
+ function clipMarker(id) {
139
+ const clipped = id;
140
+ return clipped || "noid";
141
+ }
142
+ function clipLabel(label) {
143
+ const collapsed = label.replace(/\s+/g, " ").trim().slice(0, 60);
144
+ return collapsed || "unknown";
145
+ }
146
+ function clipBody(text, maxChars = MAX_BODY_CHARS) {
147
+ if (text.length <= maxChars) return text;
148
+ return `${text.slice(0, maxChars)}\u2026[truncated ${text.length - maxChars} chars]`;
149
+ }
150
+ var BoundedIdSet = class {
151
+ constructor(max = MAX_SEEN_ENVELOPE_IDS) {
152
+ this.max = max;
153
+ }
154
+ max;
155
+ seen = /* @__PURE__ */ new Map();
156
+ /** Returns true when the id is new (and records it). */
157
+ add(id, now = Date.now()) {
158
+ if (this.seen.has(id)) return false;
159
+ this.seen.set(id, now);
160
+ if (this.seen.size > this.max) {
161
+ const drop = Math.max(1, Math.floor(this.max / 4));
162
+ let dropped = 0;
163
+ for (const key of this.seen.keys()) {
164
+ if (dropped >= drop) break;
165
+ this.seen.delete(key);
166
+ dropped += 1;
167
+ }
168
+ }
169
+ return true;
170
+ }
171
+ has(id) {
172
+ return this.seen.has(id);
173
+ }
174
+ get size() {
175
+ return this.seen.size;
176
+ }
177
+ };
178
+ var signalAliveCheck = (pid) => {
179
+ try {
180
+ process.kill(pid, 0);
181
+ return true;
182
+ } catch (error) {
183
+ return error.code === "EPERM";
184
+ }
185
+ };
186
+ function peerHealth(file, now = Date.now(), aliveCheck = signalAliveCheck) {
187
+ if (Number.isInteger(file.pid) && file.pid > 0 && !aliveCheck(file.pid)) return "dead";
188
+ if (now - file.updatedAt > STATUS_FILE_STALE_MS) return "stale";
189
+ return "alive";
190
+ }
191
+ function parseStatusFile(raw) {
192
+ let parsed;
193
+ try {
194
+ parsed = JSON.parse(raw);
195
+ } catch {
196
+ return null;
197
+ }
198
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
199
+ const record = parsed;
200
+ if (record.schema !== 1) return null;
201
+ if (typeof record.instanceId !== "string" || !isValidInstanceId(record.instanceId)) return null;
202
+ if (typeof record.pid !== "number" || !Number.isInteger(record.pid) || record.pid <= 0 || record.pid > 99999999) return null;
203
+ if (typeof record.protocol !== "number" || record.protocol !== IPC_PROTOCOL_VERSION) return null;
204
+ if (typeof record.displayName !== "string" || !record.displayName || record.displayName.length > MAX_DISPLAY_NAME_CHARS) return null;
205
+ if (hasControlChars(record.displayName)) return null;
206
+ if (record.sessionId !== null && (typeof record.sessionId !== "string" || record.sessionId.length > 256 || hasControlChars(record.sessionId))) return null;
207
+ if (typeof record.cwd !== "string" || record.cwd.length > MAX_STATUS_TEXT_FIELD_CHARS || hasControlChars(record.cwd)) return null;
208
+ if (typeof record.startedAt !== "number" || !Number.isSafeInteger(record.startedAt) || record.startedAt < 0) return null;
209
+ if (typeof record.updatedAt !== "number" || !Number.isSafeInteger(record.updatedAt) || record.updatedAt < 0) return null;
210
+ if (typeof record.state !== "string" || !LIFECYCLE_STATES.has(record.state)) return null;
211
+ const file = {
212
+ schema: 1,
213
+ instanceId: record.instanceId,
214
+ pid: record.pid,
215
+ protocol: record.protocol,
216
+ displayName: record.displayName,
217
+ sessionId: record.sessionId,
218
+ cwd: record.cwd,
219
+ startedAt: record.startedAt,
220
+ updatedAt: record.updatedAt,
221
+ state: record.state
222
+ };
223
+ if (Array.isArray(record.allowPeers) && record.allowPeers.length <= 1e3 && record.allowPeers.every((id) => typeof id === "string" && (id === "*" || isValidInstanceId(id)))) file.allowPeers = record.allowPeers;
224
+ if (typeof record.secret === "string" && /^[a-f0-9]{64}$/.test(record.secret)) file.secret = record.secret;
225
+ if (typeof record.sessionName === "string" && !hasControlChars(record.sessionName) && record.sessionName.length <= MAX_DISPLAY_NAME_CHARS) file.sessionName = record.sessionName;
226
+ if (typeof record.currentTool === "string" && !hasControlChars(record.currentTool) && record.currentTool.length <= 200) file.currentTool = record.currentTool;
227
+ if (typeof record.blockedSince === "number" && Number.isSafeInteger(record.blockedSince) && record.blockedSince >= 0) file.blockedSince = record.blockedSince;
228
+ if (typeof record.turnStartedAt === "number" && Number.isSafeInteger(record.turnStartedAt) && record.turnStartedAt >= 0) file.turnStartedAt = record.turnStartedAt;
229
+ if (typeof record.undeliveredInjections === "number" && Number.isFinite(record.undeliveredInjections)) file.undeliveredInjections = Math.min(100, Math.max(0, Math.floor(record.undeliveredInjections)));
230
+ if (typeof record.lastBodyText === "string" && record.lastBodyText.length <= MAX_STATUS_TEXT_FIELD_CHARS && !/[\u0000-\u0008\u000b-\u001f\u007f]/.test(record.lastBodyText)) file.lastBodyText = record.lastBodyText;
231
+ if (typeof record.lastBodyAt === "number" && Number.isSafeInteger(record.lastBodyAt) && record.lastBodyAt >= 0) file.lastBodyAt = record.lastBodyAt;
232
+ if (typeof record.port === "number" && Number.isInteger(record.port) && record.port > 0 && record.port <= 65535) file.port = record.port;
233
+ return file;
234
+ }
235
+ function hasControlChars(text) {
236
+ return /[\u0000-\u001f\u007f]/.test(text);
237
+ }
238
+ var BLOCKING_TOOL_NAMES = /* @__PURE__ */ new Set(["ask_user_multi"]);
239
+ function isBlockingTool(toolName) {
240
+ return BLOCKING_TOOL_NAMES.has(toolName);
241
+ }
242
+ function foldLifecycleEvent(current, event, now = Date.now()) {
243
+ switch (event.type) {
244
+ case "turn:start":
245
+ case "agent:start": {
246
+ const next = { state: "working", turnStartedAt: now };
247
+ if (current.blockedSince !== void 0) next.blockedSince = current.blockedSince;
248
+ return next;
249
+ }
250
+ case "tool:execution:start": {
251
+ if (current.state === "idle") return { ...current, state: "working", turnStartedAt: current.turnStartedAt ?? now };
252
+ if (event.toolName && isBlockingTool(event.toolName) && current.state !== "blocked") {
253
+ return { ...current, state: "blocked", blockedSince: now, currentTool: event.toolName };
254
+ }
255
+ return { ...current, currentTool: event.toolName };
256
+ }
257
+ case "tool:execution:end": {
258
+ if (current.state === "blocked" && (!event.toolName || isBlockingTool(event.toolName))) {
259
+ return { ...current, state: "working", blockedSince: void 0, currentTool: void 0 };
260
+ }
261
+ return current;
262
+ }
263
+ case "turn:completed":
264
+ case "turn:failed":
265
+ case "turn:cancelled":
266
+ case "agent:end": {
267
+ if (current.state === "idle") return current;
268
+ return { state: "idle" };
269
+ }
270
+ default:
271
+ return current;
272
+ }
273
+ }
274
+ function resolveIpcConfig(configLayers) {
275
+ const problems = [];
276
+ const merged = {};
277
+ for (const layer of configLayers) {
278
+ if (layer.content === void 0) continue;
279
+ try {
280
+ const parsed = JSON.parse(layer.content);
281
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
282
+ problems.push(`${layer.file}: root is not an object`);
283
+ continue;
284
+ }
285
+ Object.assign(merged, parsed);
286
+ } catch (error) {
287
+ problems.push(`${layer.file}: ${error instanceof Error ? error.message : String(error)}`);
288
+ }
289
+ }
290
+ if (merged.enabled !== true) return { config: null, problems };
291
+ const config = { enabled: true };
292
+ if (typeof merged.displayName === "string" && merged.displayName.trim()) {
293
+ config.displayName = merged.displayName.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 60);
294
+ }
295
+ if (Array.isArray(merged.allowPeers)) {
296
+ const ids = merged.allowPeers.filter((id) => typeof id === "string" && (id === "*" || isValidInstanceId(id))).slice(0, 1e3);
297
+ if (ids.length) config.allowPeers = ids;
298
+ }
299
+ if (merged.autoInject !== void 0) config.autoInject = merged.autoInject === true;
300
+ if (typeof merged.maxBodyChars === "number" && Number.isFinite(merged.maxBodyChars)) {
301
+ const bounded = Math.min(Math.max(Math.floor(merged.maxBodyChars), 100), MAX_BODY_CHARS);
302
+ if (bounded !== merged.maxBodyChars) {
303
+ problems.push(`pie-ipc: maxBodyChars clamped to ${bounded}`);
304
+ }
305
+ config.maxBodyChars = bounded;
306
+ }
307
+ return { config, problems };
308
+ }
309
+ function assistantBodyText(message) {
310
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) return "";
311
+ let text = "";
312
+ let hasToolCalls = false;
313
+ for (const block of message.content) {
314
+ if (typeof block !== "object" || block === null) continue;
315
+ const record = block;
316
+ if (record.type === "text" && typeof record.text === "string") text += record.text;
317
+ else if (record.type === "toolCall") hasToolCalls = true;
318
+ }
319
+ if (hasToolCalls) return "";
320
+ return text.trim();
321
+ }
322
+
323
+ // builtin/extensions/pie-ipc/discovery.ts
324
+ import * as fs from "node:fs";
325
+ import * as path from "node:path";
326
+ import { randomBytes as randomBytes2 } from "node:crypto";
327
+ function ipcDiscoveryDir(configDir) {
328
+ return path.join(configDir, "run", "ipc");
329
+ }
330
+ function statusFilePath(discoveryDir, instanceId) {
331
+ if (!isValidInstanceId(instanceId)) throw new Error("Invalid instance id");
332
+ return path.join(discoveryDir, `${instanceId}.json`);
333
+ }
334
+ function ensurePrivateDir(discoveryDir) {
335
+ if (process.platform === "win32") throw new Error("pie-ipc requires POSIX private-file permissions; Windows is not supported yet");
336
+ fs.mkdirSync(discoveryDir, { recursive: true, mode: 448 });
337
+ const stat = fs.lstatSync(discoveryDir);
338
+ if (!stat.isDirectory() || stat.isSymbolicLink() || process.getuid && stat.uid !== process.getuid()) throw new Error("Unsafe IPC directory");
339
+ fs.chmodSync(discoveryDir, 448);
340
+ }
341
+ function writeStatusFile(discoveryDir, file, log) {
342
+ let temp;
343
+ try {
344
+ ensurePrivateDir(discoveryDir);
345
+ const target = statusFilePath(discoveryDir, file.instanceId);
346
+ const raw = JSON.stringify(file);
347
+ if (Buffer.byteLength(raw) > MAX_STATUS_FILE_BYTES || !parseStatusFile(raw)) throw new Error("Invalid status payload");
348
+ temp = path.join(discoveryDir, `.${randomBytes2(16).toString("hex")}.tmp`);
349
+ const handle = fs.openSync(temp, "wx", 384);
350
+ try {
351
+ fs.writeFileSync(handle, raw);
352
+ } finally {
353
+ fs.closeSync(handle);
354
+ }
355
+ fs.renameSync(temp, target);
356
+ return true;
357
+ } catch (error) {
358
+ try {
359
+ log?.(`pie-ipc: status write failed: ${error instanceof Error ? error.message : String(error)}`);
360
+ } catch {
361
+ }
362
+ return false;
363
+ } finally {
364
+ if (temp) {
365
+ try {
366
+ fs.unlinkSync(temp);
367
+ } catch {
368
+ }
369
+ }
370
+ }
371
+ }
372
+ function readStatusFile(discoveryDir, instanceId) {
373
+ if (!isValidInstanceId(instanceId)) return null;
374
+ const target = statusFilePath(discoveryDir, instanceId);
375
+ let fd;
376
+ try {
377
+ if (process.platform === "win32") return null;
378
+ const dir = fs.lstatSync(discoveryDir);
379
+ if (!dir.isDirectory() || dir.isSymbolicLink() || process.getuid && (dir.uid !== process.getuid() || (dir.mode & 63) !== 0)) return null;
380
+ fd = fs.openSync(target, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0) | (fs.constants.O_NONBLOCK ?? 0));
381
+ const stats = fs.fstatSync(fd);
382
+ if (!stats.isFile() || stats.size > MAX_STATUS_FILE_BYTES || process.getuid && (stats.uid !== process.getuid() || (stats.mode & 63) !== 0)) return null;
383
+ const bytes = Buffer.alloc(MAX_STATUS_FILE_BYTES + 1);
384
+ const count = fs.readSync(fd, bytes, 0, bytes.length, 0);
385
+ if (count > MAX_STATUS_FILE_BYTES) return null;
386
+ const file = parseStatusFile(bytes.subarray(0, count).toString("utf8"));
387
+ return file?.instanceId === instanceId ? file : null;
388
+ } catch {
389
+ return null;
390
+ } finally {
391
+ if (fd !== void 0) fs.closeSync(fd);
392
+ }
393
+ }
394
+ function removeStatusFile(discoveryDir, instanceId) {
395
+ try {
396
+ fs.rmSync(statusFilePath(discoveryDir, instanceId), { force: true });
397
+ } catch {
398
+ }
399
+ }
400
+ function listPeers(discoveryDir, options = {}) {
401
+ const now = options.now ?? Date.now();
402
+ const aliveCheck = options.aliveCheck ?? ((pid) => {
403
+ try {
404
+ process.kill(pid, 0);
405
+ return true;
406
+ } catch (error) {
407
+ return error.code === "EPERM";
408
+ }
409
+ });
410
+ const entries = [];
411
+ let directory;
412
+ try {
413
+ directory = fs.opendirSync(discoveryDir);
414
+ for (let n = 0; n < 1024; n++) {
415
+ const entry = directory.readSync();
416
+ if (!entry) break;
417
+ entries.push(entry);
418
+ }
419
+ } catch {
420
+ return [];
421
+ } finally {
422
+ try {
423
+ directory?.closeSync();
424
+ } catch {
425
+ }
426
+ }
427
+ const listings = [];
428
+ for (const entry of entries.slice(0, 1024)) {
429
+ if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
430
+ const instanceId = entry.name.slice(0, -".json".length);
431
+ if (instanceId === options.selfId) continue;
432
+ if (!isValidInstanceId(instanceId)) continue;
433
+ const file = readStatusFile(discoveryDir, instanceId);
434
+ if (!file || file.instanceId !== instanceId) continue;
435
+ const health = peerHealth(file, now, aliveCheck);
436
+ if (health === "dead" && options.pruneDead !== false) {
437
+ try {
438
+ fs.rmSync(path.join(discoveryDir, entry.name), { force: true });
439
+ } catch {
440
+ }
441
+ continue;
442
+ }
443
+ listings.push({ file, health });
444
+ }
445
+ listings.sort((a, b) => b.file.updatedAt - a.file.updatedAt);
446
+ return listings;
447
+ }
448
+ function startHeartbeat(discoveryDir, file, intervalMs, write = (next) => writeStatusFile(discoveryDir, next), log) {
449
+ let failures = 0;
450
+ const timer = setInterval(() => {
451
+ try {
452
+ if (!write({ ...file(), updatedAt: Date.now() })) {
453
+ failures += 1;
454
+ if (failures === 3) log?.("pie-ipc: heartbeat writes keep failing; discovery may be degraded");
455
+ } else {
456
+ failures = 0;
457
+ }
458
+ } catch (error) {
459
+ try {
460
+ log?.(`pie-ipc: heartbeat error: ${error instanceof Error ? error.message : String(error)}`);
461
+ } catch {
462
+ }
463
+ }
464
+ }, intervalMs);
465
+ timer.unref?.();
466
+ return () => clearInterval(timer);
467
+ }
468
+ function watchDiscoveryDir(discoveryDir, onChange) {
469
+ let watcher = null;
470
+ let closed = false;
471
+ let lastEventAt = 0;
472
+ const attach = () => {
473
+ if (closed) return;
474
+ try {
475
+ watcher = fs.watch(discoveryDir, (_event, filename) => {
476
+ const name = String(filename ?? "");
477
+ if (!name.endsWith(".json")) return;
478
+ const now = Date.now();
479
+ if (now - lastEventAt < 50) return;
480
+ lastEventAt = now;
481
+ try {
482
+ onChange(name.slice(0, -".json".length));
483
+ } catch {
484
+ }
485
+ });
486
+ } catch {
487
+ watcher = null;
488
+ }
489
+ watcher?.on("error", () => {
490
+ watcher?.close();
491
+ watcher = null;
492
+ });
493
+ watcher?.unref?.();
494
+ };
495
+ attach();
496
+ const retry = setInterval(() => {
497
+ if (closed || watcher) return;
498
+ attach();
499
+ }, 5e3);
500
+ retry.unref?.();
501
+ return () => {
502
+ closed = true;
503
+ clearInterval(retry);
504
+ watcher?.close();
505
+ watcher = null;
506
+ };
507
+ }
508
+ function buildStatusFile(input) {
509
+ return {
510
+ schema: 1,
511
+ instanceId: input.instanceId,
512
+ pid: process.pid,
513
+ protocol: IPC_PROTOCOL_VERSION,
514
+ displayName: input.displayName,
515
+ sessionId: input.sessionId,
516
+ sessionName: input.sessionName,
517
+ cwd: input.cwd,
518
+ startedAt: Date.now(),
519
+ updatedAt: Date.now(),
520
+ state: "idle",
521
+ undeliveredInjections: 0,
522
+ port: input.port
523
+ };
524
+ }
525
+
526
+ // builtin/extensions/pie-ipc/transport.ts
527
+ import * as net from "node:net";
528
+ import { createHmac, randomBytes as randomBytes3, randomUUID, timingSafeEqual } from "node:crypto";
529
+ import { StringDecoder } from "node:string_decoder";
530
+ function signEnvelope(e, secret) {
531
+ const payload = [e.v, e.id, e.from, e.to, e.kind, e.createdAt, e.sessionId ?? null, e.text ?? null, e.replyTo ?? null, e.waitMs ?? null];
532
+ return { ...e, auth: createHmac("sha256", secret).update(JSON.stringify(payload)).digest("hex") };
533
+ }
534
+ function verified(e, secret) {
535
+ if (!e.auth || !/^[a-f0-9]{64}$/.test(e.auth)) return false;
536
+ return timingSafeEqual(Buffer.from(e.auth, "hex"), Buffer.from(signEnvelope(e, secret).auth, "hex"));
537
+ }
538
+ var IpcTransport = class {
539
+ constructor(host) {
540
+ this.host = host;
541
+ this.instanceId = host.instanceId;
542
+ this.secret = host.secret ?? randomBytes3(32).toString("hex");
543
+ this.server = net.createServer((socket) => this.accept(socket));
544
+ this.server.on("error", () => {
545
+ });
546
+ this.server.unref();
547
+ }
548
+ host;
549
+ instanceId;
550
+ secret;
551
+ server;
552
+ sockets = /* @__PURE__ */ new Set();
553
+ cancels = /* @__PURE__ */ new Set();
554
+ seen = new BoundedIdSet();
555
+ closed = false;
556
+ inflight = 0;
557
+ inbound = 0;
558
+ rateWindow = Date.now();
559
+ rateCount = 0;
560
+ port = null;
561
+ get listenPort() {
562
+ return this.port;
563
+ }
564
+ listen() {
565
+ if (this.closed) return Promise.reject(new Error("closed"));
566
+ return new Promise((resolve, reject) => {
567
+ const cancel = () => {
568
+ this.cancels.delete(cancel);
569
+ reject(new Error("closed"));
570
+ };
571
+ this.cancels.add(cancel);
572
+ const error = (e) => {
573
+ this.cancels.delete(cancel);
574
+ reject(e);
575
+ };
576
+ this.server.once("error", error);
577
+ this.server.listen(0, "127.0.0.1", () => {
578
+ this.server.off("error", error);
579
+ this.cancels.delete(cancel);
580
+ if (this.closed) {
581
+ this.server.close();
582
+ reject(new Error("closed"));
583
+ return;
584
+ }
585
+ const address = this.server.address();
586
+ if (!address || typeof address === "string") {
587
+ reject(new Error("closed"));
588
+ return;
589
+ }
590
+ this.port = address.port;
591
+ resolve(address.port);
592
+ });
593
+ });
594
+ }
595
+ write(socket, envelope, secret) {
596
+ if (socket.destroyed) return false;
597
+ const data = JSON.stringify(signEnvelope(envelope, secret)) + "\n";
598
+ if (socket.writableLength + Buffer.byteLength(data) > 512 * 1024) {
599
+ socket.destroy();
600
+ return false;
601
+ }
602
+ try {
603
+ socket.write(data);
604
+ return true;
605
+ } catch {
606
+ socket.destroy();
607
+ return false;
608
+ }
609
+ }
610
+ read(socket, receive, idleMs) {
611
+ const decoder = new FrameDecoder();
612
+ const utf8 = new StringDecoder("utf8");
613
+ let frameTimer;
614
+ let frames = 0;
615
+ socket.setTimeout(idleMs, () => socket.destroy());
616
+ socket.on("error", () => socket.destroy());
617
+ socket.on("close", () => {
618
+ if (frameTimer) clearTimeout(frameTimer);
619
+ this.sockets.delete(socket);
620
+ });
621
+ socket.on("data", (chunk) => {
622
+ try {
623
+ frames += chunk.reduce((n, b) => n + (b === 10 ? 1 : 0), 0);
624
+ if (frames > 128) {
625
+ socket.destroy();
626
+ return;
627
+ }
628
+ const envelopes = decoder.push(utf8.write(chunk));
629
+ const partial = decoder.partial || chunk.at(-1) !== 10;
630
+ if (frameTimer && (!partial || chunk.includes(10))) {
631
+ clearTimeout(frameTimer);
632
+ frameTimer = void 0;
633
+ }
634
+ if (partial && !frameTimer) {
635
+ frameTimer = setTimeout(() => socket.destroy(), 15e3);
636
+ frameTimer.unref();
637
+ }
638
+ for (const e of envelopes) {
639
+ if (socket.destroyed) break;
640
+ receive(e);
641
+ }
642
+ } catch {
643
+ socket.destroy();
644
+ }
645
+ });
646
+ }
647
+ accept(socket) {
648
+ if (this.closed || this.inbound >= 16) {
649
+ socket.destroy();
650
+ return;
651
+ }
652
+ this.inbound++;
653
+ this.sockets.add(socket);
654
+ socket.setNoDelay(true);
655
+ socket.once("close", () => {
656
+ this.inbound--;
657
+ });
658
+ const lifecycle = new AbortController();
659
+ socket.once("close", () => lifecycle.abort());
660
+ let sender;
661
+ this.read(socket, (e) => {
662
+ if (e.to !== this.instanceId || !verified(e, this.secret) || sender && sender !== e.from) {
663
+ socket.destroy();
664
+ return;
665
+ }
666
+ sender = e.from;
667
+ if (Date.now() - this.rateWindow >= 1e3) {
668
+ this.rateWindow = Date.now();
669
+ this.rateCount = 0;
670
+ }
671
+ if (++this.rateCount > 128) {
672
+ socket.destroy();
673
+ return;
674
+ }
675
+ if (e.waitMs) socket.setTimeout(Math.max(6e4, e.waitMs + 1e3));
676
+ if (!this.seen.add(`${e.from}:${e.id}`)) return;
677
+ try {
678
+ this.host.onEnvelope(e, (reply) => {
679
+ if (reply.from === this.instanceId && reply.to === e.from && reply.replyTo === e.id) this.write(socket, reply, this.secret);
680
+ }, lifecycle.signal);
681
+ } catch {
682
+ socket.destroy();
683
+ }
684
+ }, 6e4);
685
+ }
686
+ async send(peerInstanceId, port, kind, options = {}) {
687
+ if (this.closed || options.signal?.aborted || this.inflight >= MAX_INFLIGHT_SENDS) return null;
688
+ let secret;
689
+ try {
690
+ secret = this.host.resolveSecret?.(peerInstanceId);
691
+ } catch {
692
+ return null;
693
+ }
694
+ if (!secret || !/^[a-f0-9]{64}$/.test(secret) || !Number.isInteger(port) || port < 1 || port > 65535) return null;
695
+ const waitMs = options.waitMs ?? DEFAULT_SEND_WAIT_MS;
696
+ if (!Number.isFinite(waitMs) || waitMs < 0 || waitMs > 6e5) return null;
697
+ this.inflight++;
698
+ return new Promise((resolve) => {
699
+ const socket = new net.Socket();
700
+ this.sockets.add(socket);
701
+ const envelope = { v: IPC_PROTOCOL_VERSION, id: randomUUID(), from: this.instanceId, to: peerInstanceId, kind, createdAt: Date.now(), ...options.text !== void 0 ? { text: clipBody(options.text) } : {}, ...options.sessionId !== void 0 ? { sessionId: options.sessionId } : {}, ...options.replyTo ? { replyTo: options.replyTo } : {}, waitMs: options.expectReply ? waitMs : 0 };
702
+ let done = false;
703
+ const finish = (value) => {
704
+ if (done) return;
705
+ done = true;
706
+ clearTimeout(timer);
707
+ clearTimeout(dialTimer);
708
+ options.signal?.removeEventListener("abort", abort);
709
+ this.cancels.delete(abort);
710
+ this.inflight--;
711
+ socket.destroy();
712
+ resolve(value);
713
+ };
714
+ const abort = () => finish(null);
715
+ const timer = setTimeout(abort, options.expectReply ? waitMs : 3e3);
716
+ const dialTimer = setTimeout(abort, 3e3);
717
+ this.cancels.add(abort);
718
+ options.signal?.addEventListener("abort", abort, { once: true });
719
+ socket.once("close", abort);
720
+ this.read(socket, (e) => {
721
+ if (e.to !== this.instanceId || e.from !== peerInstanceId || e.replyTo !== envelope.id || !verified(e, secret)) {
722
+ finish(null);
723
+ return;
724
+ }
725
+ if ((options.replyKinds ?? /* @__PURE__ */ new Set(["result", "ack", "state"])).has(e.kind)) finish(e);
726
+ }, Math.max(waitMs, 3e3));
727
+ socket.once("connect", () => {
728
+ clearTimeout(dialTimer);
729
+ if (done || options.signal?.aborted || this.closed) {
730
+ finish(null);
731
+ return;
732
+ }
733
+ if (!this.write(socket, envelope, secret)) {
734
+ finish(null);
735
+ return;
736
+ }
737
+ if (!options.expectReply) {
738
+ socket.end(() => finish(envelope));
739
+ }
740
+ });
741
+ try {
742
+ socket.connect(port, "127.0.0.1");
743
+ } catch {
744
+ finish(null);
745
+ }
746
+ });
747
+ }
748
+ close() {
749
+ if (this.closed) return;
750
+ this.closed = true;
751
+ for (const cancel of [...this.cancels]) cancel();
752
+ for (const socket of this.sockets) socket.destroy();
753
+ this.sockets.clear();
754
+ this.server.close();
755
+ this.port = null;
756
+ }
757
+ };
758
+
759
+ // builtin/extensions/pie-ipc/delivery.ts
760
+ var AllowPolicy = class {
761
+ /**
762
+ * Empty list denies messages/body. '*' explicitly trusts authenticated local clients.
763
+ */
764
+ constructor(ids) {
765
+ this.ids = ids;
766
+ }
767
+ ids;
768
+ get allowAll() {
769
+ return this.ids.includes("*");
770
+ }
771
+ allows(instanceId) {
772
+ return this.allowAll || this.ids.includes(instanceId);
773
+ }
774
+ allow(instanceId) {
775
+ if (!this.ids.includes(instanceId)) this.ids.push(instanceId);
776
+ }
777
+ snapshot() {
778
+ return [...this.ids];
779
+ }
780
+ };
781
+ function deliveredUserText(message) {
782
+ if (message?.role !== "user") return "";
783
+ if (typeof message.content === "string") return message.content;
784
+ if (!Array.isArray(message.content)) return "";
785
+ let text = "";
786
+ for (const block of message.content) {
787
+ if (typeof block === "object" && block !== null && block.type === "text") {
788
+ const value = block.text;
789
+ if (typeof value === "string") text += value;
790
+ }
791
+ }
792
+ return text;
793
+ }
794
+ var InboundDelivery = class {
795
+ constructor(host, options) {
796
+ this.host = host;
797
+ this.options = options;
798
+ this.autoInject = options.autoInject;
799
+ this.degradeAfterMisses = options.degradeAfterMisses ?? 3;
800
+ this.verifyDeadlineMs = options.verifyDeadlineMs ?? 3e4;
801
+ }
802
+ host;
803
+ options;
804
+ inbox = [];
805
+ pending = /* @__PURE__ */ new Map();
806
+ droppedCount = { value: 0 };
807
+ autoInject;
808
+ consecutiveMisses = 0;
809
+ degradeAfterMisses;
810
+ verifyDeadlineMs;
811
+ /** Degraded = autoInject force-disabled after repeated delivery misses. */
812
+ degraded = false;
813
+ /** Counters surfaced by /ipc status. */
814
+ counters = { injected: 0, queued: 0, dropped: 0, denied: 0, degradedAt: 0, recovered: 0 };
815
+ get effectiveAutoInject() {
816
+ return this.autoInject && !this.degraded;
817
+ }
818
+ setAutoInject(enabled) {
819
+ this.autoInject = enabled;
820
+ if (enabled) {
821
+ this.degraded = false;
822
+ this.consecutiveMisses = 0;
823
+ }
824
+ }
825
+ /** Route one inbound message envelope. Returns the routing decision. */
826
+ routeMessage(envelope, displayName) {
827
+ try {
828
+ return this.routeMessageInner(envelope, displayName);
829
+ } catch (error) {
830
+ this.host.log(`pie-ipc: delivery error: ${error instanceof Error ? error.message : String(error)}`);
831
+ return "denied";
832
+ }
833
+ }
834
+ routeMessageInner(envelope, displayName) {
835
+ if (envelope.kind !== "message" || typeof envelope.text !== "string" || !envelope.text.trim()) {
836
+ return "denied";
837
+ }
838
+ if (!this.options.allowPolicy.allows(envelope.from)) {
839
+ this.counters.denied += 1;
840
+ this.host.notify?.(`pie-ipc: message from unapproved peer ${peerLabel(envelope.from, displayName)} \u2014 run /ipc allow`, "warning");
841
+ return "denied";
842
+ }
843
+ const label = peerLabel(envelope.from, displayName);
844
+ const message = {
845
+ id: envelope.id,
846
+ from: envelope.from,
847
+ fromLabel: label,
848
+ sessionId: envelope.sessionId,
849
+ text: clipBody(envelope.text),
850
+ receivedAt: Date.now()
851
+ };
852
+ try {
853
+ this.host.appendEntry("pie.ipc.message", {
854
+ id: message.id,
855
+ from: message.from,
856
+ fromLabel: message.fromLabel,
857
+ text: message.text.slice(0, 400)
858
+ });
859
+ } catch (error) {
860
+ this.host.log(`pie-ipc: audit entry failed: ${error instanceof Error ? error.message : String(error)}`);
861
+ }
862
+ if (this.host.hasUI && this.effectiveAutoInject && this.pending.size < MAX_INBOX_MESSAGES) {
863
+ this.inject(message);
864
+ return "injected";
865
+ }
866
+ this.enqueueInbox(message);
867
+ return this.host.hasUI ? "queued" : "headless-queued";
868
+ }
869
+ inject(message) {
870
+ const wrapped = wrapInboundText(message.fromLabel, message.text, message.receivedAt, message.id);
871
+ const marker = wrappedMessageMarker(wrapped) ?? message.id;
872
+ const entry = {
873
+ marker,
874
+ message,
875
+ injectedAt: Date.now(),
876
+ timer: setTimeout(() => {
877
+ try {
878
+ this.noteMiss(message);
879
+ } catch {
880
+ }
881
+ }, this.verifyDeadlineMs)
882
+ };
883
+ entry.timer.unref?.();
884
+ this.pending.set(marker, entry);
885
+ this.counters.injected += 1;
886
+ try {
887
+ this.host.sendUserMessage(wrapped);
888
+ } catch {
889
+ clearTimeout(entry.timer);
890
+ if (this.pending.has(marker)) this.enqueueInbox(message);
891
+ }
892
+ this.updateStatus();
893
+ }
894
+ enqueueInbox(message) {
895
+ this.inbox.push(message);
896
+ this.counters.queued += 1;
897
+ if (this.inbox.length > MAX_INBOX_MESSAGES) {
898
+ this.inbox.shift();
899
+ this.droppedCount.value += 1;
900
+ this.counters.dropped += 1;
901
+ this.host.notify?.("pie-ipc: inbox full; oldest message dropped", "warning");
902
+ }
903
+ this.updateStatus();
904
+ }
905
+ /**
906
+ * Called on user message:end — verify pending injections against the
907
+ * settled user text. Timeout alone never moves a queued input to the inbox.
908
+ */
909
+ onUserMessageEnd(message) {
910
+ const text = deliveredUserText(message);
911
+ if (!isWrappedInboundText(text)) return;
912
+ const marker = wrappedMessageMarker(text);
913
+ if (!marker) return;
914
+ const entry = this.pending.get(marker);
915
+ if (!entry) return;
916
+ const recovered = this.inbox.findIndex((item) => item.id === entry.message.id);
917
+ if (recovered >= 0) this.inbox.splice(recovered, 1);
918
+ this.options.onConsumed?.(entry.message.id);
919
+ clearTimeout(entry.timer);
920
+ this.pending.delete(marker);
921
+ this.consecutiveMisses = 0;
922
+ this.host.log("pie-ipc: injection delivery verified");
923
+ this.updateStatus();
924
+ }
925
+ noteMiss(message) {
926
+ this.consecutiveMisses += 1;
927
+ this.host.log(`pie-ipc: injection ${message.id} not observed (miss ${this.consecutiveMisses})`);
928
+ if (this.consecutiveMisses >= this.degradeAfterMisses) {
929
+ this.degraded = true;
930
+ this.counters.degradedAt = Date.now();
931
+ this.host.notify?.(
932
+ `pie-ipc: injected messages stopped landing ${this.consecutiveMisses}x \u2014 auto-inject disabled, messages now wait in the inbox (/ipc poll)`,
933
+ "warning"
934
+ );
935
+ }
936
+ this.updateStatus();
937
+ }
938
+ /** Drain the inbox (ipc_poll). Messages leave the inbox permanently. */
939
+ drainInbox(limit = 20) {
940
+ if (this.inbox.length === 0) return [];
941
+ const drained = this.inbox.splice(0, Math.max(0, Math.min(100, Math.floor(limit))));
942
+ this.updateStatus();
943
+ return drained;
944
+ }
945
+ get inboxSize() {
946
+ return this.inbox.length;
947
+ }
948
+ get pendingInjections() {
949
+ return this.pending.size;
950
+ }
951
+ /** Undelivered injections advertised in the status file. */
952
+ get undeliveredInjections() {
953
+ return this.pending.size;
954
+ }
955
+ get overflowDropped() {
956
+ return this.droppedCount.value;
957
+ }
958
+ /** Clear inbox + verification state on session switch. */
959
+ resetSessionState() {
960
+ this.inbox.length = 0;
961
+ for (const entry of this.pending.values()) {
962
+ clearTimeout(entry.timer);
963
+ }
964
+ this.pending.clear();
965
+ this.degraded = false;
966
+ this.consecutiveMisses = 0;
967
+ this.updateStatus();
968
+ }
969
+ updateStatus() {
970
+ try {
971
+ if (!this.host.setStatus) return;
972
+ if (this.inbox.length > 0) {
973
+ const suffix = this.degraded ? " (degraded)" : "";
974
+ this.host.setStatus("pie-ipc", `ipc inbox ${this.inbox.length}${suffix}`);
975
+ } else if (this.degraded) {
976
+ this.host.setStatus("pie-ipc", "ipc auto-inject degraded; /ipc poll");
977
+ } else {
978
+ this.host.setStatus("pie-ipc", void 0);
979
+ }
980
+ } catch (error) {
981
+ this.host.log(`pie-ipc: status update failed: ${error instanceof Error ? error.message : String(error)}`);
982
+ }
983
+ }
984
+ };
985
+
986
+ // builtin/extensions/pie-ipc/status.ts
987
+ var StatusProjection = class {
988
+ constructor(host) {
989
+ this.host = host;
990
+ }
991
+ host;
992
+ shape = { state: "idle" };
993
+ lastBodyText = "";
994
+ lastBodyAt = 0;
995
+ undelivered = 0;
996
+ tools = /* @__PURE__ */ new Map();
997
+ bodyStart = 0;
998
+ get state() {
999
+ return this.shape.state;
1000
+ }
1001
+ get currentTool() {
1002
+ return this.shape.currentTool;
1003
+ }
1004
+ /** Fold one lifecycle event; returns true when the state changed. */
1005
+ updateFromEvent(event, now = Date.now()) {
1006
+ if (event.type === "turn:completed") return false;
1007
+ if (event.type === "tool:execution:start" && event.toolName) this.tools.set(event.toolName, (this.tools.get(event.toolName) ?? 0) + 1);
1008
+ if (event.type === "tool:execution:end" && event.toolName) {
1009
+ const count = (this.tools.get(event.toolName) ?? 1) - 1;
1010
+ if (count > 0) this.tools.set(event.toolName, count);
1011
+ else this.tools.delete(event.toolName);
1012
+ }
1013
+ if (["agent:end", "turn:failed", "turn:cancelled"].includes(event.type)) this.tools.clear();
1014
+ const before = this.shape.state + (this.shape.blockedSince ?? "") + (this.shape.currentTool ?? "");
1015
+ this.shape = foldLifecycleEvent(this.shape, event, now);
1016
+ if (event.type.startsWith("tool:")) {
1017
+ const blocking = this.tools.has("ask_user_multi");
1018
+ this.shape = { ...this.shape, state: blocking ? "blocked" : "working", currentTool: blocking ? "ask_user_multi" : [...this.tools.keys()].at(-1), blockedSince: blocking ? this.shape.blockedSince ?? now : void 0 };
1019
+ }
1020
+ const after = this.shape.state + (this.shape.blockedSince ?? "") + (this.shape.currentTool ?? "");
1021
+ return before !== after;
1022
+ }
1023
+ /** A turn settled: harvest the final body text from the tail. */
1024
+ onTurnSettled() {
1025
+ const messages = this.host.getMessages();
1026
+ for (let index = messages.length - 1; index >= this.bodyStart; index -= 1) {
1027
+ const text = assistantBodyText(messages[index]);
1028
+ if (text) {
1029
+ this.lastBodyText = clipBody(text, MAX_STATUS_BODY_CHARS);
1030
+ this.lastBodyAt = Date.now();
1031
+ return;
1032
+ }
1033
+ }
1034
+ }
1035
+ /** Called when an injection may not have been consumed yet. */
1036
+ noteInjectionDelivered() {
1037
+ this.undelivered += 1;
1038
+ }
1039
+ /** Called at turn:start — queued injections are being consumed now. */
1040
+ onTurnStarted() {
1041
+ this.undelivered = 0;
1042
+ this.bodyStart = this.host.getMessages().length;
1043
+ this.shape = { ...this.shape, currentTool: void 0 };
1044
+ }
1045
+ /** Session switch: lifecycle and answer state do not carry over. */
1046
+ resetSessionState() {
1047
+ this.shape = { state: "idle" };
1048
+ this.tools.clear();
1049
+ this.bodyStart = this.host.getMessages().length;
1050
+ this.lastBodyText = "";
1051
+ this.lastBodyAt = 0;
1052
+ this.undelivered = 0;
1053
+ }
1054
+ /** Body text used to answer waiting ipc_send peers. */
1055
+ lastBodyForReply() {
1056
+ return this.lastBodyText;
1057
+ }
1058
+ snapshot(base) {
1059
+ return {
1060
+ ...base,
1061
+ state: this.shape.state,
1062
+ currentTool: this.shape.currentTool,
1063
+ blockedSince: this.shape.blockedSince,
1064
+ turnStartedAt: this.shape.turnStartedAt,
1065
+ undeliveredInjections: this.undelivered,
1066
+ lastBodyText: this.lastBodyText || void 0,
1067
+ lastBodyAt: this.lastBodyAt || void 0
1068
+ };
1069
+ }
1070
+ };
1071
+
1072
+ // builtin/extensions/pie-ipc/index.ts
1073
+ var Runtime = class {
1074
+ constructor(ctx, config, discoveryDir) {
1075
+ this.ctx = ctx;
1076
+ this.config = config;
1077
+ this.discoveryDir = discoveryDir;
1078
+ this.displayName = config.displayName ?? peerLabel(this.instanceId);
1079
+ this.allowPolicy = new AllowPolicy(config.allowPeers ? [...config.allowPeers] : []);
1080
+ this.projection = new StatusProjection({ getMessages: () => ctx.getMessages() });
1081
+ }
1082
+ ctx;
1083
+ config;
1084
+ discoveryDir;
1085
+ instanceId = generateInstanceId();
1086
+ transport = null;
1087
+ delivery = null;
1088
+ projection;
1089
+ /** Shared allow policy: /ipc allow affects delivery AND state.get gates. */
1090
+ allowPolicy;
1091
+ /** Inbound messages we accepted for a turn; answered on turn settle. */
1092
+ awaitingReply = /* @__PURE__ */ new Map();
1093
+ executionFailure;
1094
+ active = false;
1095
+ polledInputs = /* @__PURE__ */ new Map();
1096
+ /** Server readiness gate: no status writes before the listener is up. */
1097
+ serverReady = false;
1098
+ disposed = false;
1099
+ displayName;
1100
+ sessionId = null;
1101
+ sessionName;
1102
+ statusBase;
1103
+ stopHeartbeat = null;
1104
+ stopWatch = null;
1105
+ /** Server role: listen, advertise, watch peers. Publishes only after the
1106
+ * listener is up (headless instances never publish). */
1107
+ async startServer() {
1108
+ ensurePrivateDir(this.discoveryDir);
1109
+ this.transport = new IpcTransport({
1110
+ instanceId: this.instanceId,
1111
+ resolveSecret: (id) => readStatusFile(this.discoveryDir, id)?.secret,
1112
+ onEnvelope: (envelope, respond, signal) => this.handleEnvelope(envelope, respond, signal),
1113
+ log: (message) => this.ctx.log(message)
1114
+ });
1115
+ const port = await this.transport.listen();
1116
+ if (this.disposed) {
1117
+ this.transport.close();
1118
+ return;
1119
+ }
1120
+ this.statusBase = buildStatusFile({
1121
+ instanceId: this.instanceId,
1122
+ displayName: this.displayName,
1123
+ sessionId: this.sessionId,
1124
+ sessionName: this.sessionName,
1125
+ cwd: this.ctx.cwd,
1126
+ port
1127
+ });
1128
+ this.statusBase.secret = this.transport.secret;
1129
+ this.serverReady = true;
1130
+ this.writeStatus();
1131
+ this.stopHeartbeat = startHeartbeat(this.discoveryDir, () => {
1132
+ listPeers(this.discoveryDir, { selfId: this.instanceId, pruneDead: true });
1133
+ return this.currentStatus();
1134
+ }, HEARTBEAT_INTERVAL_MS, void 0, (message) => this.ctx.log(message));
1135
+ this.stopWatch = watchDiscoveryDir(this.discoveryDir, () => {
1136
+ this.ctx.ui.setStatus?.("pie-ipc-peers", void 0);
1137
+ });
1138
+ }
1139
+ currentStatus() {
1140
+ const snapshot = this.projection.snapshot({
1141
+ ...this.statusBase,
1142
+ displayName: this.displayName,
1143
+ sessionId: this.sessionId,
1144
+ sessionName: this.sessionName
1145
+ });
1146
+ return { ...snapshot, allowPeers: [...this.allowPolicy.snapshot()], undeliveredInjections: this.delivery?.undeliveredInjections ?? snapshot.undeliveredInjections ?? 0 };
1147
+ }
1148
+ writeStatus() {
1149
+ if (!this.serverReady) return;
1150
+ writeStatusFile(this.discoveryDir, this.currentStatus(), (message) => this.ctx.log(message));
1151
+ }
1152
+ handleEnvelope(envelope, respond, signal) {
1153
+ const reply = (kind, text) => {
1154
+ respond({
1155
+ v: IPC_PROTOCOL_VERSION,
1156
+ id: `${envelope.id}:r`,
1157
+ from: this.instanceId,
1158
+ to: envelope.from,
1159
+ kind,
1160
+ createdAt: Date.now(),
1161
+ replyTo: envelope.id,
1162
+ ...text !== void 0 ? { text } : {}
1163
+ });
1164
+ };
1165
+ switch (envelope.kind) {
1166
+ case "ping":
1167
+ reply("ack", "pong");
1168
+ return;
1169
+ case "state.get": {
1170
+ if (envelope.from !== this.instanceId && !this.allowPolicy.allows(envelope.from)) {
1171
+ reply("state", JSON.stringify({ state: this.projection.state, restricted: true }));
1172
+ return;
1173
+ }
1174
+ const status = this.currentStatus();
1175
+ reply("state", JSON.stringify({
1176
+ state: status.state,
1177
+ currentTool: status.currentTool,
1178
+ blockedSince: status.blockedSince,
1179
+ turnStartedAt: status.turnStartedAt,
1180
+ undeliveredInjections: status.undeliveredInjections,
1181
+ lastBodyText: status.lastBodyText,
1182
+ lastBodyAt: status.lastBodyAt,
1183
+ sessionId: status.sessionId,
1184
+ displayName: status.displayName
1185
+ }));
1186
+ return;
1187
+ }
1188
+ case "message": {
1189
+ if (envelope.sessionId !== (this.sessionId ?? "")) {
1190
+ reply("result", "rejected: target session changed");
1191
+ return;
1192
+ }
1193
+ if (envelope.waitMs && this.awaitingReply.size >= 100) {
1194
+ reply("result", "rejected: reply capacity reached");
1195
+ return;
1196
+ }
1197
+ const deliveryId = `${envelope.from}:${envelope.id}`;
1198
+ if (envelope.waitMs && envelope.waitMs > 0) {
1199
+ const timer = setTimeout(() => this.finishReply(deliveryId, "expired: input not answered within waitMs"), envelope.waitMs);
1200
+ timer.unref();
1201
+ const disconnected = () => this.finishReply(deliveryId, "cancelled: requester disconnected");
1202
+ this.awaitingReply.set(deliveryId, { reply: (text) => reply("result", text), timer, consumed: false, body: "", detach: () => signal?.removeEventListener("abort", disconnected) });
1203
+ if (signal?.aborted) {
1204
+ this.finishReply(deliveryId, "cancelled: requester disconnected");
1205
+ return;
1206
+ }
1207
+ signal?.addEventListener("abort", disconnected, { once: true });
1208
+ }
1209
+ const decision = this.delivery?.routeMessage({ ...envelope, id: deliveryId }, this.displayNameOf(envelope.from));
1210
+ if (decision === "denied") {
1211
+ this.finishReply(deliveryId, "denied: not on this peer's allow list (/ipc allow)");
1212
+ if (!envelope.waitMs) reply("result", "denied: not allowed");
1213
+ return;
1214
+ }
1215
+ reply("ack", decision);
1216
+ return;
1217
+ }
1218
+ default:
1219
+ return;
1220
+ }
1221
+ }
1222
+ displayNameOf(peerInstanceId) {
1223
+ return readStatusFile(this.discoveryDir, peerInstanceId)?.displayName;
1224
+ }
1225
+ // ---- lifecycle events ----
1226
+ onEvent(event) {
1227
+ switch (event.type) {
1228
+ case "turn:start": {
1229
+ if (!this.active) this.executionFailure = void 0;
1230
+ this.active = true;
1231
+ this.projection.onTurnStarted();
1232
+ if (this.projection.updateFromEvent(event)) this.writeStatus();
1233
+ return;
1234
+ }
1235
+ case "tool:execution:start":
1236
+ case "tool:execution:end": {
1237
+ if (this.projection.updateFromEvent(event)) this.writeStatus();
1238
+ return;
1239
+ }
1240
+ case "turn:completed":
1241
+ return;
1242
+ // inner model/tool round, not real idle
1243
+ case "agent:end":
1244
+ case "turn:failed":
1245
+ case "turn:cancelled": {
1246
+ this.projection.onTurnSettled();
1247
+ if (this.projection.updateFromEvent(event)) this.writeStatus();
1248
+ this.answerAwaitingPeers(event.type !== "agent:end" || Boolean(this.executionFailure));
1249
+ this.active = false;
1250
+ return;
1251
+ }
1252
+ case "session:changed": {
1253
+ this.sessionId = event.sessionId ?? null;
1254
+ this.sessionName = this.ctx.getSessionName?.();
1255
+ this.delivery?.resetSessionState();
1256
+ this.polledInputs.clear();
1257
+ this.projection.resetSessionState();
1258
+ this.executionFailure = void 0;
1259
+ for (const id of [...this.awaitingReply.keys()]) this.finishReply(id, "cancelled: target session changed");
1260
+ this.active = false;
1261
+ this.writeStatus();
1262
+ return;
1263
+ }
1264
+ case "message:end": {
1265
+ const message = event.message;
1266
+ if (message?.role === "user") {
1267
+ this.delivery?.onUserMessageEnd(message);
1268
+ const text = Array.isArray(message.content) ? message.content.map((block) => block?.type === "text" ? block.text : "").join("") : message.content;
1269
+ if (typeof text === "string") {
1270
+ const ids = this.polledInputs.get(text);
1271
+ if (ids) {
1272
+ this.polledInputs.delete(text);
1273
+ for (const id of ids) this.markConsumed(id);
1274
+ }
1275
+ }
1276
+ } else {
1277
+ if (message?.role === "assistant") {
1278
+ const outcome = message;
1279
+ if (outcome.stopReason === "error" || outcome.stopReason === "aborted") this.executionFailure = outcome.stopReason;
1280
+ }
1281
+ const body = message && !this.executionFailure ? assistantBodyText(message) : "";
1282
+ if (body) {
1283
+ for (const waiter of this.awaitingReply.values()) if (waiter.consumed) waiter.body = body;
1284
+ }
1285
+ }
1286
+ return;
1287
+ }
1288
+ default:
1289
+ return;
1290
+ }
1291
+ }
1292
+ /**
1293
+ * When a turn settles, every peer message that opened (or queued into)
1294
+ * this turn gets the assistant body as its awaited result. Failed/cancelled
1295
+ * turns answer with an explanatory text instead.
1296
+ */
1297
+ markConsumed(id) {
1298
+ const waiter = this.awaitingReply.get(id);
1299
+ if (waiter && this.active) waiter.consumed = true;
1300
+ }
1301
+ finishReply(id, text) {
1302
+ const waiter = this.awaitingReply.get(id);
1303
+ if (!waiter) return;
1304
+ this.awaitingReply.delete(id);
1305
+ clearTimeout(waiter.timer);
1306
+ waiter.detach();
1307
+ try {
1308
+ waiter.reply(clipBody(text));
1309
+ } catch {
1310
+ }
1311
+ }
1312
+ answerAwaitingPeers(failed) {
1313
+ for (const [id, waiter] of [...this.awaitingReply]) {
1314
+ if (waiter.consumed) this.finishReply(id, failed ? "(peer turn failed or was cancelled)" : waiter.body || "(empty reply)");
1315
+ }
1316
+ }
1317
+ // ---- mutations from commands ----
1318
+ mutations() {
1319
+ return {
1320
+ mute: (enabled) => {
1321
+ this.delivery?.setAutoInject(!enabled);
1322
+ this.ctx.log(`pie-ipc: auto-inject ${enabled ? "muted" : "enabled"}`);
1323
+ },
1324
+ allow: (instanceId) => {
1325
+ this.allowPolicy.allow(instanceId);
1326
+ this.writeStatus();
1327
+ this.ctx.log(`pie-ipc: allowed peer ${instanceId}`);
1328
+ },
1329
+ setDisplayName: (name) => {
1330
+ this.displayName = name.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 60) || peerLabel(this.instanceId);
1331
+ this.writeStatus();
1332
+ this.ctx.log(`pie-ipc: display name set to ${this.displayName}`);
1333
+ }
1334
+ };
1335
+ }
1336
+ // ---- model tool implementations ----
1337
+ async listPeers() {
1338
+ const listings = listPeers(this.discoveryDir, { selfId: this.instanceId, pruneDead: false });
1339
+ if (listings.length === 0) return "No pie-ipc peers discovered.";
1340
+ const lines = listings.map(({ file, health }) => {
1341
+ const label = peerLabel(file.instanceId, file.displayName);
1342
+ const bits = [`${label} [${health}] ${file.state}`];
1343
+ if (this.allowPolicy.allows(file.instanceId) && file.currentTool) bits.push(`tool=${file.currentTool}`);
1344
+ if (this.allowPolicy.allows(file.instanceId) && file.blockedSince !== void 0) bits.push(`blocked ${Math.max(0, Math.round((Date.now() - file.blockedSince) / 1e3))}s`);
1345
+ if (this.allowPolicy.allows(file.instanceId) && file.sessionId) bits.push(`session=${file.sessionId.slice(0, 8)}`);
1346
+ if (file.port) bits.push(`port=${file.port}`);
1347
+ bits.push(`id=${file.instanceId}`);
1348
+ return bits.join(" ");
1349
+ });
1350
+ return lines.join("\n");
1351
+ }
1352
+ async statusOf(peerRef) {
1353
+ const target = this.resolvePeer(peerRef);
1354
+ if (!target) return `Peer not found: ${peerRef}`;
1355
+ const staticView = `${peerLabel(target.instanceId, target.displayName)} [${listPeers(this.discoveryDir, { selfId: this.instanceId, pruneDead: false }).find((entry) => entry.file.instanceId === target.instanceId)?.health ?? "unknown"}] ${target.state}`;
1356
+ if (!this.allowPolicy.allows(target.instanceId) || !(target.allowPeers?.includes("*") || target.allowPeers?.includes(this.instanceId))) return `${staticView}
1357
+ (content restricted by local allow policy)`;
1358
+ if (target.lastBodyText) return `${staticView}
1359
+ last body (snapshot; may be stale): ${clipBody(target.lastBodyText, 400)}`;
1360
+ if (!target.port || !this.transport) return `${staticView}
1361
+ (no live listener; file view only)`;
1362
+ const reply = await this.transport.send(target.instanceId, target.port, "state.get", { expectReply: true, waitMs: 3e3 });
1363
+ if (!reply || reply.kind !== "state") return `${staticView}
1364
+ (live query failed or timed out)`;
1365
+ try {
1366
+ const parsed = JSON.parse(reply.text ?? "{}");
1367
+ const lines = [staticView];
1368
+ if (parsed.currentTool) lines.push(`current tool: ${String(parsed.currentTool)}`);
1369
+ if (typeof parsed.blockedSince === "number") lines.push(`blocked for ${Math.max(0, Math.round((Date.now() - parsed.blockedSince) / 1e3))}s`);
1370
+ if (typeof parsed.undeliveredInjections === "number" && parsed.undeliveredInjections > 0) lines.push(`undelivered injections: ${parsed.undeliveredInjections}`);
1371
+ if (parsed.restricted) lines.push("(peer restricts details for unapproved askers)");
1372
+ if (typeof parsed.lastBodyText === "string" && parsed.lastBodyText) lines.push(`last body: ${clipBody(parsed.lastBodyText, 400)}`);
1373
+ return lines.join("\n");
1374
+ } catch {
1375
+ return `${staticView}
1376
+ (live query returned unparsable payload)`;
1377
+ }
1378
+ }
1379
+ /** Resolve a peer by display name, name prefix, or instance id. */
1380
+ resolvePeer(peerRef) {
1381
+ const listings = listPeers(this.discoveryDir, { selfId: this.instanceId, pruneDead: false });
1382
+ const ref = peerRef.trim().toLowerCase();
1383
+ if (!ref) return null;
1384
+ const exact = listings.find(({ file }) => file.instanceId.toLowerCase() === ref);
1385
+ if (exact) return exact.file;
1386
+ const names = listings.filter(({ file }) => file.displayName.toLowerCase() === ref);
1387
+ if (names.length) return names.length === 1 ? names[0].file : null;
1388
+ const matches = listings.filter(({ file }) => file.instanceId.toLowerCase().startsWith(ref) || file.displayName.toLowerCase().startsWith(ref));
1389
+ return matches.length === 1 ? matches[0].file : null;
1390
+ }
1391
+ async sendToPeer(peerRef, text, wait, waitMs, context) {
1392
+ const target = this.resolvePeer(peerRef);
1393
+ if (!target) {
1394
+ return { content: [{ type: "text", text: `Peer not found: ${peerRef}. Call ipc_list_peers first.` }], isError: true };
1395
+ }
1396
+ if (!target.port) {
1397
+ return { content: [{ type: "text", text: `${peerLabel(target.instanceId, target.displayName)} is not listening (client-only or headless host).` }], isError: true };
1398
+ }
1399
+ if (!this.transport) {
1400
+ return { content: [{ type: "text", text: "IPC transport is unavailable (server startup failed or instance disposed)." }], isError: true };
1401
+ }
1402
+ const body = clipBody(text, this.config.maxBodyChars);
1403
+ const reply = await this.transport.send(target.instanceId, target.port, "message", {
1404
+ text: body,
1405
+ sessionId: target.sessionId ?? "",
1406
+ expectReply: wait,
1407
+ waitMs,
1408
+ signal: context.signal,
1409
+ // Waiting sends resolve only on the peer's turn-settling result (or
1410
+ // its terminal denied answer); the receipt ack stays informational.
1411
+ replyKinds: /* @__PURE__ */ new Set(["result"])
1412
+ });
1413
+ if (!wait) {
1414
+ if (!reply) return { content: [{ type: "text", text: `Send failed: ${peerLabel(target.instanceId, target.displayName)} unreachable.` }], isError: true };
1415
+ return { content: [{ type: "text", text: `Sent to ${peerLabel(target.instanceId, target.displayName)} (no wait; receipt/consumption not confirmed).` }] };
1416
+ }
1417
+ if (!reply) {
1418
+ if (context.signal?.aborted) return { content: [{ type: "text", text: "Wait cancelled; already accepted peer input is not cancelled." }] };
1419
+ const snapshot = await this.statusOf(peerRef);
1420
+ return {
1421
+ content: [{ type: "text", text: `No reply from ${peerLabel(target.instanceId, target.displayName)} within ${waitMs}ms (or aborted).
1422
+ Peer status:
1423
+ ${snapshot}` }],
1424
+ isError: false
1425
+ };
1426
+ }
1427
+ return { content: [{ type: "text", text: reply.text ?? "(empty reply)" }] };
1428
+ }
1429
+ drainInbox(limit, modelTool = true) {
1430
+ const messages = this.delivery?.drainInbox(limit) ?? [];
1431
+ if (messages.length === 0) return "Inbox empty.";
1432
+ const text = messages.map((message) => `[pie-ipc id=${message.id}] ${message.fromLabel} (${new Date(message.receivedAt).toISOString()}):
1433
+ ${message.text}`).join("\n---\n");
1434
+ if (modelTool && this.active) for (const message of messages) this.markConsumed(message.id);
1435
+ else {
1436
+ if (this.polledInputs.size >= 100) this.polledInputs.delete(this.polledInputs.keys().next().value);
1437
+ this.polledInputs.set(text, messages.map((message) => message.id));
1438
+ }
1439
+ return text;
1440
+ }
1441
+ /** /ipc send: deliver text, then surface the result without waiting. */
1442
+ async drainReplyAfterSend(peerRef, text) {
1443
+ const result = await this.sendToPeer(peerRef, text, false, 0, { toolCallId: "ipc-command", cwd: this.ctx.cwd, log: () => {
1444
+ }, reportProgress: () => {
1445
+ } });
1446
+ return result.content[0]?.text ?? "(no result)";
1447
+ }
1448
+ dispose() {
1449
+ this.disposed = true;
1450
+ this.stopHeartbeat?.();
1451
+ this.stopWatch?.();
1452
+ for (const id of [...this.awaitingReply.keys()]) this.finishReply(id, "cancelled: peer disposed");
1453
+ this.delivery?.resetSessionState();
1454
+ this.polledInputs.clear();
1455
+ try {
1456
+ this.ctx.ui.setStatus?.("pie-ipc-peers", void 0);
1457
+ } catch {
1458
+ }
1459
+ this.transport?.close();
1460
+ removeStatusFile(this.discoveryDir, this.instanceId);
1461
+ }
1462
+ };
1463
+ function loadConfig(ctx, configDir) {
1464
+ const layers = [
1465
+ { file: path2.join(configDir, "ipc.json"), content: safeRead(path2.join(configDir, "ipc.json")) },
1466
+ { file: path2.join(ctx.cwd, ".pie", "ipc.json"), content: safeRead(path2.join(ctx.cwd, ".pie", "ipc.json")) }
1467
+ ];
1468
+ return resolveIpcConfig(layers);
1469
+ }
1470
+ function safeRead(file) {
1471
+ try {
1472
+ return fs2.readFileSync(file, "utf8");
1473
+ } catch {
1474
+ return void 0;
1475
+ }
1476
+ }
1477
+ function pieIpcExtension(ctx) {
1478
+ const configDir = process.env.PIE_IPC_CONFIG_DIR ?? getConfigDirSafe(ctx);
1479
+ const discoveryDir = ipcDiscoveryDir(configDir);
1480
+ const { config, problems } = loadConfig(ctx, configDir);
1481
+ for (const problem of problems) ctx.log(`pie-ipc: ${problem}`);
1482
+ if (!config) {
1483
+ return;
1484
+ }
1485
+ if (process.platform === "win32") {
1486
+ ctx.log("pie-ipc: disabled: Windows ACL isolation is not implemented");
1487
+ return;
1488
+ }
1489
+ const runtime = new Runtime(ctx, config, discoveryDir);
1490
+ ctx.log(`pie-ipc: enabled as ${runtime.instanceId} (${ctx.hasUI ? "server+client" : "client-only"})`);
1491
+ if (ctx.hasUI) {
1492
+ runtime.delivery = new InboundDelivery(
1493
+ {
1494
+ hasUI: ctx.hasUI,
1495
+ sendUserMessage: (text) => ctx.sendUserMessage(text),
1496
+ appendEntry: (type, data) => ctx.appendEntry?.(type, data) ?? "",
1497
+ setStatus: (key, text) => ctx.ui.setStatus?.(key, text),
1498
+ notify: (message, type) => ctx.ui.notify(message, type),
1499
+ getMessages: () => ctx.getMessages(),
1500
+ log: (message) => ctx.log(message)
1501
+ },
1502
+ { autoInject: config.autoInject ?? false, allowPolicy: runtime.allowPolicy, onConsumed: (id) => runtime.markConsumed(id) }
1503
+ );
1504
+ void runtime.startServer().catch((error) => {
1505
+ ctx.log(`pie-ipc: server start failed: ${error instanceof Error ? error.message : String(error)}`);
1506
+ });
1507
+ } else {
1508
+ runtime.transport = new IpcTransport({
1509
+ instanceId: runtime.instanceId,
1510
+ resolveSecret: (id) => readStatusFile(discoveryDir, id)?.secret,
1511
+ onEnvelope: () => {
1512
+ },
1513
+ log: (message) => ctx.log(message)
1514
+ });
1515
+ }
1516
+ const events = [
1517
+ "turn:start",
1518
+ "agent:end",
1519
+ "turn:completed",
1520
+ "turn:failed",
1521
+ "turn:cancelled",
1522
+ "tool:execution:start",
1523
+ "tool:execution:end",
1524
+ "session:changed",
1525
+ "message:end"
1526
+ ];
1527
+ for (const event of events) {
1528
+ ctx.on(event, ((payload) => {
1529
+ runtime.onEvent(payload);
1530
+ }));
1531
+ }
1532
+ const risk = {
1533
+ riskClass: "external_side_effect",
1534
+ permissionScope: "host",
1535
+ requiresPermission: true,
1536
+ externalSideEffect: true,
1537
+ readsNetwork: true,
1538
+ writesNetwork: true,
1539
+ allowedForSubagentByDefault: false,
1540
+ concurrencySafe: false
1541
+ };
1542
+ ctx.registerTool({
1543
+ name: "ipc_list_peers",
1544
+ label: "List Pie IPC peers",
1545
+ description: "List other Pie processes on this machine that advertise pie-ipc. Each line: name [health] state (working/idle/blocked), current tool, blocked duration, session, port, instance id. Local read only; use ipc_status for a live query.",
1546
+ parameters: Type.Object({}),
1547
+ risk: { ...risk, riskClass: "read_only", externalSideEffect: false, requiresPermission: false, readsNetwork: false, writesNetwork: false, concurrencySafe: true },
1548
+ renderHint: {
1549
+ summary: () => "list peers"
1550
+ },
1551
+ execute: async () => ({ content: [{ type: "text", text: await runtime.listPeers() }] })
1552
+ });
1553
+ ctx.registerTool({
1554
+ name: "ipc_send",
1555
+ label: "Send a message to a Pie peer",
1556
+ description: `Send text to another Pie process (interactive peer) addressed by display name or instance id prefix. wait=false (default): fire-and-forget \u2014 the peer receives it as user input. wait=true: hold until the peer's reply arrives (bounded by waitMs, default ${DEFAULT_SEND_WAIT_MS}ms); on timeout the peer's last known status is attached. Inbound messages from peers arrive as user input or in ipc_poll.`,
1557
+ parameters: Type.Object({
1558
+ to: Type.String({ description: "Peer display name or instance id (see ipc_list_peers)" }),
1559
+ text: Type.String({ description: "Message body for the peer" }),
1560
+ wait: Type.Optional(Type.Boolean({ description: "Wait for the peer's reply text (default false)" })),
1561
+ waitMs: Type.Optional(Type.Number({ description: "Reply wait budget in ms (default 120000, max 600000)", minimum: 100, maximum: 6e5 }))
1562
+ }),
1563
+ risk,
1564
+ renderHint: {
1565
+ summary: (args) => {
1566
+ const record = args ?? {};
1567
+ return `\u2192 ${record.to ?? "?"}: ${(record.text ?? "").slice(0, 60)}`;
1568
+ }
1569
+ },
1570
+ execute: async (args, context) => runtime.sendToPeer(args.to, args.text, args.wait ?? false, Math.min(args.waitMs ?? DEFAULT_SEND_WAIT_MS, 6e5), context)
1571
+ });
1572
+ ctx.registerTool({
1573
+ name: "ipc_status",
1574
+ label: "Query a peer's live status",
1575
+ description: "Live-query a Pie peer's status over its socket: lifecycle state, current tool, blocked duration, undelivered injections, truncated last body text. Falls back to the status-file view when the peer is unreachable. unapproved askers get a restricted summary.",
1576
+ parameters: Type.Object({
1577
+ peer: Type.String({ description: "Peer display name or instance id prefix" })
1578
+ }),
1579
+ risk: { ...risk, riskClass: "network", requiresPermission: false, externalSideEffect: false, writesNetwork: false },
1580
+ renderHint: {
1581
+ summary: (args) => {
1582
+ const record = args ?? {};
1583
+ return `status ${record.peer ?? "?"}`;
1584
+ }
1585
+ },
1586
+ execute: async (args) => ({ content: [{ type: "text", text: await runtime.statusOf(args.peer) }] })
1587
+ });
1588
+ ctx.registerTool({
1589
+ name: "ipc_poll",
1590
+ label: "Poll inbound IPC inbox",
1591
+ description: "Drain undelivered inbound peer messages from the local inbox (mute mode, or while auto-inject is degraded). Messages leave the inbox permanently. Returns 'Inbox empty.' when there is nothing pending.",
1592
+ parameters: Type.Object({
1593
+ limit: Type.Optional(Type.Number({ description: "Max messages to drain (default 20)", minimum: 1, maximum: 100 }))
1594
+ }),
1595
+ risk: { ...risk, riskClass: "session_mutation", externalSideEffect: false, requiresPermission: false, readsNetwork: false, writesNetwork: false, concurrencySafe: false },
1596
+ renderHint: {
1597
+ summary: (args) => {
1598
+ const record = args ?? {};
1599
+ return `poll inbox${record.limit ? ` (limit ${record.limit})` : ""}`;
1600
+ }
1601
+ },
1602
+ execute: async (args) => ({ content: [{ type: "text", text: runtime.drainInbox(args.limit ?? 20) }] })
1603
+ });
1604
+ ctx.registerCommand({
1605
+ path: ["ipc"],
1606
+ description: "Pie IPC peers: /ipc peers|send|poll|status|name|mute|allow",
1607
+ handler: async (_extensionCtx, args) => {
1608
+ const [sub, ...rest] = (args ?? "").trim().split(/\s+/);
1609
+ const mutations = runtime.mutations();
1610
+ const run = async () => {
1611
+ switch (sub) {
1612
+ case void 0:
1613
+ case "":
1614
+ case "peers":
1615
+ return await runtime.listPeers();
1616
+ case "send": {
1617
+ const [peerRef, ...words] = rest;
1618
+ if (!peerRef || words.length === 0) return "Usage: /ipc send <peer> <text>";
1619
+ return runtime.drainReplyAfterSend(peerRef, words.join(" "));
1620
+ }
1621
+ case "poll":
1622
+ return runtime.drainInbox(20, false);
1623
+ case "status": {
1624
+ const peerRef = rest[0];
1625
+ if (!peerRef) return "Usage: /ipc status <peer>";
1626
+ return await runtime.statusOf(peerRef);
1627
+ }
1628
+ case "name":
1629
+ if (!rest[0]) return `Current name: ${runtime.displayName}
1630
+ Usage: /ipc name <new-name>`;
1631
+ mutations.setDisplayName(rest[0]);
1632
+ return `Display name set to: ${rest[0]}`;
1633
+ case "mute":
1634
+ mutations.mute(rest[0] !== "off");
1635
+ return rest[0] === "off" ? "Auto-inject re-enabled (inbox still available via ipc_poll)." : "Muted: inbound messages wait in the inbox (ipc_poll).";
1636
+ case "allow": {
1637
+ if (!rest[0]) return "Usage: /ipc allow <instance-id>";
1638
+ const target = runtime.resolvePeer(rest[0]);
1639
+ const id = target?.instanceId ?? rest[0];
1640
+ if (id !== "*" && !/^pie-[a-z0-9]{12}$/.test(id)) return "Invalid instance id.";
1641
+ mutations.allow(id);
1642
+ return `Allowed peer: ${id}`;
1643
+ }
1644
+ default:
1645
+ return "Usage: /ipc peers|send <peer> <text>|poll|status <peer>|name <name>|mute [off]|allow <id>";
1646
+ }
1647
+ };
1648
+ const output = await run();
1649
+ if (sub === "poll") return output;
1650
+ ctx.ui.notify(output, "info");
1651
+ }
1652
+ });
1653
+ ctx.registerCleanup?.(() => {
1654
+ runtime.dispose();
1655
+ });
1656
+ }
1657
+ function getConfigDirSafe(_ctx) {
1658
+ const envDir = process.env.PIE_CODING_AGENT_DIR;
1659
+ if (envDir) {
1660
+ if (envDir === "~") return homeDir();
1661
+ if (envDir.startsWith("~/")) return path2.join(homeDir(), envDir.slice(2));
1662
+ return envDir;
1663
+ }
1664
+ return path2.join(homeDir(), ".pie");
1665
+ }
1666
+ function homeDir() {
1667
+ return process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
1668
+ }
1669
+ export {
1670
+ pieIpcExtension as default
1671
+ };