pi-orchestrator-config 4.2.0 → 4.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -67
- package/agents/code-reviewer-docs.md +11 -1
- package/agents/code-reviewer-guidelines.md +11 -1
- package/agents/code-reviewer-quality.md +11 -1
- package/agents/code-reviewer-security.md +11 -1
- package/agents/code-reviewer-spec.md +11 -1
- package/agents/git-expert.md +42 -3
- package/agents/github-expert.md +11 -0
- package/extensions/acpx-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/agents/claude.ts +3 -2
- package/extensions/cli-provider/agents/cursor.ts +3 -5
- package/extensions/cli-provider/agents/gemini.ts +3 -2
- package/extensions/cli-provider/discover.ts +7 -7
- package/extensions/cli-provider/index.ts +5 -0
- package/extensions/cli-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/session-reaper.ts +5 -7
- package/extensions/cli-provider/sessions.ts +4 -0
- package/extensions/cli-provider/shared/discover-cache.ts +17 -176
- package/extensions/coms/coms-p2p.ts +1719 -423
- package/extensions/coms/coms-shared.ts +261 -39
- package/extensions/coms/coms-wrapper.ts +100 -62
- package/extensions/coms/index.ts +8 -4
- package/extensions/coms/mixed-turn.ts +26 -0
- package/extensions/coms/probe-socket.ts +42 -0
- package/extensions/coms/themeMap.ts +0 -1
- package/extensions/image-gen/image-gen.ts +19 -1
- package/extensions/orchestrator/async-agents.ts +563 -102
- package/extensions/orchestrator/async-capability.ts +8 -8
- package/extensions/orchestrator/cron.ts +15 -9
- package/extensions/orchestrator/dreaming.ts +32 -25
- package/extensions/orchestrator/enforcement-helpers.ts +459 -17
- package/extensions/orchestrator/enforcement-rules.ts +5 -4
- package/extensions/orchestrator/enforcement.ts +80 -27
- package/extensions/orchestrator/extended-autocomplete.ts +7 -0
- package/extensions/orchestrator/git-helpers.ts +2 -2
- package/extensions/orchestrator/index.ts +78 -10
- package/extensions/orchestrator/markdown-transformer.ts +313 -0
- package/extensions/orchestrator/memory-embeddings.ts +36 -34
- package/extensions/orchestrator/memory-provenance.ts +29 -25
- package/extensions/orchestrator/memory-scoring.ts +23 -18
- package/extensions/orchestrator/memory-tools.ts +10 -3
- package/extensions/orchestrator/overlay-dashboard.ts +17 -5
- package/extensions/orchestrator/parse-model-override.ts +29 -0
- package/extensions/orchestrator/pi-config-review-state.ts +57 -33
- package/extensions/orchestrator/project-settings.ts +333 -127
- package/extensions/orchestrator/resolve-agent-model.ts +24 -13
- package/extensions/orchestrator/review-porcelain.ts +4 -0
- package/extensions/orchestrator/review-ui.ts +63 -15
- package/extensions/orchestrator/rule-placeholders.ts +411 -2
- package/extensions/orchestrator/rules.ts +80 -132
- package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
- package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
- package/extensions/orchestrator/settings-tui.ts +636 -0
- package/extensions/orchestrator/state-jsonl.ts +431 -0
- package/extensions/orchestrator/subagent-tool.ts +157 -59
- package/extensions/orchestrator/sync-limit.ts +23 -0
- package/extensions/orchestrator/task-lifecycle.ts +77 -67
- package/extensions/orchestrator/utils.ts +8 -5
- package/extensions/pidash/pidash.ts +193 -156
- package/extensions/pidiff/pidiff.ts +96 -38
- package/extensions/pidiff/spawn-lock.ts +62 -0
- package/extensions/pitasks/index.ts +430 -0
- package/extensions/pitasks/reminders.ts +37 -0
- package/extensions/pitasks/task-store.ts +467 -0
- package/extensions/pitasks/task-tools.ts +375 -0
- package/extensions/pitasks/task-widget.ts +181 -0
- package/extensions/pitasks/types.ts +38 -0
- package/extensions/providers/acpx-driver.ts +12 -2
- package/extensions/providers/built-in-drivers.ts +6 -6
- package/extensions/providers/claude-driver.ts +1 -1
- package/extensions/providers/cursor-cli-driver.ts +1 -1
- package/extensions/providers/gemini-driver.ts +1 -1
- package/extensions/providers/index.ts +144 -26
- package/extensions/providers/initialized-guard.ts +27 -0
- package/extensions/providers/restore-default-model.ts +596 -0
- package/extensions/providers/session-shutdown.ts +39 -0
- package/extensions/shared/coms-active.ts +23 -0
- package/extensions/shared/create-runtime-provider.ts +2 -0
- package/extensions/shared/daemon-manager.ts +0 -8
- package/extensions/shared/file-logger.ts +132 -25
- package/extensions/shared/logger.ts +50 -0
- package/extensions/shared/models-dev.ts +421 -0
- package/extensions/shared/oneshot.ts +116 -0
- package/extensions/shared/resolve-binary.ts +191 -0
- package/extensions/shared/types.ts +3 -0
- package/package.json +9 -3
- package/prompts/coderabbit-rate-limit.md +2 -0
- package/rules/00-orchestrator-core.md +3 -0
- package/rules/10-agent-routing.md +12 -1
- package/rules/20-code-review-loop.md +52 -25
- package/rules/30-prompt-templates.md +4 -0
- package/rules/35-memory.md +3 -3
- package/rules/40-critical-rules.md +3 -0
- package/rules/55-coms-protocol.md +84 -18
- package/rules/60-task-tracking.md +2 -0
- package/settings-keys.json +563 -0
- package/templates/coms-feature-manager-prompt.md +4 -6
- package/extensions/coms/coms-net-server.ts +0 -1642
- package/extensions/coms/coms-net-wrapper.ts +0 -521
- package/extensions/coms/coms-net.ts +0 -1589
|
@@ -19,27 +19,34 @@ import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-c
|
|
|
19
19
|
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
20
20
|
import { Type } from "typebox";
|
|
21
21
|
import { applyExtensionDefaults } from "./themeMap.js";
|
|
22
|
-
import { ulid, hexFg, isValidHex, fallbackColor, comsParseYamlFrontmatter as parseFrontmatter, nowIso, abbreviateModel, findSystemPromptPath, readFrontmatterFromArgv, readTaskSummary, buildInboundContent, renderTasksPart, renderQueuePart, formatQueueStr, formatComsResponseText, FALLBACK_PALETTE, type TasksSummary } from "./coms-shared.js";
|
|
22
|
+
import { ulid, hexFg, isValidHex, fallbackColor, comsParseYamlFrontmatter as parseFrontmatter, nowIso, abbreviateModel, findSystemPromptPath, readFrontmatterFromArgv, readTaskSummary, buildInboundContent, renderTasksPart, renderQueuePart, formatQueueStr, formatComsResponseText, formatComsResponseType, formatComsResponseBody, formatComsInboundType, sanitizeComsName, createComsInboundTasks, FALLBACK_PALETTE, type TasksSummary } from "./coms-shared.js";
|
|
23
|
+
import { openListDetailOverlay, OverlayScrollDetail } from "../orchestrator/overlay-dashboard.js";
|
|
23
24
|
import * as net from "node:net";
|
|
24
25
|
import * as fs from "node:fs";
|
|
25
26
|
import * as path from "node:path";
|
|
26
27
|
import * as os from "node:os";
|
|
27
28
|
import * as crypto from "node:crypto";
|
|
28
29
|
import { Worker } from "node:worker_threads";
|
|
30
|
+
import { getSetting } from "../orchestrator/project-settings.js";
|
|
31
|
+
import { createLogger } from "../shared/logger.js";
|
|
32
|
+
import { setLogFilePrefix } from "../shared/file-logger.js";
|
|
33
|
+
import { probeStaleSocket } from "./probe-socket.js";
|
|
34
|
+
import { isUserMessageDuringInbound, computeMixedTurn } from "./mixed-turn.js";
|
|
29
35
|
|
|
30
36
|
// ━━ Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
31
37
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
const log = createLogger("coms");
|
|
39
|
+
let COMS_DIR = path.join(os.homedir(), ".pi", "coms");
|
|
40
|
+
let MAX_HOPS = 5;
|
|
41
|
+
|
|
42
|
+
let TIMEOUT_MS = 1_800_000;
|
|
36
43
|
const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
37
44
|
const LINE_CAP_BYTES = 64 * 1024;
|
|
38
45
|
|
|
39
46
|
|
|
40
47
|
// ━━ Types ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
41
48
|
|
|
42
|
-
type EnvelopeType = "prompt" | "response" | "ping";
|
|
49
|
+
type EnvelopeType = "prompt" | "response" | "ping" | "task_update" | "queue_manage" | "task_manage" | "presence";
|
|
43
50
|
|
|
44
51
|
interface Envelope {
|
|
45
52
|
type: EnvelopeType;
|
|
@@ -65,12 +72,46 @@ interface ResponseEnvelope extends Envelope {
|
|
|
65
72
|
response: any;
|
|
66
73
|
error?: string | null;
|
|
67
74
|
queued_msg_ids?: string[];
|
|
75
|
+
your_pending?: Array<{ msg_id: string; preview: string }>;
|
|
68
76
|
}
|
|
69
77
|
|
|
70
78
|
interface PingEnvelope extends Envelope {
|
|
71
79
|
type: "ping";
|
|
72
80
|
}
|
|
73
81
|
|
|
82
|
+
interface TaskUpdateEnvelope extends Envelope {
|
|
83
|
+
type: "task_update";
|
|
84
|
+
task_id: string;
|
|
85
|
+
subject: string;
|
|
86
|
+
status: string;
|
|
87
|
+
sender_name: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface QueueManageEnvelope extends Envelope {
|
|
91
|
+
type: "queue_manage";
|
|
92
|
+
action: "delete" | "edit" | "clear" | "prioritize";
|
|
93
|
+
target_msg_id?: string;
|
|
94
|
+
new_content?: string;
|
|
95
|
+
sender_name: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface TaskManageEnvelope extends Envelope {
|
|
99
|
+
type: "task_manage";
|
|
100
|
+
action: "create" | "update" | "delete" | "list" | "get";
|
|
101
|
+
sender_name: string;
|
|
102
|
+
subject?: string;
|
|
103
|
+
description?: string;
|
|
104
|
+
metadata?: Record<string, any>;
|
|
105
|
+
task_id?: string;
|
|
106
|
+
fields?: any;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
interface PresenceEnvelope extends Envelope {
|
|
110
|
+
type: "presence";
|
|
111
|
+
status: "leaving";
|
|
112
|
+
sender_name: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
74
115
|
interface AgentCard {
|
|
75
116
|
name: string;
|
|
76
117
|
purpose: string;
|
|
@@ -88,7 +129,7 @@ interface Pong {
|
|
|
88
129
|
}
|
|
89
130
|
|
|
90
131
|
interface RegistryEntry {
|
|
91
|
-
|
|
132
|
+
coms_session_id: string;
|
|
92
133
|
name: string;
|
|
93
134
|
purpose: string;
|
|
94
135
|
model: string;
|
|
@@ -105,6 +146,7 @@ interface RegistryEntry {
|
|
|
105
146
|
context_used_pct?: number;
|
|
106
147
|
queue_depth?: number;
|
|
107
148
|
heartbeat_at?: string;
|
|
149
|
+
pi_session_id?: string;
|
|
108
150
|
}
|
|
109
151
|
|
|
110
152
|
interface PendingReply {
|
|
@@ -128,6 +170,8 @@ interface InboundContext {
|
|
|
128
170
|
tasks?: Array<{ subject: string; description: string }> | null;
|
|
129
171
|
response_schema?: object | null;
|
|
130
172
|
fulfilled: boolean;
|
|
173
|
+
/** How many times this inbound was re-injected due to mixed-turn conflict */
|
|
174
|
+
mixedTurnRetries?: number;
|
|
131
175
|
}
|
|
132
176
|
|
|
133
177
|
function makeEndpoint(sessionId: string): string {
|
|
@@ -242,7 +286,7 @@ function registryFilePath(project: string, sessionId: string): string {
|
|
|
242
286
|
function writeRegistryAtomic(entry: RegistryEntry, project: string): string {
|
|
243
287
|
const dir = projectAgentsDir(project);
|
|
244
288
|
fs.mkdirSync(dir, { recursive: true });
|
|
245
|
-
const final = registryFilePath(project, entry.
|
|
289
|
+
const final = registryFilePath(project, entry.coms_session_id);
|
|
246
290
|
const tmp = `${final}.tmp`;
|
|
247
291
|
fs.writeFileSync(tmp, JSON.stringify(entry, null, 2));
|
|
248
292
|
fs.renameSync(tmp, final);
|
|
@@ -264,7 +308,7 @@ function readAllRegistryEntries(project: string): RegistryEntry[] {
|
|
|
264
308
|
try {
|
|
265
309
|
const raw = fs.readFileSync(path.join(dir, f), "utf-8");
|
|
266
310
|
const parsed = JSON.parse(raw) as RegistryEntry;
|
|
267
|
-
if (parsed && typeof parsed.
|
|
311
|
+
if (parsed && typeof parsed.coms_session_id === "string") {
|
|
268
312
|
out.push(parsed);
|
|
269
313
|
}
|
|
270
314
|
} catch {
|
|
@@ -302,20 +346,28 @@ function removeRegistryEntry(project: string, sessionId: string): void {
|
|
|
302
346
|
}
|
|
303
347
|
}
|
|
304
348
|
|
|
305
|
-
async function pruneDeadEntries(project: string): Promise<RegistryEntry[]> {
|
|
349
|
+
async function pruneDeadEntries(project: string, cwd = process.cwd()): Promise<RegistryEntry[]> {
|
|
306
350
|
const entries = readAllRegistryEntries(project);
|
|
307
351
|
const socketsDir = path.join(COMS_DIR, "sockets");
|
|
308
352
|
|
|
309
353
|
// Phase 1: Sync pre-filter (fast — no I/O)
|
|
310
354
|
const candidates: RegistryEntry[] = [];
|
|
355
|
+
const youngEntries: RegistryEntry[] = [];
|
|
311
356
|
for (const entry of entries) {
|
|
312
357
|
if (!entry.endpoint || typeof entry.endpoint !== "string") {
|
|
313
|
-
removeRegistryEntry(project, entry.
|
|
358
|
+
removeRegistryEntry(project, entry.coms_session_id);
|
|
314
359
|
continue;
|
|
315
360
|
}
|
|
316
361
|
// On Windows, endpoints are named pipes — fs.existsSync doesn't apply
|
|
317
362
|
if (process.platform !== "win32" && !fs.existsSync(entry.endpoint)) {
|
|
318
|
-
removeRegistryEntry(project, entry.
|
|
363
|
+
removeRegistryEntry(project, entry.coms_session_id);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
// Skip entries younger than 30s — recently booted peers get grace period
|
|
367
|
+
const entryAge = Date.now() - new Date(entry.heartbeat_at ?? entry.started_at ?? "").getTime();
|
|
368
|
+
if (!isNaN(entryAge) && entryAge < getSetting(cwd, "coms_entry_grace_period_ms")) {
|
|
369
|
+
log.debug("prune_skip_grace", entry.name, "age_ms", entryAge);
|
|
370
|
+
youngEntries.push(entry);
|
|
319
371
|
continue;
|
|
320
372
|
}
|
|
321
373
|
// Check heartbeat/started_at structural validity (malformed = remove, missing = remove)
|
|
@@ -326,39 +378,42 @@ async function pruneDeadEntries(project: string): Promise<RegistryEntry[]> {
|
|
|
326
378
|
if (lastSeen) {
|
|
327
379
|
const lastSeenMs = new Date(lastSeen).getTime();
|
|
328
380
|
if (isNaN(lastSeenMs)) {
|
|
329
|
-
removeRegistryEntry(project, entry.
|
|
381
|
+
removeRegistryEntry(project, entry.coms_session_id);
|
|
330
382
|
continue;
|
|
331
383
|
}
|
|
332
384
|
} else {
|
|
333
|
-
removeRegistryEntry(project, entry.
|
|
385
|
+
removeRegistryEntry(project, entry.coms_session_id);
|
|
334
386
|
continue;
|
|
335
387
|
}
|
|
336
388
|
candidates.push(entry);
|
|
337
389
|
}
|
|
338
390
|
|
|
339
391
|
// Phase 2: Parallel socket probes (capped at 10 concurrent to avoid EMFILE)
|
|
340
|
-
if (candidates.length === 0)
|
|
392
|
+
if (candidates.length === 0) {
|
|
393
|
+
log.debug("prune_complete", "checked", entries.length);
|
|
394
|
+
return youngEntries;
|
|
395
|
+
}
|
|
341
396
|
const PROBE_CONCURRENCY = 10;
|
|
342
397
|
const results: ("in_use" | "stale")[] = [];
|
|
343
398
|
for (let start = 0; start < candidates.length; start += PROBE_CONCURRENCY) {
|
|
344
399
|
const batch = candidates.slice(start, start + PROBE_CONCURRENCY);
|
|
345
|
-
const batchResults = await Promise.allSettled(batch.map(entry => probeStaleSocket(entry.endpoint)));
|
|
400
|
+
const batchResults = await Promise.allSettled(batch.map(entry => probeStaleSocket(entry.endpoint, entry.name, cwd)));
|
|
346
401
|
results.push(...batchResults.map(r => r.status === "fulfilled" ? r.value : "in_use" as const));
|
|
347
402
|
}
|
|
348
403
|
const live: RegistryEntry[] = [];
|
|
349
404
|
for (let i = 0; i < candidates.length; i++) {
|
|
350
405
|
if (results[i] === "stale") {
|
|
351
406
|
const entry = candidates[i];
|
|
352
|
-
removeRegistryEntry(project, entry.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
}
|
|
407
|
+
removeRegistryEntry(project, entry.coms_session_id);
|
|
408
|
+
// Do NOT delete socket files — they belong to the peer process.
|
|
409
|
+
// Only the owning peer should manage its own socket.
|
|
410
|
+
// If the peer is alive, it will self-heal its registry.
|
|
357
411
|
} else {
|
|
358
412
|
live.push(candidates[i]);
|
|
359
413
|
}
|
|
360
414
|
}
|
|
361
|
-
|
|
415
|
+
log.debug("prune_complete", "checked", entries.length);
|
|
416
|
+
return [...live, ...youngEntries];
|
|
362
417
|
}
|
|
363
418
|
|
|
364
419
|
async function pruneDeadEntriesAllProjects(): Promise<RegistryEntry[]> {
|
|
@@ -376,40 +431,13 @@ async function pruneDeadEntriesAllProjects(): Promise<RegistryEntry[]> {
|
|
|
376
431
|
} catch {
|
|
377
432
|
continue;
|
|
378
433
|
}
|
|
379
|
-
out.push(...await pruneDeadEntries(p));
|
|
434
|
+
out.push(...await pruneDeadEntries(p, process.cwd()));
|
|
380
435
|
}
|
|
381
436
|
return out;
|
|
382
437
|
}
|
|
383
438
|
|
|
384
439
|
// ━━ Transport ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
385
440
|
|
|
386
|
-
function probeStaleSocket(endpoint: string): Promise<"in_use" | "stale"> {
|
|
387
|
-
return new Promise((resolve) => {
|
|
388
|
-
const sock = net.createConnection({ path: endpoint });
|
|
389
|
-
let settled = false;
|
|
390
|
-
const finish = (verdict: "in_use" | "stale") => {
|
|
391
|
-
if (settled) return;
|
|
392
|
-
settled = true;
|
|
393
|
-
try { sock.destroy(); } catch { /* ignore */ }
|
|
394
|
-
resolve(verdict);
|
|
395
|
-
};
|
|
396
|
-
const timer = setTimeout(() => finish("stale"), 250);
|
|
397
|
-
sock.once("connect", () => {
|
|
398
|
-
clearTimeout(timer);
|
|
399
|
-
finish("in_use");
|
|
400
|
-
});
|
|
401
|
-
sock.once("error", (err: any) => {
|
|
402
|
-
clearTimeout(timer);
|
|
403
|
-
if (err && (err.code === "ECONNREFUSED" || err.code === "ENOENT")) {
|
|
404
|
-
finish("stale");
|
|
405
|
-
} else {
|
|
406
|
-
// Transient errors (EMFILE, EACCES, etc.) — treat as live to avoid false pruning
|
|
407
|
-
finish("in_use");
|
|
408
|
-
}
|
|
409
|
-
});
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
|
|
413
441
|
/**
|
|
414
442
|
* Strict liveness probe — only returns "alive" on actual successful TCP connect.
|
|
415
443
|
* Unlike probeStaleSocket which treats transient errors as "in_use" (safe for
|
|
@@ -532,6 +560,7 @@ function sendEnvelope(endpoint: string, envelope: Envelope | Pong | { type: stri
|
|
|
532
560
|
// ━━ Default export ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
533
561
|
|
|
534
562
|
export default function (pi: ExtensionAPI) {
|
|
563
|
+
if (process.env.PI_SUBAGENT_CHILD === "1") return;
|
|
535
564
|
// ━━ Register identity CLI flags so pi's parser accepts them. ━━━━━━━━━
|
|
536
565
|
// Without these, pi 0.73+ rejects the invocation with "Unknown options:
|
|
537
566
|
// --name, --project, ..." before this extension's hooks ever fire.
|
|
@@ -564,7 +593,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
564
593
|
|
|
565
594
|
// State containers — shared across all hooks for this extension instance.
|
|
566
595
|
let identity: {
|
|
567
|
-
|
|
596
|
+
coms_session_id: string;
|
|
568
597
|
name: string;
|
|
569
598
|
purpose: string;
|
|
570
599
|
color: string;
|
|
@@ -574,21 +603,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
574
603
|
model: string;
|
|
575
604
|
endpoint: string;
|
|
576
605
|
registryFile: string;
|
|
606
|
+
pi_session_id: string;
|
|
577
607
|
} | null = null;
|
|
578
608
|
const peerCards: Map<string, AgentCard & { staleCount: number }> = new Map();
|
|
609
|
+
let welcomeShown = false;
|
|
610
|
+
const knownPeerSessions: Map<string, string> = new Map(); // session_id → name
|
|
611
|
+
const accumulatedResponses: Map<string, string[]> = new Map();
|
|
579
612
|
const pendingReplies: Map<string, PendingReply> = new Map();
|
|
613
|
+
/** Track pending (unresponded) outbound msg_ids per target name. */
|
|
614
|
+
const pendingOutbound: Map<string, Set<string>> = new Map();
|
|
580
615
|
const inboundQueue: Map<string, InboundContext> = new Map();
|
|
581
616
|
let server: net.Server | null = null;
|
|
582
|
-
let
|
|
617
|
+
let fsWatcher: fs.FSWatcher | null = null;
|
|
583
618
|
let pingWorker: Worker | null = null;
|
|
584
619
|
let pingWorkerReady = false;
|
|
585
|
-
let cardUpdateTimer: NodeJS.Timeout | null = null;
|
|
586
620
|
let keepaliveTimer: NodeJS.Timeout | null = null;
|
|
587
621
|
let includeExplicit = false;
|
|
588
622
|
let displayProject: string | null = null;
|
|
589
623
|
let currentCtx: ExtensionContext | null = null;
|
|
590
624
|
let currentInbound: InboundContext | null = null;
|
|
625
|
+
let bundledInbounds: InboundContext[] = [];
|
|
591
626
|
let processingInbound = false;
|
|
627
|
+
/** True when the agent is running a turn NOT triggered by a coms inbound.
|
|
628
|
+
* Set on agent_start, cleared on agent_end. Only true for user-initiated turns. */
|
|
629
|
+
let agentRunningUserTurn = false;
|
|
630
|
+
/** True when currentInbound was set while the agent was running a user turn (#731). */
|
|
631
|
+
let inboundSetDuringUserTurn = false;
|
|
632
|
+
/** True when a real user message landed during an inbound turn (#741). */
|
|
633
|
+
let userMessageDuringInbound = false;
|
|
634
|
+
const comsSendCalledThisTurn: Set<string> = new Set();
|
|
592
635
|
|
|
593
636
|
function getPendingInboundCount(): number {
|
|
594
637
|
let count = 0;
|
|
@@ -596,6 +639,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
596
639
|
return count;
|
|
597
640
|
}
|
|
598
641
|
|
|
642
|
+
/** Get pending messages from a specific sender still in our queue. */
|
|
643
|
+
function getSenderPending(senderSession: string, excludeMsgId: string): Array<{ msg_id: string; preview: string }> {
|
|
644
|
+
const result: Array<{ msg_id: string; preview: string }> = [];
|
|
645
|
+
for (const ib of inboundQueue.values()) {
|
|
646
|
+
if (ib.sender_session === senderSession && !ib.fulfilled && ib.msg_id !== excludeMsgId) {
|
|
647
|
+
result.push({ msg_id: ib.msg_id, preview: ib.prompt.slice(0, 80) });
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
// Re-verify: filter out any entries removed between scan and return
|
|
651
|
+
const filtered = result.filter(r => { const ib = inboundQueue.get(r.msg_id); return ib && !ib.fulfilled; });
|
|
652
|
+
log.debug("getSenderPending", "sender", senderSession, "found", filtered.length);
|
|
653
|
+
return filtered;
|
|
654
|
+
}
|
|
655
|
+
|
|
599
656
|
let lastPoolSnapshot = "";
|
|
600
657
|
function maybeRefreshWidget(): void {
|
|
601
658
|
if (!currentCtx?.hasUI) return;
|
|
@@ -652,26 +709,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
652
709
|
if (processingInbound) {
|
|
653
710
|
ackOk(socket, env.msg_id);
|
|
654
711
|
|
|
655
|
-
|
|
656
|
-
pi.appendEntry("coms-log", {
|
|
657
|
-
event: "inbound_queued",
|
|
658
|
-
msg_id: env.msg_id,
|
|
659
|
-
sender: env.sender_session,
|
|
660
|
-
hops: env.hops,
|
|
661
|
-
queue_depth: getPendingInboundCount(),
|
|
662
|
-
});
|
|
663
|
-
} catch { /* best-effort */ }
|
|
712
|
+
log.debug("inbound_queued", env.msg_id, "from", env.sender_name, "depth", getPendingInboundCount());
|
|
664
713
|
return;
|
|
665
714
|
}
|
|
666
715
|
|
|
667
716
|
// 4. Not busy — inject immediately.
|
|
717
|
+
// If the agent is already running (user turn), this inbound arrives
|
|
718
|
+
// mid-turn via followUp — mark as mixed turn for agent_end (#731).
|
|
719
|
+
inboundSetDuringUserTurn = agentRunningUserTurn;
|
|
668
720
|
currentInbound = inbound;
|
|
669
721
|
processingInbound = true;
|
|
670
722
|
try {
|
|
671
723
|
pi.sendMessage(
|
|
672
724
|
{
|
|
673
|
-
customType: "
|
|
674
|
-
content: buildInboundContent(
|
|
725
|
+
customType: formatComsInboundType(env.sender_name, sanitizeComsName(identity?.name ?? "?"), env.sender_cwd),
|
|
726
|
+
content: buildInboundContent("", env.prompt, env.tasks, env.sender_name, env.sender_cwd),
|
|
675
727
|
display: true,
|
|
676
728
|
details: {
|
|
677
729
|
msg_id: env.msg_id,
|
|
@@ -682,6 +734,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
682
734
|
{ deliverAs: "followUp", triggerTurn: true },
|
|
683
735
|
);
|
|
684
736
|
} catch (err) {
|
|
737
|
+
const errIb = inboundQueue.get(env.msg_id);
|
|
738
|
+
if (errIb) errIb.fulfilled = true;
|
|
685
739
|
inboundQueue.delete(env.msg_id);
|
|
686
740
|
maybeRefreshWidget();
|
|
687
741
|
currentInbound = null;
|
|
@@ -693,20 +747,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
693
747
|
// 5. Ack + audit log
|
|
694
748
|
ackOk(socket, env.msg_id);
|
|
695
749
|
|
|
696
|
-
|
|
697
|
-
pi.appendEntry("coms-log", {
|
|
698
|
-
event: "inbound_prompt",
|
|
699
|
-
msg_id: env.msg_id,
|
|
700
|
-
sender: env.sender_session,
|
|
701
|
-
hops: env.hops,
|
|
702
|
-
});
|
|
703
|
-
} catch {
|
|
704
|
-
// best-effort
|
|
705
|
-
}
|
|
750
|
+
log.debug("inbound_prompt", env.msg_id, "from", env.sender_name, "hops", env.hops);
|
|
706
751
|
}
|
|
707
752
|
|
|
708
753
|
function handleResponse(socket: net.Socket, env: ResponseEnvelope): void {
|
|
709
754
|
const pending = pendingReplies.get(env.msg_id);
|
|
755
|
+
// Clean up pending outbound tracking
|
|
756
|
+
if (pending?.target_name) {
|
|
757
|
+
const set = pendingOutbound.get(pending.target_name);
|
|
758
|
+
if (set) { set.delete(env.msg_id); if (set.size === 0) pendingOutbound.delete(pending.target_name); }
|
|
759
|
+
}
|
|
710
760
|
if (pending) {
|
|
711
761
|
if (pending.timer) {
|
|
712
762
|
try { clearTimeout(pending.timer); } catch { /* ignore */ }
|
|
@@ -717,17 +767,31 @@ export default function (pi: ExtensionAPI) {
|
|
|
717
767
|
try {
|
|
718
768
|
pending.resolve(pending.result);
|
|
719
769
|
} catch (e: any) {
|
|
720
|
-
|
|
770
|
+
log.error("resolve_failed", env.msg_id, e?.message);
|
|
771
|
+
}
|
|
772
|
+
log.debug("handleResponse", env.msg_id, "from", env.sender_session.slice(-8), "error", env.error ?? "none");
|
|
773
|
+
|
|
774
|
+
// Skip display for bundled/cleared responses — not user-visible
|
|
775
|
+
if (env.error === "bundled" || env.error === "queue_cleared") {
|
|
776
|
+
setTimeout(() => { pendingReplies.delete(env.msg_id); }, 60_000).unref();
|
|
777
|
+
return;
|
|
721
778
|
}
|
|
722
779
|
|
|
723
780
|
// Auto-deliver response as followUp so the LLM sees it without polling
|
|
724
781
|
const targetName = pending.target_name ?? "peer";
|
|
725
|
-
const
|
|
782
|
+
const selfName = sanitizeComsName(identity?.name ?? "?");
|
|
783
|
+
const responseType = formatComsResponseType(targetName, selfName, queuedMsgIds);
|
|
784
|
+
let responseBody = formatComsResponseBody(env.response, env.error ?? null);
|
|
785
|
+
const yourPending = Array.isArray(env.your_pending) ? env.your_pending : [];
|
|
786
|
+
if (yourPending.length > 0) {
|
|
787
|
+
responseBody += `\n\nPeer still has ${yourPending.length} of your messages queued:\n` +
|
|
788
|
+
yourPending.filter((p: any) => p && typeof p === "object").map((p: any, i: number) => ` ${i + 1}. [${p.msg_id ?? "?"}] "${p.preview ?? ""}…"`).join("\n");
|
|
789
|
+
}
|
|
726
790
|
try {
|
|
727
791
|
pi.sendMessage(
|
|
728
792
|
{
|
|
729
|
-
customType:
|
|
730
|
-
content:
|
|
793
|
+
customType: responseType,
|
|
794
|
+
content: responseBody,
|
|
731
795
|
display: true,
|
|
732
796
|
details: {
|
|
733
797
|
msg_id: env.msg_id,
|
|
@@ -743,11 +807,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
743
807
|
// Clean up immediately since response was delivered
|
|
744
808
|
setTimeout(() => { pendingReplies.delete(env.msg_id); }, 60_000).unref();
|
|
745
809
|
} else {
|
|
746
|
-
|
|
747
|
-
pi.appendEntry("coms-log", { event: "orphan_response", msg_id: env.msg_id });
|
|
748
|
-
} catch {
|
|
749
|
-
// best-effort
|
|
750
|
-
}
|
|
810
|
+
log.warn("orphan_response", env.msg_id);
|
|
751
811
|
}
|
|
752
812
|
ackOk(socket, env.msg_id);
|
|
753
813
|
}
|
|
@@ -830,6 +890,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
830
890
|
nack(socket, mid, "malformed envelope");
|
|
831
891
|
return;
|
|
832
892
|
}
|
|
893
|
+
log.debug("envelope_received", parsed.type, parsed.msg_id?.slice(-8) ?? "?");
|
|
833
894
|
try {
|
|
834
895
|
if (parsed.type === "prompt") {
|
|
835
896
|
handlePrompt(socket, parsed as PromptEnvelope);
|
|
@@ -837,6 +898,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
837
898
|
handleResponse(socket, parsed as ResponseEnvelope);
|
|
838
899
|
} else if (parsed.type === "ping") {
|
|
839
900
|
handlePing(socket, parsed as PingEnvelope);
|
|
901
|
+
} else if (parsed.type === "task_update") {
|
|
902
|
+
handleTaskUpdate(socket, parsed as TaskUpdateEnvelope);
|
|
903
|
+
} else if (parsed.type === "queue_manage") {
|
|
904
|
+
handleQueueManage(socket, parsed as QueueManageEnvelope);
|
|
905
|
+
} else if (parsed.type === "task_manage") {
|
|
906
|
+
handleTaskManage(socket, parsed as TaskManageEnvelope);
|
|
907
|
+
} else if (parsed.type === "presence") {
|
|
908
|
+
handlePresence(socket, parsed as PresenceEnvelope);
|
|
840
909
|
} else {
|
|
841
910
|
nack(socket, parsed.msg_id, "unknown type");
|
|
842
911
|
}
|
|
@@ -851,27 +920,232 @@ export default function (pi: ExtensionAPI) {
|
|
|
851
920
|
});
|
|
852
921
|
}
|
|
853
922
|
|
|
923
|
+
// ━━ presence handler ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
924
|
+
function handlePresence(socket: net.Socket, env: PresenceEnvelope): void {
|
|
925
|
+
ackOk(socket, env.msg_id);
|
|
926
|
+
if (env.status === "leaving" && env.sender_name) {
|
|
927
|
+
for (const [sid, card] of peerCards.entries()) {
|
|
928
|
+
if (card.name === env.sender_name) {
|
|
929
|
+
peerCards.delete(sid);
|
|
930
|
+
// Also clean up knownPeerSessions and fire notification
|
|
931
|
+
if (knownPeerSessions.has(sid)) {
|
|
932
|
+
knownPeerSessions.delete(sid);
|
|
933
|
+
log.debug("presence_leaving_received", "from", env.sender_name);
|
|
934
|
+
try {
|
|
935
|
+
pi.sendMessage({ customType: "coms-peer-left", content: `📡 Peer left: ${card.name} [${new Date().toISOString()}]`, display: true }, { triggerTurn: false });
|
|
936
|
+
try { pi.events.emit("pidash:coms-peer-event", { customType: "coms-peer-left", content: `📡 Peer left: ${card.name} [${new Date().toISOString()}]` }); } catch {}
|
|
937
|
+
} catch {}
|
|
938
|
+
}
|
|
939
|
+
break;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
maybeRefreshWidget();
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
log.debug("presence", env.status, "from", env.sender_name);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// ━━ task_update handler ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
949
|
+
function handleTaskUpdate(socket: net.Socket, env: TaskUpdateEnvelope): void {
|
|
950
|
+
ackOk(socket, env.msg_id);
|
|
951
|
+
log.debug("task_update_in", env.task_id, env.status, "from", env.sender_name);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// ━━ queue_manage handler ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
955
|
+
// Only the message OWNER (sender_session match) can manage their messages.
|
|
956
|
+
function handleQueueManage(socket: net.Socket, env: QueueManageEnvelope): void {
|
|
957
|
+
const action = env.action;
|
|
958
|
+
let affected = 0;
|
|
959
|
+
let error: string | null = null;
|
|
960
|
+
|
|
961
|
+
/** Check if an inbound is eligible (pending, not processing, owned by sender). */
|
|
962
|
+
const isOwned = (ib: InboundContext): boolean =>
|
|
963
|
+
!ib.fulfilled
|
|
964
|
+
&& !(currentInbound && ib.msg_id === currentInbound.msg_id)
|
|
965
|
+
&& ib.sender_session === env.sender_session;
|
|
966
|
+
|
|
967
|
+
/** Fulfill and remove a queued inbound, sending error response. */
|
|
968
|
+
const dropInbound = (ib: InboundContext): void => {
|
|
969
|
+
ib.fulfilled = true;
|
|
970
|
+
inboundQueue.delete(ib.msg_id);
|
|
971
|
+
if (identity) {
|
|
972
|
+
sendEnvelope(ib.sender_endpoint, {
|
|
973
|
+
type: "response", msg_id: ib.msg_id,
|
|
974
|
+
sender_session: identity.coms_session_id, sender_endpoint: identity.endpoint,
|
|
975
|
+
hops: 0, timestamp: nowIso(),
|
|
976
|
+
response: null, error: "queue_cleared",
|
|
977
|
+
queued_msg_ids: [],
|
|
978
|
+
}).catch(() => { /* best-effort */ });
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
if (action === "delete" && env.target_msg_id) {
|
|
983
|
+
const ib = inboundQueue.get(env.target_msg_id);
|
|
984
|
+
if (!ib) { error = "not_found"; }
|
|
985
|
+
else if (!isOwned(ib)) { error = "not_owned"; }
|
|
986
|
+
else { dropInbound(ib); affected++; }
|
|
987
|
+
} else if (action === "edit" && env.target_msg_id && typeof env.new_content === "string") {
|
|
988
|
+
const ib = inboundQueue.get(env.target_msg_id);
|
|
989
|
+
if (!ib) { error = "not_found"; }
|
|
990
|
+
else if (!isOwned(ib)) { error = "not_owned"; }
|
|
991
|
+
else { ib.prompt = env.new_content; affected++; }
|
|
992
|
+
} else if (action === "clear") {
|
|
993
|
+
for (const ib of [...inboundQueue.values()]) {
|
|
994
|
+
if (isOwned(ib)) { dropInbound(ib); affected++; }
|
|
995
|
+
}
|
|
996
|
+
} else if (action === "prioritize" && env.target_msg_id) {
|
|
997
|
+
const ib = inboundQueue.get(env.target_msg_id);
|
|
998
|
+
if (!ib) { error = "not_found"; }
|
|
999
|
+
else if (!isOwned(ib)) { error = "not_owned"; }
|
|
1000
|
+
else {
|
|
1001
|
+
inboundQueue.delete(ib.msg_id);
|
|
1002
|
+
const entries = [...inboundQueue.entries()];
|
|
1003
|
+
inboundQueue.clear();
|
|
1004
|
+
inboundQueue.set(ib.msg_id, ib);
|
|
1005
|
+
for (const [k, v] of entries) inboundQueue.set(k, v);
|
|
1006
|
+
affected++;
|
|
1007
|
+
}
|
|
1008
|
+
} else {
|
|
1009
|
+
error = "invalid_action";
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Send ack with result — includes error and affected count
|
|
1013
|
+
try {
|
|
1014
|
+
socket.write(JSON.stringify({ type: "ack", msg_id: env.msg_id, affected, error }) + "\n");
|
|
1015
|
+
} catch { /* ignore */ }
|
|
1016
|
+
|
|
1017
|
+
maybeRefreshWidget();
|
|
1018
|
+
log.debug("queue_manage", action, "target", env.target_msg_id, "affected", affected, "error", error);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// ━━ task_manage handler — remote task operations from peers ━━━━━━━━━━━
|
|
1022
|
+
async function handleTaskManage(socket: net.Socket, env: TaskManageEnvelope): Promise<void> {
|
|
1023
|
+
ackOk(socket, env.msg_id);
|
|
1024
|
+
let result: any = null;
|
|
1025
|
+
let error: string | null = null;
|
|
1026
|
+
|
|
1027
|
+
try {
|
|
1028
|
+
const { createTask, getTask, listTasks, updateTask, deleteTask } = await import("../pitasks/index.js");
|
|
1029
|
+
switch (env.action) {
|
|
1030
|
+
case "create":
|
|
1031
|
+
if (env.subject) result = createTask(env.subject, env.description ?? "", env.metadata);
|
|
1032
|
+
else error = "missing subject";
|
|
1033
|
+
break;
|
|
1034
|
+
case "get":
|
|
1035
|
+
if (env.task_id) result = getTask(env.task_id);
|
|
1036
|
+
else error = "missing task_id";
|
|
1037
|
+
break;
|
|
1038
|
+
case "list":
|
|
1039
|
+
result = listTasks();
|
|
1040
|
+
break;
|
|
1041
|
+
case "update":
|
|
1042
|
+
if (env.task_id && env.fields) {
|
|
1043
|
+
const t = getTask(env.task_id);
|
|
1044
|
+
if (t?.createdBy?.session === env.sender_session) {
|
|
1045
|
+
result = updateTask(env.task_id, env.fields);
|
|
1046
|
+
} else {
|
|
1047
|
+
error = "ownership_denied — can only update tasks you created";
|
|
1048
|
+
}
|
|
1049
|
+
} else error = "missing task_id or fields";
|
|
1050
|
+
break;
|
|
1051
|
+
case "delete":
|
|
1052
|
+
if (env.task_id) {
|
|
1053
|
+
const t = getTask(env.task_id);
|
|
1054
|
+
if (t?.createdBy?.session === env.sender_session) {
|
|
1055
|
+
result = deleteTask(env.task_id);
|
|
1056
|
+
} else {
|
|
1057
|
+
error = "ownership_denied — can only delete tasks you created";
|
|
1058
|
+
}
|
|
1059
|
+
} else error = "missing task_id";
|
|
1060
|
+
break;
|
|
1061
|
+
default:
|
|
1062
|
+
error = `unknown action: ${env.action}`;
|
|
1063
|
+
}
|
|
1064
|
+
} catch (e: any) {
|
|
1065
|
+
error = e?.message ?? String(e);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Send response back to sender
|
|
1069
|
+
if (identity) {
|
|
1070
|
+
try {
|
|
1071
|
+
await sendEnvelope(env.sender_endpoint, {
|
|
1072
|
+
type: "response",
|
|
1073
|
+
msg_id: env.msg_id,
|
|
1074
|
+
sender_session: identity.coms_session_id,
|
|
1075
|
+
sender_endpoint: identity.endpoint,
|
|
1076
|
+
hops: 0,
|
|
1077
|
+
timestamp: nowIso(),
|
|
1078
|
+
response: result,
|
|
1079
|
+
error,
|
|
1080
|
+
queued_msg_ids: [],
|
|
1081
|
+
});
|
|
1082
|
+
} catch { /* best-effort */ }
|
|
1083
|
+
}
|
|
1084
|
+
log.debug("task_manage", env.action, "from", env.sender_name, "error", error);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ━━ tool_result: send task heartbeat to coms originator (#731) ━━━━━━━━
|
|
1088
|
+
pi.on("tool_result", async (event: any, ctx) => {
|
|
1089
|
+
if (!identity) return;
|
|
1090
|
+
const toolName = event?.toolName as string;
|
|
1091
|
+
if (toolName !== "TaskUpdate") return;
|
|
1092
|
+
const input = event?.input || {};
|
|
1093
|
+
const taskId = input.taskId as string;
|
|
1094
|
+
const newStatus = input.status as string;
|
|
1095
|
+
if (!taskId || !newStatus) return;
|
|
1096
|
+
|
|
1097
|
+
// Look up the task to check if created by a coms peer
|
|
1098
|
+
try {
|
|
1099
|
+
const { getComsOriginTask } = await import("./coms-shared.js");
|
|
1100
|
+
const task = await getComsOriginTask(taskId);
|
|
1101
|
+
if (!task?.createdBy) return;
|
|
1102
|
+
const origin = task.createdBy;
|
|
1103
|
+
if (!origin.endpoint) return;
|
|
1104
|
+
|
|
1105
|
+
const env: TaskUpdateEnvelope = {
|
|
1106
|
+
type: "task_update",
|
|
1107
|
+
msg_id: ulid(),
|
|
1108
|
+
sender_session: identity.coms_session_id,
|
|
1109
|
+
sender_endpoint: identity.endpoint,
|
|
1110
|
+
hops: 0,
|
|
1111
|
+
timestamp: nowIso(),
|
|
1112
|
+
task_id: taskId,
|
|
1113
|
+
subject: task.subject ?? "",
|
|
1114
|
+
status: newStatus,
|
|
1115
|
+
sender_name: identity.name,
|
|
1116
|
+
};
|
|
1117
|
+
await sendEnvelope(origin.endpoint, env);
|
|
1118
|
+
log.debug("task_update_out", taskId, newStatus, "to", origin.name);
|
|
1119
|
+
} catch { /* best-effort — don't block tool_result */ }
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
// ━━ task heartbeat keepalive timer ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1123
|
+
let taskHeartbeatTimer: NodeJS.Timeout | null = null;
|
|
1124
|
+
|
|
854
1125
|
// ━━ session_start ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
855
1126
|
pi.on("session_start", async (_event, ctx) => {
|
|
856
1127
|
applyExtensionDefaults(import.meta.url, ctx);
|
|
857
1128
|
currentCtx = ctx;
|
|
858
1129
|
shuttingDown = false;
|
|
859
1130
|
peerCards.clear();
|
|
1131
|
+
welcomeShown = false;
|
|
1132
|
+
knownPeerSessions.clear();
|
|
1133
|
+
accumulatedResponses.clear();
|
|
860
1134
|
|
|
861
1135
|
// 1. Resolve identity from CLI flags > frontmatter > defaults.
|
|
862
1136
|
const flags = readCliFlags(pi);
|
|
863
1137
|
const fm = readFrontmatterFromArgv(process.argv);
|
|
864
1138
|
const project = flags.project || "default";
|
|
865
1139
|
const explicit = flags.explicit === true;
|
|
866
|
-
const
|
|
1140
|
+
const comsSessionId = (globalThis as any).__piConfigSessionId || ulid();
|
|
867
1141
|
|
|
868
|
-
const defaultName = `agent-${
|
|
1142
|
+
const defaultName = `agent-${comsSessionId.slice(-6)}`;
|
|
869
1143
|
const name = flags.name || fm.name || defaultName;
|
|
870
1144
|
const purpose = flags.purpose || fm.description || "";
|
|
871
1145
|
|
|
872
1146
|
// Color: validate at every level; fall through invalid hex to next.
|
|
873
1147
|
// Order: --color CLI flag > frontmatter color > deterministic fallback.
|
|
874
|
-
let color = fallbackColor(
|
|
1148
|
+
let color = fallbackColor(comsSessionId);
|
|
875
1149
|
if (fm.color && isValidHex(fm.color)) {
|
|
876
1150
|
color = fm.color;
|
|
877
1151
|
}
|
|
@@ -879,8 +1153,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
879
1153
|
color = flags.color;
|
|
880
1154
|
}
|
|
881
1155
|
|
|
882
|
-
const endpoint = makeEndpoint(session_id);
|
|
883
1156
|
const cwd = ctx.cwd || process.cwd();
|
|
1157
|
+
|
|
1158
|
+
// Resolve coms settings from project settings
|
|
1159
|
+
COMS_DIR = getSetting(cwd, "coms_dir") || path.join(os.homedir(), ".pi", "coms");
|
|
1160
|
+
MAX_HOPS = getSetting(cwd, "coms_max_hops");
|
|
1161
|
+
TIMEOUT_MS = getSetting(cwd, "coms_timeout_ms");
|
|
1162
|
+
const TASK_HEARTBEAT_MS: number = getSetting(cwd, "coms_task_heartbeat_ms") ?? 300_000;
|
|
1163
|
+
|
|
1164
|
+
const endpoint = makeEndpoint(comsSessionId);
|
|
884
1165
|
const model = ctx.model?.id ?? "unknown";
|
|
885
1166
|
|
|
886
1167
|
// 2. Ensure storage dirs exist.
|
|
@@ -898,7 +1179,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
898
1179
|
// 2b. Reject duplicate names — each peer must have a unique name in the project.
|
|
899
1180
|
const existingEntries = readAllRegistryEntries(project);
|
|
900
1181
|
for (const existing of existingEntries) {
|
|
901
|
-
if (existing.name === name && existing.
|
|
1182
|
+
if (existing.name === name && existing.coms_session_id !== comsSessionId) {
|
|
902
1183
|
// Verify the existing peer is actually alive via .ping endpoint.
|
|
903
1184
|
if (typeof existing.endpoint === "string" && existing.endpoint) {
|
|
904
1185
|
const pingEp = `${existing.endpoint}.ping`;
|
|
@@ -924,14 +1205,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
924
1205
|
|
|
925
1206
|
if (!hasPing && !hasMain) {
|
|
926
1207
|
// No socket files at all — definitely dead
|
|
927
|
-
removeRegistryEntry(project, existing.
|
|
1208
|
+
removeRegistryEntry(project, existing.coms_session_id);
|
|
928
1209
|
} else {
|
|
929
1210
|
// Probe(s) returned stale — remove registry, peer is dead
|
|
930
|
-
removeRegistryEntry(project, existing.
|
|
1211
|
+
removeRegistryEntry(project, existing.coms_session_id);
|
|
931
1212
|
}
|
|
932
1213
|
} else {
|
|
933
1214
|
// No valid endpoint — remove stale entry
|
|
934
|
-
removeRegistryEntry(project, existing.
|
|
1215
|
+
removeRegistryEntry(project, existing.coms_session_id);
|
|
935
1216
|
}
|
|
936
1217
|
}
|
|
937
1218
|
}
|
|
@@ -953,17 +1234,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
953
1234
|
pingWorkerReady = true;
|
|
954
1235
|
updatePingWorkerCard(); // Push initial card immediately
|
|
955
1236
|
}
|
|
956
|
-
if (msg.type === "error")
|
|
1237
|
+
if (msg.type === "error") log.warn("ping_worker_error", msg.message);
|
|
957
1238
|
});
|
|
958
1239
|
pingWorker.on("error", () => { pingWorkerReady = false; });
|
|
959
1240
|
pingWorker.on("exit", () => { pingWorkerReady = false; pingWorker = null; });
|
|
960
1241
|
} catch (err) {
|
|
961
|
-
|
|
1242
|
+
log.warn("ping_worker_start_failed", err instanceof Error ? err.message : String(err));
|
|
962
1243
|
}
|
|
963
1244
|
|
|
964
1245
|
// 4. Build + write registry entry atomically.
|
|
1246
|
+
const piSessionId = (globalThis as any).__piConfigSessionId || "";
|
|
965
1247
|
const entry: RegistryEntry = {
|
|
966
|
-
|
|
1248
|
+
coms_session_id: comsSessionId,
|
|
967
1249
|
name,
|
|
968
1250
|
purpose,
|
|
969
1251
|
model,
|
|
@@ -974,6 +1256,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
974
1256
|
started_at: nowIso(),
|
|
975
1257
|
explicit,
|
|
976
1258
|
version: 1,
|
|
1259
|
+
pi_session_id: piSessionId,
|
|
977
1260
|
};
|
|
978
1261
|
let registryFile: string;
|
|
979
1262
|
try {
|
|
@@ -985,7 +1268,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
985
1268
|
}
|
|
986
1269
|
|
|
987
1270
|
identity = {
|
|
988
|
-
|
|
1271
|
+
coms_session_id: comsSessionId,
|
|
989
1272
|
name,
|
|
990
1273
|
purpose,
|
|
991
1274
|
color,
|
|
@@ -995,16 +1278,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
995
1278
|
model,
|
|
996
1279
|
endpoint,
|
|
997
1280
|
registryFile,
|
|
1281
|
+
pi_session_id: piSessionId,
|
|
998
1282
|
};
|
|
999
1283
|
includeExplicit = false;
|
|
1000
1284
|
displayProject = project;
|
|
1001
1285
|
|
|
1002
1286
|
// 5. Audit log: boot.
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
// best-effort
|
|
1007
|
-
}
|
|
1287
|
+
setLogFilePrefix("coms", identity.name);
|
|
1288
|
+
try { pi.events.emit("pidash:coms-identity", { name: identity.name, purpose: identity.purpose || "", project: identity.project || "" }); } catch {}
|
|
1289
|
+
log.info("boot", name, project);
|
|
1008
1290
|
|
|
1009
1291
|
// 6. Surface presence in the UI + install the live pool widget.
|
|
1010
1292
|
try {
|
|
@@ -1018,33 +1300,225 @@ export default function (pi: ExtensionAPI) {
|
|
|
1018
1300
|
// hasUI may be false in some contexts — non-fatal.
|
|
1019
1301
|
}
|
|
1020
1302
|
|
|
1021
|
-
// 7. Start
|
|
1303
|
+
// 7. Start timers (skip in one-shot modes).
|
|
1022
1304
|
if (ctx.mode !== "print" && ctx.mode !== "json") {
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1305
|
+
|
|
1306
|
+
// Seed peerCards from existing registry entries
|
|
1307
|
+
const existingPeers = readAllRegistryEntries(project);
|
|
1308
|
+
for (const entry of existingPeers) {
|
|
1309
|
+
if (entry.coms_session_id === identity.coms_session_id) continue;
|
|
1310
|
+
if (entry.explicit && !includeExplicit) continue;
|
|
1311
|
+
peerCards.set(entry.coms_session_id, {
|
|
1312
|
+
name: entry.name,
|
|
1313
|
+
purpose: entry.purpose,
|
|
1314
|
+
model: entry.model,
|
|
1315
|
+
color: entry.color,
|
|
1316
|
+
context_used_pct: entry.context_used_pct ?? 0,
|
|
1317
|
+
queue_depth: entry.queue_depth ?? 0,
|
|
1318
|
+
tasks_summary: null,
|
|
1319
|
+
staleCount: 0,
|
|
1320
|
+
});
|
|
1321
|
+
knownPeerSessions.set(entry.coms_session_id, entry.name);
|
|
1322
|
+
}
|
|
1323
|
+
log.info("boot_seed", "peers", peerCards.size);
|
|
1324
|
+
if (peerCards.size > 0) {
|
|
1325
|
+
maybeRefreshWidget();
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
// Show welcome if peers found — reusable so the FIRST peer (who booted
|
|
1329
|
+
// with no peers) still gets it retroactively when a peer later joins.
|
|
1330
|
+
const showWelcomeIfNeeded = () => {
|
|
1331
|
+
if (welcomeShown || !identity) return;
|
|
1332
|
+
welcomeShown = true;
|
|
1333
|
+
const peerList = peerCards.size > 0
|
|
1334
|
+
? [...peerCards.values()]
|
|
1335
|
+
.map(card => `● ${card.name} (${card.model})${card.purpose ? ` — ${card.purpose}` : ""}`)
|
|
1336
|
+
.join("\n")
|
|
1337
|
+
: "(none yet — you are the first peer)";
|
|
1338
|
+
const welcomeMsg = `📡 Connected to coms · You are "${identity.name}" on project "${identity.project}"
|
|
1339
|
+
|
|
1340
|
+
## How to reply
|
|
1341
|
+
- Your assistant text IS your reply — auto-captured when your turn ends
|
|
1342
|
+
|
|
1343
|
+
## Available tools
|
|
1344
|
+
- coms_list — see connected peers
|
|
1345
|
+
- coms_send — send a message to a peer
|
|
1346
|
+
- coms_get — check status of a sent message
|
|
1347
|
+
- coms_queue_delete / coms_queue_edit / coms_queue_clear / coms_queue_prioritize — manage queued messages
|
|
1348
|
+
- coms_tasks_create — create tasks on a peer's task list with auto-report and auto-message
|
|
1349
|
+
- coms_task_delete — delete a task you created on a peer's task list
|
|
1350
|
+
- coms_task_list — list tasks on a peer's task list without sending a message
|
|
1351
|
+
- coms_task_get — get a specific task from a peer's task list
|
|
1352
|
+
- coms_task_update — update a task you created on a peer's task list
|
|
1353
|
+
|
|
1354
|
+
## Task handling
|
|
1355
|
+
- Tasks may appear in your task widget at any time — check TaskList when prompted to work
|
|
1356
|
+
- When you receive a message to start working, check TaskList first for assigned tasks
|
|
1357
|
+
- Work through tasks in order using TaskUpdate to mark progress
|
|
1358
|
+
- Report completion back to the sender when all tasks are done
|
|
1359
|
+
- If a task is too large, break it into subtasks
|
|
1360
|
+
- The last task in your list will always be "Report completion to sender" — it's blocked by all other tasks. When all tasks are done, use coms_send to report back.
|
|
1361
|
+
|
|
1362
|
+
## Connected peers
|
|
1363
|
+
${peerList}
|
|
1364
|
+
Do not respond to this message.`;
|
|
1365
|
+
|
|
1366
|
+
// Seed knownPeerSessions for every current peer so the retroactive
|
|
1367
|
+
// welcome does not also trigger duplicate "peer joined" cards.
|
|
1368
|
+
for (const [sid, card] of peerCards) knownPeerSessions.set(sid, card.name);
|
|
1369
|
+
|
|
1370
|
+
log.info("welcome_shown", "peers", peerCards.size);
|
|
1371
|
+
log.debug("welcome_trigger_turn", peerCards.size > 0);
|
|
1372
|
+
try {
|
|
1373
|
+
pi.sendMessage({
|
|
1374
|
+
customType: "coms-welcome",
|
|
1375
|
+
content: welcomeMsg,
|
|
1376
|
+
display: true,
|
|
1377
|
+
}, { triggerTurn: peerCards.size > 0 });
|
|
1378
|
+
} catch {}
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
// Show welcome if peers found on boot
|
|
1382
|
+
showWelcomeIfNeeded();
|
|
1383
|
+
|
|
1384
|
+
// Watch registry directory for peer changes (fs.watch)
|
|
1385
|
+
const agentsDir = projectAgentsDir(project);
|
|
1386
|
+
try {
|
|
1387
|
+
fsWatcher = fs.watch(agentsDir, (eventType, filename) => {
|
|
1388
|
+
log.debug("fs_watch_event", eventType, filename);
|
|
1389
|
+
if (!filename || !filename.endsWith(".json") || filename.endsWith(".tmp")) return;
|
|
1390
|
+
if (!identity || shuttingDown) return;
|
|
1391
|
+
const filePath = path.join(agentsDir, filename);
|
|
1392
|
+
const sessionId = filename.replace(".json", "");
|
|
1393
|
+
if (sessionId === identity.coms_session_id) return;
|
|
1394
|
+
|
|
1395
|
+
if (eventType === "rename") {
|
|
1396
|
+
if (fs.existsSync(filePath)) {
|
|
1397
|
+
try {
|
|
1398
|
+
const entry = JSON.parse(fs.readFileSync(filePath, "utf-8")) as RegistryEntry;
|
|
1399
|
+
if (entry && entry.coms_session_id && entry.endpoint) {
|
|
1400
|
+
if (peerCards.has(entry.coms_session_id)) {
|
|
1401
|
+
// Existing peer — update card (heartbeat via atomic rename)
|
|
1402
|
+
const existing = peerCards.get(entry.coms_session_id)!;
|
|
1403
|
+
existing.context_used_pct = entry.context_used_pct ?? existing.context_used_pct;
|
|
1404
|
+
existing.queue_depth = entry.queue_depth ?? existing.queue_depth;
|
|
1405
|
+
existing.model = entry.model ?? existing.model;
|
|
1406
|
+
existing.staleCount = 0;
|
|
1407
|
+
log.debug("fs_watch_heartbeat_rename", entry.name, "ctx", entry.context_used_pct);
|
|
1408
|
+
} else {
|
|
1409
|
+
// New peer
|
|
1410
|
+
const card: AgentCard & { staleCount: number } = {
|
|
1411
|
+
name: entry.name, purpose: entry.purpose, model: entry.model,
|
|
1412
|
+
color: entry.color, context_used_pct: entry.context_used_pct ?? 0,
|
|
1413
|
+
queue_depth: entry.queue_depth ?? 0, tasks_summary: null, staleCount: 0,
|
|
1414
|
+
};
|
|
1415
|
+
peerCards.set(entry.coms_session_id, card);
|
|
1416
|
+
log.info("fs_watch_peer_added", entry.name, entry.coms_session_id);
|
|
1417
|
+
maybeRefreshWidget();
|
|
1418
|
+
if (!welcomeShown) {
|
|
1419
|
+
log.debug("welcome_retroactive", entry.name);
|
|
1420
|
+
showWelcomeIfNeeded();
|
|
1421
|
+
}
|
|
1422
|
+
if (welcomeShown && !knownPeerSessions.has(entry.coms_session_id)) {
|
|
1423
|
+
knownPeerSessions.set(entry.coms_session_id, entry.name);
|
|
1424
|
+
try { pi.sendMessage({ customType: "coms-peer-joined", content: `📡 Peer joined: ${entry.name} (${entry.model})${entry.purpose ? ` — ${entry.purpose}` : ""} [${new Date().toISOString()}]`, display: true }, { triggerTurn: false }); } catch {}
|
|
1425
|
+
try { pi.events.emit("pidash:coms-peer-event", { customType: "coms-peer-joined", content: `📡 Peer joined: ${entry.name} (${entry.model})${entry.purpose ? ` — ${entry.purpose}` : ""} [${new Date().toISOString()}]` }); } catch {}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
} catch {}
|
|
1430
|
+
} else {
|
|
1431
|
+
if (peerCards.has(sessionId)) {
|
|
1432
|
+
const card = peerCards.get(sessionId);
|
|
1433
|
+
peerCards.delete(sessionId);
|
|
1434
|
+
log.info("fs_watch_peer_removed", card?.name ?? sessionId);
|
|
1435
|
+
maybeRefreshWidget();
|
|
1436
|
+
if (knownPeerSessions.has(sessionId)) {
|
|
1437
|
+
const name = knownPeerSessions.get(sessionId) ?? sessionId;
|
|
1438
|
+
knownPeerSessions.delete(sessionId);
|
|
1439
|
+
const sameNameStillExists = [...peerCards.values()].some(c => c.name === name);
|
|
1440
|
+
if (!sameNameStillExists) {
|
|
1441
|
+
try { pi.sendMessage({ customType: "coms-peer-left", content: `📡 Peer left: ${name} [${new Date().toISOString()}]`, display: true }, { triggerTurn: false }); } catch {}
|
|
1442
|
+
try { pi.events.emit("pidash:coms-peer-event", { customType: "coms-peer-left", content: `📡 Peer left: ${name} [${new Date().toISOString()}]` }); } catch {}
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
} else if (eventType === "change") {
|
|
1448
|
+
try {
|
|
1449
|
+
const entry = JSON.parse(fs.readFileSync(filePath, "utf-8")) as RegistryEntry;
|
|
1450
|
+
if (entry && entry.coms_session_id && peerCards.has(entry.coms_session_id)) {
|
|
1451
|
+
const existing = peerCards.get(entry.coms_session_id)!;
|
|
1452
|
+
existing.context_used_pct = entry.context_used_pct ?? existing.context_used_pct;
|
|
1453
|
+
existing.queue_depth = entry.queue_depth ?? existing.queue_depth;
|
|
1454
|
+
existing.staleCount = 0;
|
|
1455
|
+
existing.model = entry.model ?? existing.model;
|
|
1456
|
+
log.debug("fs_watch_heartbeat", entry.name, "ctx", entry.context_used_pct);
|
|
1457
|
+
} else if (entry && entry.coms_session_id && !peerCards.has(entry.coms_session_id)) {
|
|
1458
|
+
const card: AgentCard & { staleCount: number } = {
|
|
1459
|
+
name: entry.name, purpose: entry.purpose, model: entry.model,
|
|
1460
|
+
color: entry.color, context_used_pct: entry.context_used_pct ?? 0,
|
|
1461
|
+
queue_depth: entry.queue_depth ?? 0, tasks_summary: null, staleCount: 0,
|
|
1462
|
+
};
|
|
1463
|
+
peerCards.set(entry.coms_session_id, card);
|
|
1464
|
+
log.info("fs_watch_peer_added_via_change", entry.name, entry.coms_session_id);
|
|
1465
|
+
maybeRefreshWidget();
|
|
1466
|
+
if (!welcomeShown) {
|
|
1467
|
+
log.debug("welcome_retroactive", entry.name);
|
|
1468
|
+
showWelcomeIfNeeded();
|
|
1469
|
+
}
|
|
1470
|
+
if (welcomeShown && !knownPeerSessions.has(entry.coms_session_id)) {
|
|
1471
|
+
knownPeerSessions.set(entry.coms_session_id, entry.name);
|
|
1472
|
+
try { pi.sendMessage({ customType: "coms-peer-joined", content: `📡 Peer joined: ${entry.name} (${entry.model})${entry.purpose ? ` — ${entry.purpose}` : ""} [${new Date().toISOString()}]`, display: true }, { triggerTurn: false }); } catch {}
|
|
1473
|
+
try { pi.events.emit("pidash:coms-peer-event", { customType: "coms-peer-joined", content: `📡 Peer joined: ${entry.name} (${entry.model})${entry.purpose ? ` — ${entry.purpose}` : ""} [${new Date().toISOString()}]` }); } catch {}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
} catch {}
|
|
1477
|
+
}
|
|
1478
|
+
});
|
|
1479
|
+
log.info("fs_watch_started", agentsDir);
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
log.error("fs_watch_failed", err instanceof Error ? err.message : String(err));
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Start task heartbeat keepalive timer (#731)
|
|
1485
|
+
if (taskHeartbeatTimer) { try { clearInterval(taskHeartbeatTimer); } catch { /* ignore */ } }
|
|
1486
|
+
taskHeartbeatTimer = setInterval(async () => {
|
|
1487
|
+
if (!identity || shuttingDown) return;
|
|
1488
|
+
try {
|
|
1489
|
+
const { getComsOriginTasks } = await import("./coms-shared.js");
|
|
1490
|
+
const tasks = await getComsOriginTasks();
|
|
1491
|
+
for (const task of tasks) {
|
|
1492
|
+
if (!task.createdBy.endpoint) continue;
|
|
1493
|
+
try {
|
|
1494
|
+
await sendEnvelope(task.createdBy.endpoint, {
|
|
1495
|
+
type: "task_update",
|
|
1496
|
+
msg_id: ulid(),
|
|
1497
|
+
sender_session: identity.coms_session_id,
|
|
1498
|
+
sender_endpoint: identity.endpoint,
|
|
1499
|
+
hops: 0,
|
|
1500
|
+
timestamp: nowIso(),
|
|
1501
|
+
task_id: task.taskId,
|
|
1502
|
+
subject: task.subject,
|
|
1503
|
+
status: task.status,
|
|
1504
|
+
sender_name: identity.name,
|
|
1505
|
+
});
|
|
1506
|
+
} catch {}
|
|
1507
|
+
}
|
|
1508
|
+
} catch {}
|
|
1509
|
+
}, TASK_HEARTBEAT_MS);
|
|
1510
|
+
try { (taskHeartbeatTimer as any).unref?.(); } catch {}
|
|
1511
|
+
|
|
1512
|
+
// Keepalive — write registry heartbeat + socket self-heal
|
|
1028
1513
|
keepaliveTimer = setInterval(async () => {
|
|
1029
1514
|
if (!identity) return;
|
|
1030
1515
|
if (shuttingDown) return;
|
|
1031
1516
|
try {
|
|
1032
|
-
// Socket self-heal: if our socket file was deleted (e.g., by pruneStaleRegistry
|
|
1033
|
-
// timeout), the net.Server is orphaned — listening on an inode with no filesystem
|
|
1034
|
-
// entry. Re-bind the server and ping worker on the same endpoint path.
|
|
1035
1517
|
if (!shuttingDown && process.platform !== "win32" && !fs.existsSync(identity.endpoint)) {
|
|
1036
1518
|
try {
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
if (server) {
|
|
1040
|
-
try { server.close(); } catch { /* ignore */ }
|
|
1041
|
-
server = null;
|
|
1042
|
-
}
|
|
1043
|
-
// Re-bind new server on same endpoint
|
|
1519
|
+
log.warn("self_heal_socket_missing", identity.coms_session_id, identity.endpoint);
|
|
1520
|
+
if (server) { try { server.close(); } catch {} server = null; }
|
|
1044
1521
|
server = await bindEndpoint(identity.endpoint, connHandler);
|
|
1045
|
-
// Restart ping worker — terminate old one and remove its
|
|
1046
|
-
// event handlers to prevent late exit/error from clobbering
|
|
1047
|
-
// the new worker's state.
|
|
1048
1522
|
if (pingWorker) {
|
|
1049
1523
|
const oldWorker = pingWorker;
|
|
1050
1524
|
oldWorker.removeAllListeners();
|
|
@@ -1057,26 +1531,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
1057
1531
|
try {
|
|
1058
1532
|
pingWorker = createPingWorker(pingEndpoint);
|
|
1059
1533
|
pingWorker.on("message", (msg: any) => {
|
|
1060
|
-
if (msg.type === "ready") {
|
|
1061
|
-
|
|
1062
|
-
updatePingWorkerCard();
|
|
1063
|
-
}
|
|
1064
|
-
if (msg.type === "error") console.debug("[coms] ping worker error:", msg.message);
|
|
1534
|
+
if (msg.type === "ready") { pingWorkerReady = true; updatePingWorkerCard(); }
|
|
1535
|
+
if (msg.type === "error") log.warn("ping_worker_error", msg.message);
|
|
1065
1536
|
});
|
|
1066
1537
|
pingWorker.on("error", () => { pingWorkerReady = false; });
|
|
1067
1538
|
pingWorker.on("exit", () => { pingWorkerReady = false; pingWorker = null; });
|
|
1068
|
-
} catch {
|
|
1539
|
+
} catch {}
|
|
1069
1540
|
} catch (err) {
|
|
1070
|
-
|
|
1541
|
+
log.error("self_heal_socket_failed", identity.coms_session_id, err instanceof Error ? err.message : String(err));
|
|
1071
1542
|
}
|
|
1072
1543
|
}
|
|
1073
|
-
|
|
1074
1544
|
const ctx = currentCtx;
|
|
1075
|
-
// Detect missing-registry BEFORE writing so the self_heal audit only
|
|
1076
|
-
// fires when something actually went wrong (file unlinked under us).
|
|
1077
1545
|
const missingBeforeWrite = !fs.existsSync(identity.registryFile);
|
|
1078
1546
|
const live: RegistryEntry = {
|
|
1079
|
-
|
|
1547
|
+
coms_session_id: identity.coms_session_id,
|
|
1080
1548
|
name: identity.name,
|
|
1081
1549
|
purpose: identity.purpose,
|
|
1082
1550
|
model: ctx?.model?.id ?? identity.model,
|
|
@@ -1090,58 +1558,69 @@ export default function (pi: ExtensionAPI) {
|
|
|
1090
1558
|
context_used_pct: Math.round(ctx?.getContextUsage()?.percent ?? 0),
|
|
1091
1559
|
queue_depth: getPendingInboundCount(),
|
|
1092
1560
|
heartbeat_at: nowIso(),
|
|
1561
|
+
pi_session_id: (globalThis as any).__piConfigSessionId || identity.pi_session_id || "",
|
|
1093
1562
|
};
|
|
1094
|
-
// Unconditional atomic write: handles BOTH the live-status refresh
|
|
1095
|
-
// (file present → overwrite with fresh values) AND self-heal (file
|
|
1096
|
-
// missing → re-create entry). The atomic write also bumps mtime.
|
|
1097
1563
|
writeRegistryAtomic(live, identity.project);
|
|
1098
1564
|
if (missingBeforeWrite) {
|
|
1099
|
-
|
|
1100
|
-
// Edge case: if the file was unlinked again between our write and
|
|
1101
|
-
// this check, re-write once to be safe.
|
|
1565
|
+
log.warn("self_heal_registry", identity.coms_session_id);
|
|
1102
1566
|
if (!fs.existsSync(identity.registryFile)) {
|
|
1103
1567
|
writeRegistryAtomic(live, identity.project);
|
|
1104
1568
|
}
|
|
1105
1569
|
}
|
|
1106
|
-
|
|
1570
|
+
updatePingWorkerCard();
|
|
1571
|
+
} catch {}
|
|
1107
1572
|
}, KEEPALIVE_INTERVAL_MS);
|
|
1108
|
-
try { (keepaliveTimer as any).unref?.(); } catch {
|
|
1573
|
+
try { (keepaliveTimer as any).unref?.(); } catch {}
|
|
1574
|
+
|
|
1575
|
+
// Stale peer detection — runs every 60s, confirms with socket probe
|
|
1576
|
+
const STALE_THRESHOLD_MS = 90_000;
|
|
1577
|
+
const staleCheckTimer = setInterval(async () => {
|
|
1578
|
+
if (!identity || shuttingDown) return;
|
|
1579
|
+
for (const [sid, card] of peerCards.entries()) {
|
|
1580
|
+
if (sid === identity.coms_session_id) continue;
|
|
1581
|
+
const regFile = registryFilePath(identity.project, sid);
|
|
1582
|
+
try {
|
|
1583
|
+
const stat = fs.statSync(regFile);
|
|
1584
|
+
const age = Date.now() - stat.mtimeMs;
|
|
1585
|
+
if (age > STALE_THRESHOLD_MS) {
|
|
1586
|
+
log.debug("stale_check", card.name, "age", Math.round(age / 1000), "s");
|
|
1587
|
+
const entries = readAllRegistryEntries(identity.project);
|
|
1588
|
+
const entry = entries.find(e => e.coms_session_id === sid);
|
|
1589
|
+
if (entry?.endpoint) {
|
|
1590
|
+
const alive = await probeAlive(entry.endpoint);
|
|
1591
|
+
if (!alive) {
|
|
1592
|
+
log.info("stale_confirmed_dead", card.name, sid);
|
|
1593
|
+
peerCards.delete(sid);
|
|
1594
|
+
if (knownPeerSessions.has(sid)) {
|
|
1595
|
+
const name = knownPeerSessions.get(sid) ?? sid;
|
|
1596
|
+
knownPeerSessions.delete(sid);
|
|
1597
|
+
const sameNameStillExists = [...peerCards.values()].some(c => c.name === name);
|
|
1598
|
+
if (!sameNameStillExists) {
|
|
1599
|
+
try { pi.sendMessage({ customType: "coms-peer-left", content: `📡 Peer left: ${name} [${new Date().toISOString()}]`, display: true }, { triggerTurn: false }); } catch {}
|
|
1600
|
+
try { pi.events.emit("pidash:coms-peer-event", { customType: "coms-peer-left", content: `📡 Peer left: ${name} [${new Date().toISOString()}]` }); } catch {}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
removeRegistryEntry(identity.project, sid);
|
|
1604
|
+
maybeRefreshWidget();
|
|
1605
|
+
} else {
|
|
1606
|
+
log.debug("stale_but_alive", card.name, "resetting staleCount");
|
|
1607
|
+
card.staleCount = 0;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
} catch {}
|
|
1612
|
+
}
|
|
1613
|
+
}, 60_000);
|
|
1614
|
+
log.debug("stale_check_timer_started", "interval", 60000, "threshold", STALE_THRESHOLD_MS);
|
|
1615
|
+
try { (staleCheckTimer as any).unref?.(); } catch {}
|
|
1109
1616
|
|
|
1110
|
-
// Kick one ping cycle immediately so the widget populates fast.
|
|
1111
|
-
refreshPool().catch(() => {});
|
|
1112
1617
|
} // end mode guard for timers
|
|
1113
1618
|
});
|
|
1114
1619
|
|
|
1115
1620
|
// ━━ Helpers used by tools ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1116
1621
|
|
|
1117
|
-
async function pingPeer(endpoint: string): Promise<AgentCard | null> {
|
|
1118
|
-
if (!identity) return null;
|
|
1119
|
-
const env: PingEnvelope = {
|
|
1120
|
-
type: "ping",
|
|
1121
|
-
msg_id: ulid(),
|
|
1122
|
-
sender_session: identity.session_id,
|
|
1123
|
-
sender_endpoint: identity.endpoint,
|
|
1124
|
-
hops: 0,
|
|
1125
|
-
timestamp: nowIso(),
|
|
1126
|
-
};
|
|
1127
|
-
try {
|
|
1128
|
-
const resp = await sendEnvelope(endpoint, env);
|
|
1129
|
-
if (resp && resp.type === "pong" && resp.agent_card) {
|
|
1130
|
-
return resp.agent_card as AgentCard;
|
|
1131
|
-
}
|
|
1132
|
-
} catch {
|
|
1133
|
-
// ignore — peer unreachable
|
|
1134
|
-
}
|
|
1135
|
-
return null;
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
1622
|
// ━━ Pool widget rendering ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1139
1623
|
function renderPool(width: number, theme: Theme): string[] {
|
|
1140
|
-
const projectFilter = displayProject ?? identity?.project ?? "default";
|
|
1141
|
-
const registryEntries = projectFilter === "*"
|
|
1142
|
-
? readAllRegistryEntriesAcrossProjects()
|
|
1143
|
-
: readAllRegistryEntries(projectFilter);
|
|
1144
|
-
|
|
1145
1624
|
interface Row {
|
|
1146
1625
|
name: string;
|
|
1147
1626
|
model: string;
|
|
@@ -1154,11 +1633,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1154
1633
|
queue_depth: number;
|
|
1155
1634
|
}
|
|
1156
1635
|
const rows: Row[] = [];
|
|
1157
|
-
const seenSessions = new Set<string>();
|
|
1158
1636
|
|
|
1159
1637
|
for (const [sid, card] of peerCards.entries()) {
|
|
1160
|
-
if (identity && sid === identity.
|
|
1161
|
-
seenSessions.add(sid);
|
|
1638
|
+
if (identity && sid === identity.coms_session_id) continue;
|
|
1162
1639
|
rows.push({
|
|
1163
1640
|
name: card.name,
|
|
1164
1641
|
model: card.model,
|
|
@@ -1172,25 +1649,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1172
1649
|
});
|
|
1173
1650
|
}
|
|
1174
1651
|
|
|
1175
|
-
// Registry-only entries that aren't yet in peerCards → pending
|
|
1176
|
-
const seenNames = new Set(rows.map((r) => r.name));
|
|
1177
|
-
for (const entry of registryEntries) {
|
|
1178
|
-
if (identity && entry.session_id === identity.session_id) continue;
|
|
1179
|
-
if (!includeExplicit && entry.explicit) continue;
|
|
1180
|
-
if (seenSessions.has(entry.session_id)) continue;
|
|
1181
|
-
if (seenNames.has(entry.name)) continue;
|
|
1182
|
-
rows.push({
|
|
1183
|
-
name: entry.name,
|
|
1184
|
-
model: entry.model,
|
|
1185
|
-
color: entry.color,
|
|
1186
|
-
purpose: entry.purpose,
|
|
1187
|
-
pct: null,
|
|
1188
|
-
pending: true,
|
|
1189
|
-
stale: false,
|
|
1190
|
-
queue_depth: 0,
|
|
1191
|
-
});
|
|
1192
|
-
}
|
|
1193
|
-
|
|
1194
1652
|
// Border helpers — sandwich the body with single-line box-drawing rules
|
|
1195
1653
|
// so the widget reads as its own block above the minimal footer. The
|
|
1196
1654
|
// top border carries a branded ` coms ` tag so the widget reads as its
|
|
@@ -1288,94 +1746,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1288
1746
|
}
|
|
1289
1747
|
|
|
1290
1748
|
// ━━ Ping cycle ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1291
|
-
async function refreshPool(): Promise<void> {
|
|
1292
|
-
if (!identity) return;
|
|
1293
|
-
const projectFilter = displayProject ?? identity.project;
|
|
1294
|
-
const live = projectFilter === "*"
|
|
1295
|
-
? await pruneDeadEntriesAllProjects()
|
|
1296
|
-
: await pruneDeadEntries(projectFilter);
|
|
1297
|
-
|
|
1298
|
-
const peers = live.filter((e) =>
|
|
1299
|
-
e.session_id !== identity!.session_id && (includeExplicit || !e.explicit),
|
|
1300
|
-
);
|
|
1301
|
-
|
|
1302
|
-
const results = await Promise.allSettled(peers.map(async (peer) => {
|
|
1303
|
-
const pingEnv: PingEnvelope = {
|
|
1304
|
-
type: "ping",
|
|
1305
|
-
msg_id: ulid(),
|
|
1306
|
-
sender_session: identity!.session_id,
|
|
1307
|
-
sender_endpoint: identity!.endpoint,
|
|
1308
|
-
hops: 0,
|
|
1309
|
-
timestamp: nowIso(),
|
|
1310
|
-
};
|
|
1311
|
-
// Try dedicated ping socket first (immune to event-loop blocks), fall back to main
|
|
1312
|
-
const pingEndpoint = `${peer.endpoint}.ping`;
|
|
1313
|
-
let reply: any;
|
|
1314
|
-
try {
|
|
1315
|
-
reply = await sendEnvelope(pingEndpoint, pingEnv);
|
|
1316
|
-
} catch {
|
|
1317
|
-
reply = await sendEnvelope(peer.endpoint, pingEnv);
|
|
1318
|
-
}
|
|
1319
|
-
return { peer, pong: reply as Pong };
|
|
1320
|
-
}));
|
|
1321
|
-
|
|
1322
|
-
const seenSessions = new Set<string>();
|
|
1323
|
-
let changed = false;
|
|
1324
|
-
|
|
1325
|
-
for (const r of results) {
|
|
1326
|
-
if (r.status === "fulfilled" && r.value.pong && r.value.pong.agent_card) {
|
|
1327
|
-
const { peer, pong } = r.value;
|
|
1328
|
-
seenSessions.add(peer.session_id);
|
|
1329
|
-
const prev = peerCards.get(peer.session_id);
|
|
1330
|
-
const next = { ...pong.agent_card, staleCount: 0 };
|
|
1331
|
-
if (!prev || JSON.stringify({ ...prev, staleCount: 0 }) !== JSON.stringify(next)) {
|
|
1332
|
-
peerCards.set(peer.session_id, next);
|
|
1333
|
-
changed = true;
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
for (const [sid, card] of peerCards.entries()) {
|
|
1339
|
-
if (identity && sid === identity.session_id) continue;
|
|
1340
|
-
if (!seenSessions.has(sid)) {
|
|
1341
|
-
card.staleCount = (card.staleCount ?? 0) + 1;
|
|
1342
|
-
if (card.staleCount > 6) {
|
|
1343
|
-
peerCards.delete(sid);
|
|
1344
|
-
}
|
|
1345
|
-
changed = true;
|
|
1346
|
-
}
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
// Dedup peerCards by name — when a peer reloads, it gets a new session_id.
|
|
1350
|
-
// Both old (stale) and new (alive) entries exist briefly. Keep the one with
|
|
1351
|
-
// lower staleCount (fresher). This prevents brief duplicate display in the widget.
|
|
1352
|
-
const nameToSid = new Map<string, string>();
|
|
1353
|
-
for (const [sid, card] of peerCards.entries()) {
|
|
1354
|
-
const existing = nameToSid.get(card.name);
|
|
1355
|
-
if (existing) {
|
|
1356
|
-
const prevCard = peerCards.get(existing)!;
|
|
1357
|
-
// Only dedup when one is clearly stale (reload artifact).
|
|
1358
|
-
// If both are alive (staleCount 0), keep both — they're distinct peers.
|
|
1359
|
-
if (card.staleCount === 0 && prevCard.staleCount === 0) {
|
|
1360
|
-
// Both alive — keep both, skip dedup
|
|
1361
|
-
} else if (card.staleCount < prevCard.staleCount) {
|
|
1362
|
-
peerCards.delete(existing);
|
|
1363
|
-
nameToSid.set(card.name, sid);
|
|
1364
|
-
changed = true;
|
|
1365
|
-
} else {
|
|
1366
|
-
peerCards.delete(sid);
|
|
1367
|
-
changed = true;
|
|
1368
|
-
}
|
|
1369
|
-
} else {
|
|
1370
|
-
nameToSid.set(card.name, sid);
|
|
1371
|
-
}
|
|
1372
|
-
}
|
|
1373
|
-
|
|
1374
|
-
if (changed && currentCtx?.hasUI) {
|
|
1375
|
-
installPoolWidget(currentCtx);
|
|
1376
|
-
}
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
1749
|
function listProjects(): string[] {
|
|
1380
1750
|
const root = path.join(COMS_DIR, "projects");
|
|
1381
1751
|
try {
|
|
@@ -1390,13 +1760,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
1390
1760
|
async function resolveTarget(target: string): Promise<RegistryEntry | null> {
|
|
1391
1761
|
// Prefer exact session_id match first (unambiguous).
|
|
1392
1762
|
if (identity) {
|
|
1393
|
-
const localEntries = await pruneDeadEntries(identity.project);
|
|
1763
|
+
const localEntries = await pruneDeadEntries(identity.project, process.cwd());
|
|
1394
1764
|
// Try session_id match first (always unambiguous)
|
|
1395
|
-
const bySession = localEntries.find((e) => e.
|
|
1396
|
-
if (bySession) return bySession;
|
|
1765
|
+
const bySession = localEntries.find((e) => e.coms_session_id === target);
|
|
1766
|
+
if (bySession) { log.debug("resolveTarget", target, "→", bySession.name, "by_session"); return bySession; }
|
|
1397
1767
|
// Name match — warn if ambiguous
|
|
1398
1768
|
const byName = localEntries.filter((e) => e.name === target);
|
|
1399
|
-
if (byName.length === 1) return byName[0];
|
|
1769
|
+
if (byName.length === 1) { log.debug("resolveTarget", target, "→", byName[0].name, "by_name"); return byName[0]; }
|
|
1400
1770
|
if (byName.length > 1) {
|
|
1401
1771
|
// Ambiguous — sort deterministically: freshest heartbeat first, session_id tiebreaker
|
|
1402
1772
|
byName.sort((a, b) => {
|
|
@@ -1405,32 +1775,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
1405
1775
|
const ha = isNaN(haRaw) ? 0 : haRaw;
|
|
1406
1776
|
const hb = isNaN(hbRaw) ? 0 : hbRaw;
|
|
1407
1777
|
if (hb !== ha) return hb - ha; // freshest first
|
|
1408
|
-
return a.
|
|
1778
|
+
return a.coms_session_id.localeCompare(b.coms_session_id); // stable tiebreak
|
|
1409
1779
|
});
|
|
1410
|
-
|
|
1411
|
-
pi.appendEntry("coms-log", {
|
|
1412
|
-
event: "ambiguous_target",
|
|
1413
|
-
target,
|
|
1414
|
-
matches: byName.length,
|
|
1415
|
-
selected_session: byName[0].session_id,
|
|
1416
|
-
selected_heartbeat: byName[0].heartbeat_at ?? null,
|
|
1417
|
-
});
|
|
1418
|
-
} catch { /* best-effort */ }
|
|
1780
|
+
log.warn("ambiguous_target", target, "matches", byName.length, "selected", byName[0].coms_session_id);
|
|
1419
1781
|
return byName[0];
|
|
1420
1782
|
}
|
|
1421
1783
|
}
|
|
1422
1784
|
// Fall back to scanning all projects by session_id.
|
|
1423
1785
|
for (const proj of listProjects()) {
|
|
1424
|
-
const entries = await pruneDeadEntries(proj);
|
|
1425
|
-
const bySession = entries.find((e) => e.
|
|
1786
|
+
const entries = await pruneDeadEntries(proj, process.cwd());
|
|
1787
|
+
const bySession = entries.find((e) => e.coms_session_id === target);
|
|
1426
1788
|
if (bySession) return bySession;
|
|
1427
1789
|
}
|
|
1428
1790
|
// Fall back to name match across projects.
|
|
1429
1791
|
for (const proj of listProjects()) {
|
|
1430
|
-
const entries = await pruneDeadEntries(proj);
|
|
1792
|
+
const entries = await pruneDeadEntries(proj, process.cwd());
|
|
1431
1793
|
const byName = entries.find((e) => e.name === target);
|
|
1432
1794
|
if (byName) return byName;
|
|
1433
1795
|
}
|
|
1796
|
+
log.debug("resolveTarget", target, "→ not_found");
|
|
1434
1797
|
return null;
|
|
1435
1798
|
}
|
|
1436
1799
|
|
|
@@ -1451,34 +1814,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
1451
1814
|
const projectFilter = params.project ?? identity?.project ?? "default";
|
|
1452
1815
|
const projects = projectFilter === "*" ? listProjects() : [projectFilter];
|
|
1453
1816
|
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
return {
|
|
1470
|
-
name: c.entry.name,
|
|
1471
|
-
session_id: c.entry.session_id,
|
|
1472
|
-
purpose: c.entry.purpose,
|
|
1473
|
-
model: c.entry.model,
|
|
1474
|
-
cwd: c.entry.cwd,
|
|
1475
|
-
project: c.project,
|
|
1476
|
-
alive: pong != null,
|
|
1477
|
-
context_used_pct: pong ? pong.context_used_pct : null,
|
|
1478
|
-
queue_depth: pong ? pong.queue_depth : 0,
|
|
1479
|
-
color: c.entry.color,
|
|
1480
|
-
};
|
|
1481
|
-
});
|
|
1817
|
+
// Read from peerCards — no pinging needed
|
|
1818
|
+
const agents = [...peerCards.entries()]
|
|
1819
|
+
.filter(([sid]) => identity && sid !== identity.coms_session_id)
|
|
1820
|
+
.map(([sid, card]) => ({
|
|
1821
|
+
name: card.name,
|
|
1822
|
+
session_id: sid,
|
|
1823
|
+
purpose: card.purpose,
|
|
1824
|
+
model: card.model,
|
|
1825
|
+
cwd: "",
|
|
1826
|
+
project: identity?.project ?? "default",
|
|
1827
|
+
alive: true,
|
|
1828
|
+
context_used_pct: card.context_used_pct,
|
|
1829
|
+
queue_depth: card.queue_depth ?? 0,
|
|
1830
|
+
color: card.color,
|
|
1831
|
+
}));
|
|
1482
1832
|
|
|
1483
1833
|
const lines = agents.length === 0
|
|
1484
1834
|
? "No peer agents found."
|
|
@@ -1536,6 +1886,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1536
1886
|
subject: Type.String({ description: "Brief task title" }),
|
|
1537
1887
|
description: Type.String({ description: "Detailed task description" }),
|
|
1538
1888
|
}), { description: "Optional structured tasks to create in the peer's task list." })),
|
|
1889
|
+
clearPrevious: Type.Optional(Type.Boolean({ description: "If true, clear all your prior pending messages on the receiver before delivering this one." })),
|
|
1539
1890
|
}),
|
|
1540
1891
|
async execute(_callId, params) {
|
|
1541
1892
|
if (!identity) {
|
|
@@ -1545,21 +1896,48 @@ export default function (pi: ExtensionAPI) {
|
|
|
1545
1896
|
if (!target) {
|
|
1546
1897
|
throw new Error(`coms: no live agent matching "${params.target}"`);
|
|
1547
1898
|
}
|
|
1899
|
+
// Block coms_send to the sender when processing their inbound — auto-capture handles the reply
|
|
1900
|
+
if (processingInbound && currentInbound && !currentInbound.fulfilled && target.coms_session_id === currentInbound.sender_session) {
|
|
1901
|
+
log.info("coms_send_blocked", "to", target.name, "auto-capture will reply");
|
|
1902
|
+
return {
|
|
1903
|
+
content: [{ type: "text" as const, text: `coms_send blocked: you are responding to ${target.name}'s inbound message. Your assistant text will be auto-captured and sent back. Do NOT use coms_send to reply.` }],
|
|
1904
|
+
details: { blocked: true, reason: "processing_inbound", target: target.name },
|
|
1905
|
+
};
|
|
1906
|
+
}
|
|
1907
|
+
// Clear sender's prior pending messages on the receiver before delivering
|
|
1908
|
+
if (params.clearPrevious) {
|
|
1909
|
+
try { await sendQueueManage(target, "clear"); } catch { /* best-effort */ }
|
|
1910
|
+
}
|
|
1548
1911
|
const hops = currentInbound ? currentInbound.hops + 1 : 0;
|
|
1549
1912
|
if (hops >= MAX_HOPS) {
|
|
1550
1913
|
throw new Error(`coms: hop limit reached (${hops} >= ${MAX_HOPS})`);
|
|
1551
1914
|
}
|
|
1552
1915
|
const msg_id = ulid();
|
|
1916
|
+
// Append sender's active tasks for this peer to the prompt
|
|
1917
|
+
let promptText = params.prompt;
|
|
1918
|
+
try {
|
|
1919
|
+
const { listTasksForSession } = require("../pitasks/index.js");
|
|
1920
|
+
const targetPiSessionId = target.pi_session_id || target.coms_session_id;
|
|
1921
|
+
const allTasks = listTasksForSession(targetPiSessionId, target.cwd) || [];
|
|
1922
|
+
const myTasks = allTasks.filter((t: any) =>
|
|
1923
|
+
(t.status === "pending" || t.status === "in_progress") &&
|
|
1924
|
+
t.createdBy?.session === identity!.coms_session_id
|
|
1925
|
+
);
|
|
1926
|
+
if (myTasks.length > 0) {
|
|
1927
|
+
const taskLines = myTasks.map((t: any) => `#${t.id} [${t.status}] ${t.subject}`).join("\n");
|
|
1928
|
+
promptText += `\n\n---\nReminder - Tasks assigned to you by ${identity!.name}:\n${taskLines}`;
|
|
1929
|
+
}
|
|
1930
|
+
} catch { /* task store not available — skip */ }
|
|
1553
1931
|
const env: PromptEnvelope = {
|
|
1554
1932
|
type: "prompt",
|
|
1555
1933
|
msg_id,
|
|
1556
|
-
sender_session: identity.
|
|
1934
|
+
sender_session: identity.coms_session_id,
|
|
1557
1935
|
sender_endpoint: identity.endpoint,
|
|
1558
1936
|
sender_name: identity.name,
|
|
1559
1937
|
sender_cwd: identity.cwd,
|
|
1560
1938
|
hops,
|
|
1561
1939
|
timestamp: nowIso(),
|
|
1562
|
-
prompt:
|
|
1940
|
+
prompt: promptText,
|
|
1563
1941
|
conversation_id: params.conversation_id ?? null,
|
|
1564
1942
|
response_schema: (params.response_schema as object | undefined) ?? null,
|
|
1565
1943
|
tasks: params.tasks ?? null,
|
|
@@ -1567,6 +1945,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
1567
1945
|
|
|
1568
1946
|
// Send the envelope synchronously and wait for the receiver's ack.
|
|
1569
1947
|
await sendEnvelope(target.endpoint, env);
|
|
1948
|
+
comsSendCalledThisTurn.add(target.coms_session_id);
|
|
1949
|
+
|
|
1950
|
+
// Auto-create tasks on the target's task store (sender-side, instant)
|
|
1951
|
+
if (params.tasks && params.tasks.length > 0 && target.coms_session_id) {
|
|
1952
|
+
try {
|
|
1953
|
+
const count = createComsInboundTasks(
|
|
1954
|
+
params.tasks,
|
|
1955
|
+
{ sender_session: identity.coms_session_id, sender_name: identity.name, sender_endpoint: identity.endpoint },
|
|
1956
|
+
target.pi_session_id || target.coms_session_id,
|
|
1957
|
+
target.cwd,
|
|
1958
|
+
);
|
|
1959
|
+
if (count > 0) {
|
|
1960
|
+
log.info("tasks_auto_created", msg_id, "to", target.name, "count", count);
|
|
1961
|
+
}
|
|
1962
|
+
} catch { /* best-effort */ }
|
|
1963
|
+
}
|
|
1570
1964
|
|
|
1571
1965
|
// Register a pending entry whose promise the receiver-side handleResponse
|
|
1572
1966
|
// (or the timeout below) will settle.
|
|
@@ -1588,27 +1982,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
1588
1982
|
if (entry.result) return;
|
|
1589
1983
|
entry.result = { error: "timeout" };
|
|
1590
1984
|
try { entry.resolve(entry.result); } catch { /* ignore */ }
|
|
1591
|
-
// Clean up timed-out entry
|
|
1985
|
+
// Clean up timed-out entry from both maps
|
|
1986
|
+
const pSet = pendingOutbound.get(target.name);
|
|
1987
|
+
if (pSet) { pSet.delete(msg_id); if (pSet.size === 0) pendingOutbound.delete(target.name); }
|
|
1592
1988
|
setTimeout(() => { pendingReplies.delete(msg_id); }, 60_000).unref();
|
|
1593
1989
|
}, TIMEOUT_MS);
|
|
1594
1990
|
// Don't keep the event loop alive solely for this timer.
|
|
1595
1991
|
try { (entry.timer as any).unref?.(); } catch { /* ignore */ }
|
|
1596
1992
|
pendingReplies.set(msg_id, entry);
|
|
1597
1993
|
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1994
|
+
log.debug("outbound_prompt", msg_id, "to", target.name, "hops", hops);
|
|
1995
|
+
|
|
1996
|
+
// Track pending outbound
|
|
1997
|
+
const targetKey = target.name;
|
|
1998
|
+
if (!pendingOutbound.has(targetKey)) pendingOutbound.set(targetKey, new Set());
|
|
1999
|
+
pendingOutbound.get(targetKey)!.add(msg_id);
|
|
2000
|
+
|
|
2001
|
+
// Peer status from peerCards
|
|
2002
|
+
const peerCard = peerCards.get(target.coms_session_id);
|
|
2003
|
+
const peer_queue_depth = peerCard?.queue_depth ?? 0;
|
|
2004
|
+
const peer_status = currentInbound && !currentInbound.fulfilled ? "processing" : "idle";
|
|
2005
|
+
|
|
2006
|
+
// Warn if pending messages exist
|
|
2007
|
+
const pendingCount = (pendingOutbound.get(targetKey)?.size ?? 1) - 1; // exclude this one
|
|
2008
|
+
const pendingWarn = pendingCount > 0 && !params.clearPrevious
|
|
2009
|
+
? `\n⚠️ You have ${pendingCount} pending message(s) to ${targetKey} that haven't been answered yet. Wait for responses or use clearPrevious:true to replace them.`
|
|
2010
|
+
: "";
|
|
1608
2011
|
|
|
1609
2012
|
return {
|
|
1610
|
-
content: [{ type: "text" as const, text: `coms_send → ${target.name}\nmsg_id ${msg_id}\nhops ${hops}` }],
|
|
1611
|
-
details: { msg_id, target: target.name, target_session: target.
|
|
2013
|
+
content: [{ type: "text" as const, text: `coms_send → ${target.name}\nmsg_id ${msg_id}\nhops ${hops}${pendingWarn}` }],
|
|
2014
|
+
details: { msg_id, target: target.name, target_session: target.coms_session_id, hops, peer_queue_depth, peer_status },
|
|
1612
2015
|
};
|
|
1613
2016
|
},
|
|
1614
2017
|
renderCall(args, theme) {
|
|
@@ -1685,23 +2088,634 @@ export default function (pi: ExtensionAPI) {
|
|
|
1685
2088
|
},
|
|
1686
2089
|
});
|
|
1687
2090
|
|
|
1688
|
-
// ━━
|
|
2091
|
+
// ━━ coms queue management tools ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1689
2092
|
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
2093
|
+
/** Send a queue_manage envelope to a peer. Only affects messages owned by this session. */
|
|
2094
|
+
async function sendQueueManage(target: { name: string; endpoint: string }, action: string, targetMsgId?: string, newContent?: string): Promise<{ affected?: number; error?: string | null }> {
|
|
2095
|
+
if (!identity) throw new Error("coms not initialised");
|
|
2096
|
+
const ack = await sendEnvelope(target.endpoint, {
|
|
2097
|
+
type: "queue_manage",
|
|
2098
|
+
msg_id: ulid(),
|
|
2099
|
+
sender_session: identity.coms_session_id,
|
|
2100
|
+
sender_endpoint: identity.endpoint,
|
|
2101
|
+
hops: 0,
|
|
2102
|
+
timestamp: nowIso(),
|
|
2103
|
+
action,
|
|
2104
|
+
target_msg_id: targetMsgId,
|
|
2105
|
+
new_content: newContent,
|
|
2106
|
+
sender_name: identity.name,
|
|
2107
|
+
});
|
|
2108
|
+
return { affected: ack?.affected, error: ack?.error };
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
pi.registerTool({
|
|
2112
|
+
name: "coms_queue_delete",
|
|
2113
|
+
label: "Coms Queue Delete",
|
|
2114
|
+
description: "Delete your message from a peer's queue. Only works on your own pending messages.",
|
|
2115
|
+
parameters: Type.Object({
|
|
2116
|
+
target: Type.String({ description: "Peer name or session_id." }),
|
|
2117
|
+
msg_id: Type.String({ description: "The msg_id to delete from the peer's queue." }),
|
|
2118
|
+
}),
|
|
2119
|
+
async execute(_callId, params) {
|
|
2120
|
+
const target = await resolveTarget(params.target);
|
|
2121
|
+
if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
|
|
2122
|
+
const result = await sendQueueManage(target, "delete", params.msg_id);
|
|
2123
|
+
if (result.error) throw new Error(`coms_queue_delete failed: ${result.error}`);
|
|
2124
|
+
return { content: [{ type: "text" as const, text: `coms_queue_delete → ${target.name}\nmsg_id: ${params.msg_id}` }] };
|
|
2125
|
+
},
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
pi.registerTool({
|
|
2129
|
+
name: "coms_queue_edit",
|
|
2130
|
+
label: "Coms Queue Edit",
|
|
2131
|
+
description: "Replace the content of your queued message on a peer. Only works on your own pending messages.",
|
|
2132
|
+
parameters: Type.Object({
|
|
2133
|
+
target: Type.String({ description: "Peer name or session_id." }),
|
|
2134
|
+
msg_id: Type.String({ description: "The msg_id to edit." }),
|
|
2135
|
+
new_content: Type.String({ description: "New prompt content to replace the existing message." }),
|
|
2136
|
+
}),
|
|
2137
|
+
async execute(_callId, params) {
|
|
2138
|
+
const target = await resolveTarget(params.target);
|
|
2139
|
+
if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
|
|
2140
|
+
const result = await sendQueueManage(target, "edit", params.msg_id, params.new_content);
|
|
2141
|
+
if (result.error) throw new Error(`coms_queue_edit failed: ${result.error}`);
|
|
2142
|
+
return { content: [{ type: "text" as const, text: `coms_queue_edit → ${target.name}\nmsg_id: ${params.msg_id}` }] };
|
|
2143
|
+
},
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
pi.registerTool({
|
|
2147
|
+
name: "coms_queue_clear",
|
|
2148
|
+
label: "Coms Queue Clear",
|
|
2149
|
+
description: "Delete ALL your pending messages from a peer's queue. Only affects your own messages.",
|
|
2150
|
+
parameters: Type.Object({
|
|
2151
|
+
target: Type.String({ description: "Peer name or session_id." }),
|
|
2152
|
+
}),
|
|
2153
|
+
async execute(_callId, params) {
|
|
2154
|
+
const target = await resolveTarget(params.target);
|
|
2155
|
+
if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
|
|
2156
|
+
await sendQueueManage(target, "clear");
|
|
2157
|
+
return { content: [{ type: "text" as const, text: `coms_queue_clear → ${target.name}` }] };
|
|
2158
|
+
},
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
pi.registerTool({
|
|
2162
|
+
name: "coms_queue_prioritize",
|
|
2163
|
+
label: "Coms Queue Prioritize",
|
|
2164
|
+
description: "Move your message to the front of a peer's queue. Only works on your own pending messages.",
|
|
2165
|
+
parameters: Type.Object({
|
|
2166
|
+
target: Type.String({ description: "Peer name or session_id." }),
|
|
2167
|
+
msg_id: Type.String({ description: "The msg_id to prioritize." }),
|
|
2168
|
+
}),
|
|
2169
|
+
async execute(_callId, params) {
|
|
2170
|
+
const target = await resolveTarget(params.target);
|
|
2171
|
+
if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
|
|
2172
|
+
const result = await sendQueueManage(target, "prioritize", params.msg_id);
|
|
2173
|
+
if (result.error) throw new Error(`coms_queue_prioritize failed: ${result.error}`);
|
|
2174
|
+
return { content: [{ type: "text" as const, text: `coms_queue_prioritize → ${target.name}\nmsg_id: ${params.msg_id}` }] };
|
|
2175
|
+
},
|
|
2176
|
+
});
|
|
2177
|
+
|
|
2178
|
+
pi.registerTool({
|
|
2179
|
+
name: "coms_tasks_create",
|
|
2180
|
+
label: "Coms Tasks Create",
|
|
2181
|
+
description: "Create multiple tasks on a peer's task list. Automatically adds a 'Report completion to sender' task blocked by all created tasks, and sends a message to the peer to start working.",
|
|
2182
|
+
parameters: Type.Object({
|
|
2183
|
+
target: Type.String({ description: "Peer name or session_id." }),
|
|
2184
|
+
tasks: Type.Array(Type.Object({
|
|
2185
|
+
subject: Type.String({ description: "Brief task title" }),
|
|
2186
|
+
description: Type.String({ description: "Detailed task description" }),
|
|
2187
|
+
}), { description: "Tasks to create on the peer" }),
|
|
2188
|
+
}),
|
|
2189
|
+
async execute(_callId, params) {
|
|
2190
|
+
if (!identity) throw new Error("coms not initialised");
|
|
2191
|
+
if (!params.tasks || params.tasks.length === 0) throw new Error("coms_tasks_create requires at least one task");
|
|
2192
|
+
const target = await resolveTarget(params.target);
|
|
2193
|
+
if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
|
|
2194
|
+
|
|
2195
|
+
const createdBy = {
|
|
2196
|
+
type: "coms" as const,
|
|
2197
|
+
origin: identity.name,
|
|
2198
|
+
session: identity.coms_session_id,
|
|
2199
|
+
project: process.cwd(),
|
|
2200
|
+
};
|
|
2201
|
+
const meta: Record<string, any> = {};
|
|
2202
|
+
if (identity.endpoint) meta.sender_endpoint = identity.endpoint;
|
|
2203
|
+
|
|
2204
|
+
try {
|
|
2205
|
+
const { createTasksForSession, listTasksForSession, updateTaskForSession } = require("../pitasks/index.js");
|
|
2206
|
+
const targetPiSessionId = target.pi_session_id || target.coms_session_id;
|
|
2207
|
+
|
|
2208
|
+
// Create all tasks in one batch
|
|
2209
|
+
const taskDefs = params.tasks.map(t => ({
|
|
2210
|
+
subject: t.subject,
|
|
2211
|
+
description: t.description,
|
|
2212
|
+
createdBy,
|
|
2213
|
+
metadata: { ...meta },
|
|
2214
|
+
}));
|
|
2215
|
+
const created = createTasksForSession(targetPiSessionId, taskDefs, target.cwd);
|
|
2216
|
+
const createdIds = created.map((t: any) => t.id);
|
|
2217
|
+
|
|
2218
|
+
// Check if "Report completion to sender" already exists from this sender
|
|
2219
|
+
const allTasks = listTasksForSession(targetPiSessionId, target.cwd) || [];
|
|
2220
|
+
const existingReport = allTasks.find((t: any) =>
|
|
2221
|
+
t.createdBy?.session === identity!.coms_session_id &&
|
|
2222
|
+
t.subject === "Report completion to sender" &&
|
|
2223
|
+
t.status !== "completed" && t.status !== "deleted"
|
|
2224
|
+
);
|
|
2225
|
+
|
|
2226
|
+
if (existingReport) {
|
|
2227
|
+
// Update existing report task's blockedBy to include new task IDs
|
|
2228
|
+
const newBlockedBy = [...new Set([...(existingReport.blockedBy || []), ...createdIds])];
|
|
2229
|
+
updateTaskForSession(targetPiSessionId, existingReport.id, { blockedBy: newBlockedBy }, target.cwd);
|
|
2230
|
+
} else {
|
|
2231
|
+
// Create report task blocked by all new tasks
|
|
2232
|
+
createTasksForSession(targetPiSessionId, [{
|
|
2233
|
+
subject: "Report completion to sender",
|
|
2234
|
+
description: `When all tasks are done, use coms_send to report back to ${identity!.name}.`,
|
|
2235
|
+
createdBy,
|
|
2236
|
+
blockedBy: createdIds,
|
|
2237
|
+
metadata: { ...meta },
|
|
2238
|
+
}], target.cwd);
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
log.info("coms_tasks_create", created.length, "tasks on", target.name);
|
|
2242
|
+
|
|
2243
|
+
// Auto-send message to peer to start working
|
|
2244
|
+
try {
|
|
2245
|
+
const msg_id = ulid();
|
|
2246
|
+
const env: PromptEnvelope = {
|
|
2247
|
+
type: "prompt",
|
|
2248
|
+
msg_id,
|
|
2249
|
+
sender_session: identity.coms_session_id,
|
|
2250
|
+
sender_endpoint: identity.endpoint,
|
|
2251
|
+
sender_name: identity.name,
|
|
2252
|
+
sender_cwd: identity.cwd,
|
|
2253
|
+
hops: 0,
|
|
2254
|
+
timestamp: nowIso(),
|
|
2255
|
+
prompt: `You have tasks to work on. Check TaskList and start.`,
|
|
2256
|
+
conversation_id: null,
|
|
2257
|
+
response_schema: null,
|
|
2258
|
+
tasks: null,
|
|
2259
|
+
};
|
|
2260
|
+
await sendEnvelope(target.endpoint, env);
|
|
2261
|
+
pendingReplies.set(msg_id, { target_session: target.coms_session_id, target_name: target.name, sent_at: Date.now() });
|
|
2262
|
+
} catch (e: any) {
|
|
2263
|
+
log.debug("auto-message after task create failed:", e?.message);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
const lines = created.map((t: any) => `#${t.id}: ${t.subject}`);
|
|
2267
|
+
return {
|
|
2268
|
+
content: [{ type: "text" as const, text: `Created ${created.length} tasks on ${target.name}:\n${lines.join("\n")}` }],
|
|
2269
|
+
details: { target: target.name, count: created.length, taskIds: createdIds },
|
|
2270
|
+
};
|
|
2271
|
+
} catch (e: any) {
|
|
2272
|
+
throw new Error(`Failed to create tasks on ${target.name}: ${e?.message}`);
|
|
2273
|
+
}
|
|
2274
|
+
},
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
pi.registerTool({
|
|
2278
|
+
name: "coms_task_delete",
|
|
2279
|
+
label: "Coms Task Delete",
|
|
2280
|
+
description: "Delete a task from a peer's task list. Only works on tasks you created (createdBy match).",
|
|
2281
|
+
parameters: Type.Object({
|
|
2282
|
+
target: Type.String({ description: "Peer name or session_id." }),
|
|
2283
|
+
task_id: Type.String({ description: "Task ID to delete." }),
|
|
2284
|
+
}),
|
|
2285
|
+
async execute(_callId, params) {
|
|
2286
|
+
if (!identity) throw new Error("coms not initialised");
|
|
2287
|
+
const target = await resolveTarget(params.target);
|
|
2288
|
+
if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
|
|
2289
|
+
|
|
2290
|
+
try {
|
|
2291
|
+
const { getTaskForSession, deleteTaskForSession } = require("../pitasks/index.js");
|
|
2292
|
+
const targetPiSessionId = target.pi_session_id || target.coms_session_id;
|
|
2293
|
+
const task = getTaskForSession(targetPiSessionId, params.task_id, target.cwd);
|
|
2294
|
+
if (!task) throw new Error(`Task #${params.task_id} not found`);
|
|
2295
|
+
if (task.createdBy?.session !== identity.coms_session_id) {
|
|
2296
|
+
throw new Error(`Ownership denied — can only delete tasks you created`);
|
|
2297
|
+
}
|
|
2298
|
+
if (task.status === "in_progress") {
|
|
2299
|
+
return {
|
|
2300
|
+
content: [{ type: "text" as const, text: `Cannot delete task #${params.task_id} — task is in_progress. Wait for the peer to finish.` }],
|
|
2301
|
+
details: { blocked: true, reason: "in_progress" },
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
deleteTaskForSession(targetPiSessionId, params.task_id, target.cwd);
|
|
2305
|
+
log.info("coms_task_delete", params.task_id, "on", target.name);
|
|
2306
|
+
} catch (e: any) {
|
|
2307
|
+
throw new Error(`Failed to delete task on ${target.name}: ${e?.message}`);
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
return {
|
|
2311
|
+
content: [{ type: "text" as const, text: `Task #${params.task_id} deleted from ${target.name}` }],
|
|
2312
|
+
details: { target: target.name, task_id: params.task_id },
|
|
2313
|
+
};
|
|
2314
|
+
},
|
|
2315
|
+
});
|
|
2316
|
+
|
|
2317
|
+
pi.registerTool({
|
|
2318
|
+
name: "coms_task_list",
|
|
2319
|
+
label: "Coms Task List",
|
|
2320
|
+
description: "List tasks on a peer's task list without sending a message.",
|
|
2321
|
+
parameters: Type.Object({
|
|
2322
|
+
target: Type.String({ description: "Peer name or session_id." }),
|
|
2323
|
+
}),
|
|
2324
|
+
async execute(_callId, params) {
|
|
2325
|
+
if (!identity) throw new Error("coms not initialised");
|
|
2326
|
+
const target = await resolveTarget(params.target);
|
|
2327
|
+
if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
|
|
2328
|
+
|
|
2329
|
+
try {
|
|
2330
|
+
const { listTasksForSession } = require("../pitasks/index.js");
|
|
2331
|
+
const targetPiSessionId = target.pi_session_id || target.coms_session_id;
|
|
2332
|
+
const tasks = listTasksForSession(targetPiSessionId, target.cwd);
|
|
2333
|
+
log.debug("coms_task_list", target.name, "tasks", tasks?.length ?? 0);
|
|
2334
|
+
|
|
2335
|
+
if (!tasks || tasks.length === 0) {
|
|
2336
|
+
return {
|
|
2337
|
+
content: [{ type: "text" as const, text: "No tasks found" }],
|
|
2338
|
+
details: undefined,
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
|
|
2342
|
+
const sorted = [...tasks].sort((a: any, b: any) => {
|
|
2343
|
+
const statusOrder: Record<string, number> = { pending: 0, in_progress: 1, completed: 2 };
|
|
2344
|
+
const so = (statusOrder[a.status] ?? 0) - (statusOrder[b.status] ?? 0);
|
|
2345
|
+
if (so !== 0) return so;
|
|
2346
|
+
return Number(a.id) - Number(b.id);
|
|
2347
|
+
});
|
|
2348
|
+
const lines = sorted.map((t: any) => {
|
|
2349
|
+
let line = `#${t.id} [${t.status}] ${t.subject}`;
|
|
2350
|
+
if (t.owner) line += ` (${t.owner})`;
|
|
2351
|
+
if (t.blockedBy?.length > 0) {
|
|
2352
|
+
const openBlockers = t.blockedBy.filter((bid: string) => {
|
|
2353
|
+
const blocker = tasks.find((bt: any) => bt.id === bid);
|
|
2354
|
+
return blocker && blocker.status !== "completed";
|
|
2355
|
+
});
|
|
2356
|
+
if (openBlockers.length > 0) line += ` [blocked by ${openBlockers.map((id: string) => "#" + id).join(", ")}]`;
|
|
2357
|
+
}
|
|
2358
|
+
return line;
|
|
2359
|
+
});
|
|
2360
|
+
return {
|
|
2361
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
2362
|
+
details: undefined,
|
|
2363
|
+
};
|
|
2364
|
+
} catch (e: any) {
|
|
2365
|
+
throw new Error(`Failed to list tasks on ${target.name}: ${e?.message}`);
|
|
2366
|
+
}
|
|
2367
|
+
},
|
|
2368
|
+
});
|
|
2369
|
+
|
|
2370
|
+
pi.registerTool({
|
|
2371
|
+
name: "coms_task_get",
|
|
2372
|
+
label: "Coms Task Get",
|
|
2373
|
+
description: "Get a specific task from a peer's task list without sending a message.",
|
|
2374
|
+
parameters: Type.Object({
|
|
2375
|
+
target: Type.String({ description: "Peer name or session_id." }),
|
|
2376
|
+
task_id: Type.String({ description: "Task ID to retrieve." }),
|
|
2377
|
+
}),
|
|
2378
|
+
async execute(_callId, params) {
|
|
2379
|
+
if (!identity) throw new Error("coms not initialised");
|
|
2380
|
+
const target = await resolveTarget(params.target);
|
|
2381
|
+
if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
|
|
2382
|
+
|
|
2383
|
+
try {
|
|
2384
|
+
const { getTaskForSession } = require("../pitasks/index.js");
|
|
2385
|
+
const targetPiSessionId = target.pi_session_id || target.coms_session_id;
|
|
2386
|
+
const task = getTaskForSession(targetPiSessionId, params.task_id, target.cwd);
|
|
2387
|
+
if (!task) throw new Error(`Task #${params.task_id} not found on ${target.name}`);
|
|
2388
|
+
|
|
2389
|
+
log.debug("coms_task_get", target.name, "task", params.task_id);
|
|
2390
|
+
|
|
2391
|
+
// Match local TaskGet format
|
|
2392
|
+
const desc = task.description || "(no description)";
|
|
2393
|
+
const lines: string[] = [`Task #${task.id}: ${task.subject}`, `Status: ${task.status}`];
|
|
2394
|
+
if (task.owner) lines.push(`Owner: ${task.owner}`);
|
|
2395
|
+
lines.push(`Description: ${desc}`);
|
|
2396
|
+
if (task.blockedBy?.length > 0) {
|
|
2397
|
+
const { listTasksForSession } = require("../pitasks/index.js");
|
|
2398
|
+
const allTasks = listTasksForSession(targetPiSessionId, target.cwd);
|
|
2399
|
+
const openBlockers = task.blockedBy.filter((bid: string) => {
|
|
2400
|
+
const blocker = allTasks.find((bt: any) => bt.id === bid);
|
|
2401
|
+
return blocker && blocker.status !== "completed";
|
|
2402
|
+
});
|
|
2403
|
+
if (openBlockers.length > 0) lines.push(`Blocked by: ${openBlockers.map((id: string) => "#" + id).join(", ")}`);
|
|
2404
|
+
}
|
|
2405
|
+
if (task.blocks?.length > 0) lines.push(`Blocks: ${task.blocks.map((id: string) => "#" + id).join(", ")}`);
|
|
2406
|
+
|
|
2407
|
+
return {
|
|
2408
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
2409
|
+
details: undefined,
|
|
2410
|
+
};
|
|
2411
|
+
} catch (e: any) {
|
|
2412
|
+
throw new Error(`Failed to get task from ${target.name}: ${e?.message}`);
|
|
2413
|
+
}
|
|
2414
|
+
},
|
|
2415
|
+
});
|
|
2416
|
+
|
|
2417
|
+
pi.registerTool({
|
|
2418
|
+
name: "coms_task_update",
|
|
2419
|
+
label: "Coms Task Update",
|
|
2420
|
+
description: "Update a task on a peer's task list. Only works on tasks you created (createdBy match).",
|
|
2421
|
+
parameters: Type.Object({
|
|
2422
|
+
target: Type.String({ description: "Peer name or session_id." }),
|
|
2423
|
+
task_id: Type.String({ description: "The ID of the task to update" }),
|
|
2424
|
+
status: Type.Optional(Type.Unsafe({ type: "string", enum: ["pending", "in_progress", "completed", "deleted"], description: "New status for the task" })),
|
|
2425
|
+
subject: Type.Optional(Type.String({ description: "New subject for the task" })),
|
|
2426
|
+
description: Type.Optional(Type.String({ description: "New description for the task" })),
|
|
2427
|
+
activeForm: Type.Optional(Type.String({ description: "Present continuous form shown in spinner when in_progress" })),
|
|
2428
|
+
owner: Type.Optional(Type.String({ description: "New owner for the task" })),
|
|
2429
|
+
metadata: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Metadata keys to merge into the task. Set a key to null to delete it." })),
|
|
2430
|
+
addBlocks: Type.Optional(Type.Array(Type.String(), { description: "Task IDs that this task blocks" })),
|
|
2431
|
+
addBlockedBy: Type.Optional(Type.Array(Type.String(), { description: "Task IDs that block this task" })),
|
|
2432
|
+
}),
|
|
2433
|
+
async execute(_callId, params) {
|
|
2434
|
+
if (!identity) throw new Error("coms not initialised");
|
|
2435
|
+
const target = await resolveTarget(params.target);
|
|
2436
|
+
if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
|
|
2437
|
+
|
|
2438
|
+
try {
|
|
2439
|
+
const { getTaskForSession, updateTaskForSession } = require("../pitasks/index.js");
|
|
2440
|
+
const targetPiSessionId = target.pi_session_id || target.coms_session_id;
|
|
2441
|
+
const task = getTaskForSession(targetPiSessionId, params.task_id, target.cwd);
|
|
2442
|
+
if (!task) throw new Error(`Task #${params.task_id} not found on ${target.name}`);
|
|
2443
|
+
if (task.createdBy?.session !== identity.coms_session_id) {
|
|
2444
|
+
throw new Error(`Ownership denied — can only update tasks you created`);
|
|
2445
|
+
}
|
|
2446
|
+
if (task.status === "in_progress") {
|
|
2447
|
+
return {
|
|
2448
|
+
content: [{ type: "text" as const, text: `Cannot update task #${params.task_id} — task is in_progress. Wait for the peer to finish.` }],
|
|
2449
|
+
details: { blocked: true, reason: "in_progress" },
|
|
2450
|
+
};
|
|
2451
|
+
}
|
|
2452
|
+
|
|
2453
|
+
const { target: _t, task_id: _tid, ...fields } = params;
|
|
2454
|
+
const result = updateTaskForSession(targetPiSessionId, params.task_id, fields, target.cwd);
|
|
2455
|
+
log.info("coms_task_update", params.task_id, "on", target.name, "fields", Object.keys(fields).join(","));
|
|
2456
|
+
|
|
2457
|
+
const changedFields = result?.changedFields ?? [];
|
|
2458
|
+
const warnings = result?.warnings ?? [];
|
|
2459
|
+
let msg = `Updated task #${params.task_id} ${changedFields.join(", ")}`;
|
|
2460
|
+
if (warnings.length > 0) msg += ` (warning: ${warnings.join("; ")})`;
|
|
2461
|
+
return {
|
|
2462
|
+
content: [{ type: "text" as const, text: msg }],
|
|
2463
|
+
details: undefined,
|
|
2464
|
+
};
|
|
2465
|
+
} catch (e: any) {
|
|
2466
|
+
throw new Error(`Failed to update task on ${target.name}: ${e?.message}`);
|
|
2467
|
+
}
|
|
2468
|
+
},
|
|
2469
|
+
});
|
|
2470
|
+
|
|
2471
|
+
// ━━ Track agent running state for mixed-turn detection (#731) ━━━━━━━━━
|
|
2472
|
+
// Only mark as "user turn" if NO coms inbound is active (processingInbound false).
|
|
2473
|
+
// When processingInbound is true, the agent is running a coms followUp — not a user turn.
|
|
2474
|
+
pi.on("agent_start" as any, () => {
|
|
2475
|
+
agentRunningUserTurn = !processingInbound;
|
|
2476
|
+
comsSendCalledThisTurn.clear();
|
|
2477
|
+
userMessageDuringInbound = false;
|
|
2478
|
+
log.debug("agent_start", "userTurn", agentRunningUserTurn, "processingInbound", processingInbound, "currentInbound", currentInbound?.msg_id ?? "null");
|
|
2479
|
+
});
|
|
2480
|
+
|
|
2481
|
+
// ━━ message_start: detect a real user message interrupting an inbound turn (#741) ━━
|
|
2482
|
+
// A message_start with role "user" while processingInbound means a genuine user
|
|
2483
|
+
// message landed during a coms inbound turn — auto-capturing that assistant text
|
|
2484
|
+
// would leak user-directed content to the peer. Flag it for mixed-turn handling.
|
|
2485
|
+
pi.on("message_start" as any, (event: any) => {
|
|
2486
|
+
if (shuttingDown || !identity) return;
|
|
2487
|
+
if (isUserMessageDuringInbound(processingInbound, event?.message?.role)) {
|
|
2488
|
+
userMessageDuringInbound = true;
|
|
2489
|
+
log.info("user_message_during_inbound", currentInbound?.msg_id ?? "null");
|
|
2490
|
+
}
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
// ━━ agent_end: capture turn output and dispatch response back ━━━━━━━━
|
|
2494
|
+
|
|
2495
|
+
pi.on("agent_end", async (_event, ctx) => {
|
|
2496
|
+
log.info("agent_end_enter", "currentInbound", currentInbound?.msg_id ?? "null", "identity", !!identity, "processingInbound", processingInbound);
|
|
2497
|
+
if (!currentInbound || !identity) {
|
|
2498
|
+
// Drain any orphaned inbounds — can't send responses without identity,
|
|
2499
|
+
// but must clear the queue to prevent permanent buildup
|
|
2500
|
+
if (!identity) {
|
|
1695
2501
|
for (const [id, ib] of inboundQueue) {
|
|
1696
2502
|
ib.fulfilled = true;
|
|
1697
2503
|
inboundQueue.delete(id);
|
|
1698
2504
|
}
|
|
1699
2505
|
currentInbound = null;
|
|
1700
2506
|
}
|
|
2507
|
+
agentRunningUserTurn = false;
|
|
2508
|
+
inboundSetDuringUserTurn = false;
|
|
2509
|
+
userMessageDuringInbound = false;
|
|
1701
2510
|
processingInbound = false;
|
|
2511
|
+
log.debug("agent_end_early", "currentInbound", currentInbound?.msg_id ?? "null", "processingInbound", processingInbound);
|
|
1702
2512
|
return;
|
|
1703
2513
|
}
|
|
1704
2514
|
const inbound = currentInbound;
|
|
2515
|
+
currentInbound = null;
|
|
2516
|
+
agentRunningUserTurn = false;
|
|
2517
|
+
|
|
2518
|
+
// Skip auto-capture if LLM already replied via coms_send to the same peer
|
|
2519
|
+
if (comsSendCalledThisTurn.has(inbound.sender_session)) {
|
|
2520
|
+
log.info("agent_end_skip_autocapture", inbound.msg_id, "coms_send called this turn", "sender", inbound.sender_name);
|
|
2521
|
+
inbound.fulfilled = true;
|
|
2522
|
+
inboundQueue.delete(inbound.msg_id);
|
|
2523
|
+
maybeRefreshWidget();
|
|
2524
|
+
// Continue FIFO drain
|
|
2525
|
+
const next = [...inboundQueue.values()].find((i) => !i.fulfilled);
|
|
2526
|
+
if (next) {
|
|
2527
|
+
currentInbound = next;
|
|
2528
|
+
try {
|
|
2529
|
+
pi.sendMessage(
|
|
2530
|
+
{
|
|
2531
|
+
customType: formatComsInboundType(next.sender_name, sanitizeComsName(identity?.name ?? "?"), next.sender_cwd),
|
|
2532
|
+
content: buildInboundContent("", next.prompt, next.tasks, next.sender_name, next.sender_cwd),
|
|
2533
|
+
display: true,
|
|
2534
|
+
details: {
|
|
2535
|
+
msg_id: next.msg_id,
|
|
2536
|
+
sender_session: next.sender_session,
|
|
2537
|
+
response_schema: next.response_schema ?? null,
|
|
2538
|
+
},
|
|
2539
|
+
},
|
|
2540
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
2541
|
+
);
|
|
2542
|
+
} catch {
|
|
2543
|
+
currentInbound = null;
|
|
2544
|
+
processingInbound = false;
|
|
2545
|
+
}
|
|
2546
|
+
} else {
|
|
2547
|
+
processingInbound = false;
|
|
2548
|
+
}
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
// ── Mixed-turn detection (#731) ──────────────────────────────────────
|
|
2553
|
+
// If the inbound was set while the agent was running a user turn
|
|
2554
|
+
// (not a coms followUp), the assistant's response is for the user.
|
|
2555
|
+
// Re-inject the inbound for a dedicated turn.
|
|
2556
|
+
const hasMixedTurn = computeMixedTurn(inboundSetDuringUserTurn, userMessageDuringInbound);
|
|
2557
|
+
if (userMessageDuringInbound && !inboundSetDuringUserTurn) {
|
|
2558
|
+
log.info("mixed_turn_user_interrupt", inbound.msg_id);
|
|
2559
|
+
}
|
|
2560
|
+
inboundSetDuringUserTurn = false;
|
|
2561
|
+
userMessageDuringInbound = false;
|
|
2562
|
+
|
|
2563
|
+
const MAX_MIXED_RETRIES = 2;
|
|
2564
|
+
if (hasMixedTurn) {
|
|
2565
|
+
const retries = inbound.mixedTurnRetries ?? 0;
|
|
2566
|
+
if (retries < MAX_MIXED_RETRIES) {
|
|
2567
|
+
// Re-inject the inbound for a clean dedicated turn.
|
|
2568
|
+
inbound.mixedTurnRetries = retries + 1;
|
|
2569
|
+
log.info("mixed_turn_reinject", inbound.msg_id, "retry", inbound.mixedTurnRetries, "from", inbound.sender_name);
|
|
2570
|
+
try {
|
|
2571
|
+
pi.sendMessage(
|
|
2572
|
+
{
|
|
2573
|
+
customType: formatComsInboundType(inbound.sender_name, sanitizeComsName(identity?.name ?? "?"), inbound.sender_cwd),
|
|
2574
|
+
content: buildInboundContent(
|
|
2575
|
+
`[NOTE: your previous reply was not captured because a user message arrived in the same turn. ` +
|
|
2576
|
+
`This is a re-injection — please reply to this peer message now.]`,
|
|
2577
|
+
inbound.prompt, inbound.tasks ?? null, inbound.sender_name, inbound.sender_cwd),
|
|
2578
|
+
display: true,
|
|
2579
|
+
details: {
|
|
2580
|
+
msg_id: inbound.msg_id,
|
|
2581
|
+
sender_session: inbound.sender_session,
|
|
2582
|
+
response_schema: inbound.response_schema ?? null,
|
|
2583
|
+
},
|
|
2584
|
+
},
|
|
2585
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
2586
|
+
);
|
|
2587
|
+
} catch (err: any) {
|
|
2588
|
+
// Re-injection failed — send error response to peer, then FIFO drain remaining
|
|
2589
|
+
log.error("mixed_turn_reinject_failed", inbound.msg_id, err?.message);
|
|
2590
|
+
inbound.fulfilled = true;
|
|
2591
|
+
inboundQueue.delete(inbound.msg_id);
|
|
2592
|
+
maybeRefreshWidget();
|
|
2593
|
+
currentInbound = null;
|
|
2594
|
+
const queuedIds: string[] = [];
|
|
2595
|
+
for (const i of inboundQueue.values()) if (!i.fulfilled && i !== inbound) queuedIds.push(i.msg_id);
|
|
2596
|
+
try {
|
|
2597
|
+
await sendEnvelope(inbound.sender_endpoint, {
|
|
2598
|
+
type: "response", msg_id: inbound.msg_id,
|
|
2599
|
+
sender_session: identity.coms_session_id, sender_endpoint: identity.endpoint,
|
|
2600
|
+
hops: 0, timestamp: nowIso(),
|
|
2601
|
+
response: null, error: "mixed_turn_conflict",
|
|
2602
|
+
queued_msg_ids: queuedIds,
|
|
2603
|
+
});
|
|
2604
|
+
} catch { /* best-effort */ }
|
|
2605
|
+
// FIFO drain: inject next or drain all remaining with error responses
|
|
2606
|
+
const next = [...inboundQueue.values()].find((i) => !i.fulfilled);
|
|
2607
|
+
if (next) {
|
|
2608
|
+
currentInbound = next;
|
|
2609
|
+
try {
|
|
2610
|
+
pi.sendMessage(
|
|
2611
|
+
{
|
|
2612
|
+
customType: formatComsInboundType(next.sender_name, sanitizeComsName(identity?.name ?? "?"), next.sender_cwd),
|
|
2613
|
+
content: buildInboundContent("", next.prompt, next.tasks, next.sender_name, next.sender_cwd),
|
|
2614
|
+
display: true,
|
|
2615
|
+
details: {
|
|
2616
|
+
msg_id: next.msg_id,
|
|
2617
|
+
sender_session: next.sender_session,
|
|
2618
|
+
response_schema: next.response_schema ?? null,
|
|
2619
|
+
},
|
|
2620
|
+
},
|
|
2621
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
2622
|
+
);
|
|
2623
|
+
} catch (drainErr: any) {
|
|
2624
|
+
log.error("fifo_drain_failed", next.msg_id, drainErr?.message);
|
|
2625
|
+
const remaining = [next, ...[...inboundQueue.values()].filter(i => !i.fulfilled && i.msg_id !== next.msg_id)];
|
|
2626
|
+
for (let idx = 0; idx < remaining.length; idx++) {
|
|
2627
|
+
const orphan = remaining[idx];
|
|
2628
|
+
try {
|
|
2629
|
+
await sendEnvelope(orphan.sender_endpoint, {
|
|
2630
|
+
type: "response", msg_id: orphan.msg_id,
|
|
2631
|
+
sender_session: identity!.coms_session_id, sender_endpoint: identity!.endpoint,
|
|
2632
|
+
hops: 0, timestamp: nowIso(),
|
|
2633
|
+
response: null, error: "injection_failed",
|
|
2634
|
+
queued_msg_ids: remaining.slice(idx + 1).map(r => r.msg_id),
|
|
2635
|
+
});
|
|
2636
|
+
} catch (e: any) {
|
|
2637
|
+
log.error("orphan_cleanup_failed", orphan.msg_id, e?.message);
|
|
2638
|
+
}
|
|
2639
|
+
orphan.fulfilled = true;
|
|
2640
|
+
inboundQueue.delete(orphan.msg_id);
|
|
2641
|
+
}
|
|
2642
|
+
currentInbound = null;
|
|
2643
|
+
processingInbound = false;
|
|
2644
|
+
}
|
|
2645
|
+
} else {
|
|
2646
|
+
processingInbound = false;
|
|
2647
|
+
}
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2650
|
+
// Re-injection succeeded — restore currentInbound for next agent_end
|
|
2651
|
+
currentInbound = inbound;
|
|
2652
|
+
return;
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
// Max retries exhausted — send error response to peer.
|
|
2656
|
+
log.warn("mixed_turn_exhausted", inbound.msg_id, "from", inbound.sender_name, "retries", retries);
|
|
2657
|
+
const queuedIds: string[] = [];
|
|
2658
|
+
for (const i of inboundQueue.values()) if (!i.fulfilled && i !== inbound) queuedIds.push(i.msg_id);
|
|
2659
|
+
try {
|
|
2660
|
+
await sendEnvelope(inbound.sender_endpoint, {
|
|
2661
|
+
type: "response", msg_id: inbound.msg_id,
|
|
2662
|
+
sender_session: identity.coms_session_id, sender_endpoint: identity.endpoint,
|
|
2663
|
+
hops: 0, timestamp: nowIso(),
|
|
2664
|
+
response: null, error: "mixed_turn_conflict",
|
|
2665
|
+
queued_msg_ids: queuedIds,
|
|
2666
|
+
});
|
|
2667
|
+
} catch { /* best-effort */ }
|
|
2668
|
+
inbound.fulfilled = true;
|
|
2669
|
+
inboundQueue.delete(inbound.msg_id);
|
|
2670
|
+
maybeRefreshWidget();
|
|
2671
|
+
currentInbound = null;
|
|
2672
|
+
|
|
2673
|
+
// Continue FIFO drain after mixed-turn exhaustion
|
|
2674
|
+
const next = [...inboundQueue.values()].find((i) => !i.fulfilled);
|
|
2675
|
+
if (next) {
|
|
2676
|
+
currentInbound = next;
|
|
2677
|
+
try {
|
|
2678
|
+
pi.sendMessage(
|
|
2679
|
+
{
|
|
2680
|
+
customType: formatComsInboundType(next.sender_name, sanitizeComsName(identity?.name ?? "?"), next.sender_cwd),
|
|
2681
|
+
content: buildInboundContent("", next.prompt, next.tasks, next.sender_name, next.sender_cwd),
|
|
2682
|
+
display: true,
|
|
2683
|
+
details: {
|
|
2684
|
+
msg_id: next.msg_id,
|
|
2685
|
+
sender_session: next.sender_session,
|
|
2686
|
+
response_schema: next.response_schema ?? null,
|
|
2687
|
+
},
|
|
2688
|
+
},
|
|
2689
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
2690
|
+
);
|
|
2691
|
+
} catch (drainErr: any) {
|
|
2692
|
+
log.error("fifo_drain_failed", next.msg_id, drainErr?.message);
|
|
2693
|
+
const remaining = [next, ...[...inboundQueue.values()].filter(i => !i.fulfilled && i.msg_id !== next.msg_id)];
|
|
2694
|
+
for (let idx = 0; idx < remaining.length; idx++) {
|
|
2695
|
+
const orphan = remaining[idx];
|
|
2696
|
+
try {
|
|
2697
|
+
await sendEnvelope(orphan.sender_endpoint, {
|
|
2698
|
+
type: "response", msg_id: orphan.msg_id,
|
|
2699
|
+
sender_session: identity!.coms_session_id, sender_endpoint: identity!.endpoint,
|
|
2700
|
+
hops: 0, timestamp: nowIso(),
|
|
2701
|
+
response: null, error: "injection_failed",
|
|
2702
|
+
queued_msg_ids: remaining.slice(idx + 1).map(r => r.msg_id),
|
|
2703
|
+
});
|
|
2704
|
+
} catch (e: any) {
|
|
2705
|
+
log.error("orphan_cleanup_failed", orphan.msg_id, e?.message);
|
|
2706
|
+
}
|
|
2707
|
+
orphan.fulfilled = true;
|
|
2708
|
+
inboundQueue.delete(orphan.msg_id);
|
|
2709
|
+
}
|
|
2710
|
+
currentInbound = null;
|
|
2711
|
+
processingInbound = false;
|
|
2712
|
+
}
|
|
2713
|
+
} else {
|
|
2714
|
+
processingInbound = false;
|
|
2715
|
+
}
|
|
2716
|
+
return;
|
|
2717
|
+
}
|
|
2718
|
+
// ── End mixed-turn detection ─────────────────────────────────────────
|
|
1705
2719
|
|
|
1706
2720
|
// Walk the session branch for the most recent assistant message text.
|
|
1707
2721
|
let lastAssistantText = "";
|
|
@@ -1719,7 +2733,83 @@ export default function (pi: ExtensionAPI) {
|
|
|
1719
2733
|
}
|
|
1720
2734
|
}
|
|
1721
2735
|
|
|
1722
|
-
|
|
2736
|
+
// Check if sender has more pending messages — accumulate response if so
|
|
2737
|
+
const bundledIds = new Set(bundledInbounds.map(bi => bi.msg_id));
|
|
2738
|
+
const morePending = [...inboundQueue.values()].some(i =>
|
|
2739
|
+
!i.fulfilled && i.sender_session === inbound.sender_session && i.msg_id !== inbound.msg_id
|
|
2740
|
+
&& !bundledIds.has(i.msg_id)
|
|
2741
|
+
);
|
|
2742
|
+
|
|
2743
|
+
if (morePending) {
|
|
2744
|
+
// Accumulate response, fulfill silently, let FIFO drain continue with next from same sender
|
|
2745
|
+
if (!accumulatedResponses.has(inbound.sender_session)) {
|
|
2746
|
+
accumulatedResponses.set(inbound.sender_session, []);
|
|
2747
|
+
}
|
|
2748
|
+
accumulatedResponses.get(inbound.sender_session)!.push(lastAssistantText);
|
|
2749
|
+
log.debug("agent_end_accumulate", inbound.msg_id, "from", inbound.sender_name, "accumulated", accumulatedResponses.get(inbound.sender_session)!.length);
|
|
2750
|
+
|
|
2751
|
+
inbound.fulfilled = true;
|
|
2752
|
+
inboundQueue.delete(inbound.msg_id);
|
|
2753
|
+
|
|
2754
|
+
// Also fulfill bundled inbounds silently
|
|
2755
|
+
for (const bi of bundledInbounds) {
|
|
2756
|
+
bi.fulfilled = true;
|
|
2757
|
+
inboundQueue.delete(bi.msg_id);
|
|
2758
|
+
}
|
|
2759
|
+
bundledInbounds = [];
|
|
2760
|
+
|
|
2761
|
+
maybeRefreshWidget();
|
|
2762
|
+
|
|
2763
|
+
// FIFO drain — pick next from same sender (or any sender)
|
|
2764
|
+
const allPending = [...inboundQueue.values()].filter((i) => !i.fulfilled);
|
|
2765
|
+
if (allPending.length > 0) {
|
|
2766
|
+
const firstSender = allPending[0].sender_session;
|
|
2767
|
+
const senderPending = allPending.filter(i => i.sender_session === firstSender);
|
|
2768
|
+
|
|
2769
|
+
if (senderPending.length === 1) {
|
|
2770
|
+
currentInbound = senderPending[0];
|
|
2771
|
+
bundledInbounds = [];
|
|
2772
|
+
log.debug("agent_end_fifo_next", senderPending[0].msg_id, "from", senderPending[0].sender_name);
|
|
2773
|
+
try {
|
|
2774
|
+
pi.sendMessage({
|
|
2775
|
+
customType: formatComsInboundType(senderPending[0].sender_name, sanitizeComsName(identity?.name ?? "?"), senderPending[0].sender_cwd),
|
|
2776
|
+
content: buildInboundContent("", senderPending[0].prompt, senderPending[0].tasks, senderPending[0].sender_name, senderPending[0].sender_cwd),
|
|
2777
|
+
display: true,
|
|
2778
|
+
details: { msg_id: senderPending[0].msg_id, sender_session: senderPending[0].sender_session, response_schema: senderPending[0].response_schema ?? null },
|
|
2779
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
2780
|
+
} catch { currentInbound = null; bundledInbounds = []; processingInbound = false; }
|
|
2781
|
+
} else {
|
|
2782
|
+
const primary = senderPending[0];
|
|
2783
|
+
currentInbound = primary;
|
|
2784
|
+
bundledInbounds = senderPending.slice(1);
|
|
2785
|
+
const bundledContent = senderPending.map((ib, idx) =>
|
|
2786
|
+
`--- Message ${idx + 1} of ${senderPending.length} [${ib.msg_id}] ---\n${ib.prompt}`
|
|
2787
|
+
).join("\n\n");
|
|
2788
|
+
const header = `[BUNDLED: ${senderPending.length} messages from ${primary.sender_name}. Later messages may override earlier ones. Read ALL before acting.]`;
|
|
2789
|
+
log.debug("agent_end_fifo_bundle", senderPending.length, "from", primary.sender_name);
|
|
2790
|
+
try {
|
|
2791
|
+
pi.sendMessage({
|
|
2792
|
+
customType: formatComsInboundType(primary.sender_name, sanitizeComsName(identity?.name ?? "?"), primary.sender_cwd),
|
|
2793
|
+
content: buildInboundContent(header, bundledContent, null, primary.sender_name, primary.sender_cwd),
|
|
2794
|
+
display: true,
|
|
2795
|
+
details: { msg_id: primary.msg_id, sender_session: primary.sender_session, response_schema: primary.response_schema ?? null, bundled_msg_ids: senderPending.map(i => i.msg_id) },
|
|
2796
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
2797
|
+
} catch { currentInbound = null; bundledInbounds = []; processingInbound = false; }
|
|
2798
|
+
}
|
|
2799
|
+
} else {
|
|
2800
|
+
processingInbound = false;
|
|
2801
|
+
}
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
// No more pending from this sender — build combined response
|
|
2806
|
+
const accumulated = accumulatedResponses.get(inbound.sender_session) ?? [];
|
|
2807
|
+
accumulated.push(lastAssistantText);
|
|
2808
|
+
accumulatedResponses.delete(inbound.sender_session);
|
|
2809
|
+
const combinedPayload = accumulated.length > 1 ? accumulated.join("\n\n---\n\n") : accumulated[0] || lastAssistantText;
|
|
2810
|
+
log.debug("agent_end_combined_response", inbound.msg_id, "parts", accumulated.length);
|
|
2811
|
+
|
|
2812
|
+
let payload: any = combinedPayload;
|
|
1723
2813
|
let error: string | null = null;
|
|
1724
2814
|
if (inbound.response_schema && typeof inbound.response_schema === "object") {
|
|
1725
2815
|
try {
|
|
@@ -1733,11 +2823,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
1733
2823
|
// Use inline count instead of getPendingInboundCount() — `inbound` (local param)
|
|
1734
2824
|
// differs from `currentInbound` (module-scoped, already null at this point).
|
|
1735
2825
|
const queuedIds: string[] = [];
|
|
1736
|
-
for (const i of inboundQueue.values()) if (!i.fulfilled && i !== inbound) queuedIds.push(i.msg_id);
|
|
2826
|
+
for (const i of inboundQueue.values()) if (!i.fulfilled && i !== inbound && !bundledIds.has(i.msg_id)) queuedIds.push(i.msg_id);
|
|
1737
2827
|
const respEnv: ResponseEnvelope = {
|
|
1738
2828
|
type: "response",
|
|
1739
2829
|
msg_id: inbound.msg_id,
|
|
1740
|
-
sender_session: identity.
|
|
2830
|
+
sender_session: identity.coms_session_id,
|
|
1741
2831
|
sender_endpoint: identity.endpoint,
|
|
1742
2832
|
hops: 0,
|
|
1743
2833
|
timestamp: nowIso(),
|
|
@@ -1745,127 +2835,333 @@ export default function (pi: ExtensionAPI) {
|
|
|
1745
2835
|
error,
|
|
1746
2836
|
queued_msg_ids: queuedIds,
|
|
1747
2837
|
};
|
|
2838
|
+
// Compute your_pending at the last moment before send
|
|
2839
|
+
respEnv.your_pending = getSenderPending(inbound.sender_session, inbound.msg_id).filter(p => !bundledIds.has(p.msg_id));
|
|
1748
2840
|
|
|
1749
2841
|
try {
|
|
1750
2842
|
await sendEnvelope(inbound.sender_endpoint, respEnv);
|
|
1751
|
-
|
|
1752
|
-
pi.appendEntry("coms-log", {
|
|
1753
|
-
event: "outbound_response",
|
|
1754
|
-
msg_id: inbound.msg_id,
|
|
1755
|
-
error,
|
|
1756
|
-
});
|
|
1757
|
-
} catch { /* best-effort */ }
|
|
2843
|
+
log.debug("outbound_response", inbound.msg_id, "error", error);
|
|
1758
2844
|
} catch (e: any) {
|
|
1759
|
-
|
|
1760
|
-
pi.appendEntry("coms-log", {
|
|
1761
|
-
event: "outbound_response_failed",
|
|
1762
|
-
msg_id: inbound.msg_id,
|
|
1763
|
-
reason: e?.message ?? String(e),
|
|
1764
|
-
});
|
|
1765
|
-
} catch { /* best-effort */ }
|
|
2845
|
+
log.error("outbound_response_failed", inbound.msg_id, e?.message);
|
|
1766
2846
|
}
|
|
1767
2847
|
|
|
1768
2848
|
inbound.fulfilled = true;
|
|
1769
2849
|
inboundQueue.delete(inbound.msg_id);
|
|
2850
|
+
|
|
2851
|
+
// Fulfill bundled inbounds — fire-and-forget responses, don't block agent_end
|
|
2852
|
+
for (const bi of bundledInbounds) {
|
|
2853
|
+
const biMsgId = bi.msg_id;
|
|
2854
|
+
const biEndpoint = bi.sender_endpoint;
|
|
2855
|
+
// Fire-and-forget — don't await, don't block
|
|
2856
|
+
if (identity) {
|
|
2857
|
+
sendEnvelope(biEndpoint, {
|
|
2858
|
+
type: "response",
|
|
2859
|
+
msg_id: biMsgId,
|
|
2860
|
+
sender_session: identity.coms_session_id,
|
|
2861
|
+
sender_endpoint: identity.endpoint,
|
|
2862
|
+
hops: 0,
|
|
2863
|
+
timestamp: nowIso(),
|
|
2864
|
+
response: null,
|
|
2865
|
+
error: "bundled",
|
|
2866
|
+
queued_msg_ids: [],
|
|
2867
|
+
}).catch(() => { /* best-effort */ });
|
|
2868
|
+
}
|
|
2869
|
+
bi.fulfilled = true;
|
|
2870
|
+
inboundQueue.delete(bi.msg_id);
|
|
2871
|
+
}
|
|
2872
|
+
bundledInbounds = [];
|
|
2873
|
+
|
|
1770
2874
|
maybeRefreshWidget();
|
|
1771
|
-
currentInbound = null;
|
|
1772
2875
|
|
|
1773
|
-
// FIFO drain:
|
|
1774
|
-
const
|
|
1775
|
-
if (
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
2876
|
+
// FIFO drain: collect ALL pending from the same sender and bundle them
|
|
2877
|
+
const allPending = [...inboundQueue.values()].filter((i) => !i.fulfilled);
|
|
2878
|
+
if (allPending.length > 0) {
|
|
2879
|
+
// Group by sender — process first sender's messages together
|
|
2880
|
+
const firstSender = allPending[0].sender_session;
|
|
2881
|
+
const senderPending = allPending.filter(i => i.sender_session === firstSender);
|
|
2882
|
+
|
|
2883
|
+
if (senderPending.length === 1) {
|
|
2884
|
+
// Single message — inject normally
|
|
2885
|
+
const next = senderPending[0];
|
|
2886
|
+
currentInbound = next;
|
|
2887
|
+
bundledInbounds = [];
|
|
2888
|
+
try {
|
|
2889
|
+
pi.sendMessage(
|
|
2890
|
+
{
|
|
2891
|
+
customType: formatComsInboundType(next.sender_name, sanitizeComsName(identity?.name ?? "?"), next.sender_cwd),
|
|
2892
|
+
content: buildInboundContent("", next.prompt, next.tasks, next.sender_name, next.sender_cwd),
|
|
2893
|
+
display: true,
|
|
2894
|
+
details: {
|
|
2895
|
+
msg_id: next.msg_id,
|
|
2896
|
+
sender_session: next.sender_session,
|
|
2897
|
+
response_schema: next.response_schema ?? null,
|
|
2898
|
+
},
|
|
1787
2899
|
},
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
)
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
2900
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
2901
|
+
);
|
|
2902
|
+
} catch (err: any) {
|
|
2903
|
+
log.error("fifo_drain_failed", next.msg_id, err?.message);
|
|
2904
|
+
next.fulfilled = true;
|
|
2905
|
+
inboundQueue.delete(next.msg_id);
|
|
2906
|
+
currentInbound = null;
|
|
2907
|
+
bundledInbounds = [];
|
|
2908
|
+
processingInbound = false;
|
|
2909
|
+
}
|
|
2910
|
+
} else {
|
|
2911
|
+
// Multiple messages from same sender — bundle them
|
|
2912
|
+
const primary = senderPending[0];
|
|
2913
|
+
currentInbound = primary;
|
|
2914
|
+
bundledInbounds = senderPending.slice(1);
|
|
2915
|
+
|
|
2916
|
+
const bundledContent = senderPending.map((ib, idx) =>
|
|
2917
|
+
`--- Message ${idx + 1} of ${senderPending.length} [${ib.msg_id}] ---\n${ib.prompt}`
|
|
2918
|
+
).join("\n\n");
|
|
2919
|
+
|
|
2920
|
+
const header = `[BUNDLED: ${senderPending.length} messages from ${primary.sender_name}. Later messages may override earlier ones. Read ALL before acting.]`;
|
|
2921
|
+
|
|
2922
|
+
try {
|
|
2923
|
+
pi.sendMessage(
|
|
2924
|
+
{
|
|
2925
|
+
customType: formatComsInboundType(primary.sender_name, sanitizeComsName(identity?.name ?? "?"), primary.sender_cwd),
|
|
2926
|
+
content: buildInboundContent(header, bundledContent, null, primary.sender_name, primary.sender_cwd),
|
|
2927
|
+
display: true,
|
|
2928
|
+
details: {
|
|
2929
|
+
msg_id: primary.msg_id,
|
|
2930
|
+
sender_session: primary.sender_session,
|
|
2931
|
+
response_schema: primary.response_schema ?? null,
|
|
2932
|
+
bundled_msg_ids: senderPending.map(i => i.msg_id),
|
|
2933
|
+
},
|
|
2934
|
+
},
|
|
2935
|
+
{ deliverAs: "followUp", triggerTurn: true },
|
|
2936
|
+
);
|
|
2937
|
+
} catch (err: any) {
|
|
2938
|
+
log.error("fifo_bundle_failed", primary.msg_id, err?.message);
|
|
2939
|
+
for (const ib of senderPending) {
|
|
2940
|
+
ib.fulfilled = true;
|
|
2941
|
+
inboundQueue.delete(ib.msg_id);
|
|
1811
2942
|
}
|
|
1812
|
-
|
|
1813
|
-
|
|
2943
|
+
currentInbound = null;
|
|
2944
|
+
bundledInbounds = [];
|
|
2945
|
+
processingInbound = false;
|
|
1814
2946
|
}
|
|
1815
|
-
currentInbound = null;
|
|
1816
|
-
processingInbound = false;
|
|
1817
2947
|
}
|
|
1818
2948
|
} else {
|
|
2949
|
+
bundledInbounds = [];
|
|
1819
2950
|
processingInbound = false;
|
|
1820
2951
|
}
|
|
1821
2952
|
});
|
|
1822
2953
|
|
|
1823
2954
|
// ━━ /coms slash command ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
2955
|
+
const comsRefreshHandler = async (args: any, ctx: any) => {
|
|
2956
|
+
const trimmed = ((args as string) ?? "").trim();
|
|
2957
|
+
if (trimmed.includes("--all")) {
|
|
2958
|
+
includeExplicit = !includeExplicit;
|
|
2959
|
+
try { ctx.ui.notify(`coms: include_explicit = ${includeExplicit}`, "info"); } catch { /* ignore */ }
|
|
2960
|
+
}
|
|
2961
|
+
const projectMatch = trimmed.match(/--project\s+(\S+)/);
|
|
2962
|
+
if (projectMatch) {
|
|
2963
|
+
displayProject = projectMatch[1];
|
|
2964
|
+
try { ctx.ui.notify(`coms: displaying project ${displayProject}`, "info"); } catch { /* ignore */ }
|
|
2965
|
+
}
|
|
2966
|
+
maybeRefreshWidget();
|
|
2967
|
+
};
|
|
1824
2968
|
pi.registerCommand("coms", {
|
|
1825
2969
|
description: "Force-refresh the coms pool widget (or filter with --all / --project <name>)",
|
|
1826
|
-
handler:
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
2970
|
+
handler: comsRefreshHandler,
|
|
2971
|
+
});
|
|
2972
|
+
|
|
2973
|
+
// ━━ /coms-queue command — view and kill queued messages ━━━━━━━━━━━━━━━
|
|
2974
|
+
let comsQueueCmdHandler: any;
|
|
2975
|
+
pi.registerCommand("coms-queue", {
|
|
2976
|
+
description: "View and manage queued coms inbound messages",
|
|
2977
|
+
handler: comsQueueCmdHandler = async (_args: any, ctx: any) => {
|
|
2978
|
+
interface QueueItem {
|
|
2979
|
+
id: string;
|
|
2980
|
+
msg_id: string;
|
|
2981
|
+
sender_name: string;
|
|
2982
|
+
prompt: string;
|
|
2983
|
+
status: string;
|
|
2984
|
+
receivedAt: number;
|
|
1836
2985
|
}
|
|
1837
|
-
|
|
2986
|
+
|
|
2987
|
+
const getItems = (): QueueItem[] => {
|
|
2988
|
+
const items: QueueItem[] = [];
|
|
2989
|
+
const now = Date.now();
|
|
2990
|
+
if (currentInbound && !currentInbound.fulfilled) {
|
|
2991
|
+
items.push({
|
|
2992
|
+
id: currentInbound.msg_id,
|
|
2993
|
+
msg_id: currentInbound.msg_id,
|
|
2994
|
+
sender_name: currentInbound.sender_name,
|
|
2995
|
+
prompt: currentInbound.prompt,
|
|
2996
|
+
status: "processing",
|
|
2997
|
+
receivedAt: now,
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
for (const ib of inboundQueue.values()) {
|
|
3001
|
+
if (ib.fulfilled) continue;
|
|
3002
|
+
if (currentInbound && ib.msg_id === currentInbound.msg_id) continue;
|
|
3003
|
+
items.push({
|
|
3004
|
+
id: ib.msg_id,
|
|
3005
|
+
msg_id: ib.msg_id,
|
|
3006
|
+
sender_name: ib.sender_name,
|
|
3007
|
+
prompt: ib.prompt,
|
|
3008
|
+
status: "pending",
|
|
3009
|
+
receivedAt: now,
|
|
3010
|
+
});
|
|
3011
|
+
}
|
|
3012
|
+
return items;
|
|
3013
|
+
};
|
|
3014
|
+
|
|
3015
|
+
const killItem = async (msgId: string) => {
|
|
3016
|
+
const ib = inboundQueue.get(msgId);
|
|
3017
|
+
if (!ib || ib.fulfilled) return;
|
|
3018
|
+
ib.fulfilled = true;
|
|
3019
|
+
inboundQueue.delete(msgId);
|
|
3020
|
+
if (currentInbound?.msg_id === msgId) {
|
|
3021
|
+
currentInbound = null;
|
|
3022
|
+
}
|
|
3023
|
+
maybeRefreshWidget();
|
|
3024
|
+
// Send error response to sender
|
|
3025
|
+
if (identity) {
|
|
3026
|
+
try {
|
|
3027
|
+
await sendEnvelope(ib.sender_endpoint, {
|
|
3028
|
+
type: "response", msg_id: ib.msg_id,
|
|
3029
|
+
sender_session: identity.coms_session_id, sender_endpoint: identity.endpoint,
|
|
3030
|
+
hops: 0, timestamp: nowIso(),
|
|
3031
|
+
response: null, error: "killed_by_user",
|
|
3032
|
+
queued_msg_ids: [],
|
|
3033
|
+
});
|
|
3034
|
+
} catch { /* best-effort */ }
|
|
3035
|
+
}
|
|
3036
|
+
log.info("queue_kill", msgId, "sender", ib.sender_name);
|
|
3037
|
+
};
|
|
3038
|
+
|
|
3039
|
+
const fmtAge = (ms: number): string => {
|
|
3040
|
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`;
|
|
3041
|
+
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`;
|
|
3042
|
+
return `${Math.round(ms / 3_600_000)}h`;
|
|
3043
|
+
};
|
|
3044
|
+
|
|
3045
|
+
await openListDetailOverlay(ctx, {
|
|
3046
|
+
emptyMessage: "No queued coms messages.",
|
|
3047
|
+
listSpec: {
|
|
3048
|
+
title: "Coms Queue",
|
|
3049
|
+
countLabel: (items) => `${items.length} message${items.length === 1 ? "" : "s"}`,
|
|
3050
|
+
borderTitle: (items) => {
|
|
3051
|
+
const processing = items.filter(i => i.status === "processing").length;
|
|
3052
|
+
return `queue · ${processing} processing / ${items.length}`;
|
|
3053
|
+
},
|
|
3054
|
+
footerHints: "↑↓/jk select · Enter view · x kill · Esc close",
|
|
3055
|
+
listItems: getItems,
|
|
3056
|
+
rowParts: (item, theme) => {
|
|
3057
|
+
const shortId = item.msg_id.length > 8 ? item.msg_id.slice(-8) : item.msg_id;
|
|
3058
|
+
const preview = item.prompt.length > 40 ? `${item.prompt.slice(0, 40)}…` : item.prompt;
|
|
3059
|
+
const statusColor = item.status === "processing" ? "warning" : "dim";
|
|
3060
|
+
return {
|
|
3061
|
+
glyph: item.status === "processing" ? theme.fg("warning", "■") : theme.fg("dim", "○"),
|
|
3062
|
+
title: item.sender_name,
|
|
3063
|
+
idLabel: theme.fg("dim", shortId),
|
|
3064
|
+
rightParts: [
|
|
3065
|
+
theme.fg("dim", preview),
|
|
3066
|
+
theme.fg(statusColor, item.status),
|
|
3067
|
+
],
|
|
3068
|
+
};
|
|
3069
|
+
},
|
|
3070
|
+
onX: (item) => { void killItem(item.id); },
|
|
3071
|
+
},
|
|
3072
|
+
createDetail: (item, tui, theme, done) => {
|
|
3073
|
+
return new OverlayScrollDetail(tui, theme, {
|
|
3074
|
+
followTail: false,
|
|
3075
|
+
emptyBody: "(no prompt)",
|
|
3076
|
+
footerHints: "↑↓/jk scroll · x kill · Esc back",
|
|
3077
|
+
onX: () => {
|
|
3078
|
+
void killItem(item.id);
|
|
3079
|
+
return true;
|
|
3080
|
+
},
|
|
3081
|
+
getHeader: (t) => {
|
|
3082
|
+
const shortId = item.msg_id.length > 8 ? item.msg_id.slice(-8) : item.msg_id;
|
|
3083
|
+
return (
|
|
3084
|
+
t.fg("accent", t.bold(item.sender_name)) +
|
|
3085
|
+
t.fg("dim", ` · ${shortId}`) +
|
|
3086
|
+
t.fg("muted", ` · ${item.status}`)
|
|
3087
|
+
);
|
|
3088
|
+
},
|
|
3089
|
+
getBodyLines: () => item.prompt.split("\n"),
|
|
3090
|
+
}, done);
|
|
3091
|
+
},
|
|
3092
|
+
});
|
|
1838
3093
|
},
|
|
1839
3094
|
});
|
|
1840
3095
|
|
|
3096
|
+
// Expose command handlers to pidash for browser command dispatch
|
|
3097
|
+
try {
|
|
3098
|
+
if ((globalThis as any).__coms_p2p_pidash_listener) {
|
|
3099
|
+
try { pi.events.removeListener("pidash:request-commands", (globalThis as any).__coms_p2p_pidash_listener); } catch {}
|
|
3100
|
+
}
|
|
3101
|
+
const registerWithPidash = () => {
|
|
3102
|
+
pi.events.emit("pidash:register-command", { name: "coms-queue", handler: comsQueueCmdHandler });
|
|
3103
|
+
};
|
|
3104
|
+
(globalThis as any).__coms_p2p_pidash_listener = registerWithPidash;
|
|
3105
|
+
registerWithPidash();
|
|
3106
|
+
pi.events.on("pidash:request-commands", registerWithPidash);
|
|
3107
|
+
} catch {}
|
|
3108
|
+
|
|
1841
3109
|
// ━━ Clean shutdown ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
1842
3110
|
let shuttingDown = false;
|
|
1843
3111
|
async function cleanShutdown(): Promise<void> {
|
|
1844
3112
|
if (shuttingDown) return;
|
|
1845
3113
|
shuttingDown = true;
|
|
1846
|
-
|
|
3114
|
+
try { pi.events.emit("pidash:coms-identity", { name: null, purpose: null }); } catch {}
|
|
3115
|
+
const ident = identity; // snapshot before nulling
|
|
3116
|
+
identity = null; // null identity so old handlers from previous reload cycles exit via !identity check
|
|
3117
|
+
if (fsWatcher) { try { fsWatcher.close(); } catch { /* ignore */ } fsWatcher = null; }
|
|
1847
3118
|
if (keepaliveTimer) { try { clearInterval(keepaliveTimer); } catch { /* ignore */ } keepaliveTimer = null; }
|
|
3119
|
+
if (taskHeartbeatTimer) { try { clearInterval(taskHeartbeatTimer); } catch { /* ignore */ } taskHeartbeatTimer = null; }
|
|
3120
|
+
// Broadcast "leaving" presence to all known peers (fire-and-forget)
|
|
3121
|
+
if (ident) {
|
|
3122
|
+
for (const [, card] of peerCards) {
|
|
3123
|
+
// Find endpoint for this peer from registry
|
|
3124
|
+
const entries = readAllRegistryEntries(ident.project);
|
|
3125
|
+
const peerEntry = entries.find(e => e.name === card.name && e.coms_session_id !== ident!.coms_session_id);
|
|
3126
|
+
if (peerEntry?.endpoint) {
|
|
3127
|
+
log.info("shutdown_leaving_broadcast", "to", card.name, peerEntry.endpoint);
|
|
3128
|
+
try {
|
|
3129
|
+
const sock = net.createConnection({ path: peerEntry.endpoint });
|
|
3130
|
+
sock.once("connect", () => {
|
|
3131
|
+
try {
|
|
3132
|
+
sock.write(JSON.stringify({
|
|
3133
|
+
type: "presence",
|
|
3134
|
+
status: "leaving",
|
|
3135
|
+
sender_name: ident!.name,
|
|
3136
|
+
msg_id: ulid(),
|
|
3137
|
+
sender_session: ident!.coms_session_id,
|
|
3138
|
+
sender_endpoint: ident!.endpoint,
|
|
3139
|
+
hops: 0,
|
|
3140
|
+
timestamp: nowIso(),
|
|
3141
|
+
}) + "\n");
|
|
3142
|
+
} catch { /* ignore */ }
|
|
3143
|
+
setTimeout(() => { try { sock.destroy(); } catch {} }, 200);
|
|
3144
|
+
});
|
|
3145
|
+
sock.once("error", () => { try { sock.destroy(); } catch {} });
|
|
3146
|
+
} catch { /* ignore */ }
|
|
3147
|
+
}
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
1848
3150
|
if (server) {
|
|
1849
3151
|
try { server.close(); } catch { /* ignore */ }
|
|
1850
3152
|
server = null;
|
|
1851
3153
|
}
|
|
1852
|
-
if (cardUpdateTimer) {
|
|
1853
|
-
clearInterval(cardUpdateTimer);
|
|
1854
|
-
cardUpdateTimer = null;
|
|
1855
|
-
}
|
|
1856
3154
|
if (pingWorker) {
|
|
1857
3155
|
try { pingWorker.postMessage({ type: "shutdown" }); } catch {}
|
|
1858
3156
|
pingWorker = null;
|
|
1859
3157
|
pingWorkerReady = false;
|
|
1860
3158
|
}
|
|
1861
|
-
if (
|
|
3159
|
+
if (ident) {
|
|
1862
3160
|
if (process.platform !== "win32") {
|
|
1863
|
-
try { fs.unlinkSync(
|
|
3161
|
+
try { fs.unlinkSync(ident.endpoint); } catch { /* ignore */ }
|
|
1864
3162
|
}
|
|
1865
|
-
try { removeRegistryEntry(
|
|
1866
|
-
|
|
1867
|
-
pi.appendEntry("coms-log", { event: "shutdown", session_id: identity.session_id });
|
|
1868
|
-
} catch { /* best-effort */ }
|
|
3163
|
+
try { removeRegistryEntry(ident.project, ident.coms_session_id); } catch { /* ignore */ }
|
|
3164
|
+
log.info("shutdown", ident.coms_session_id);
|
|
1869
3165
|
}
|
|
1870
3166
|
if (currentCtx?.hasUI) {
|
|
1871
3167
|
try { currentCtx.ui.setWidget("coms-pool", undefined); } catch { /* ignore */ }
|