pi-orchestrator-config 4.2.1 → 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.
Files changed (92) hide show
  1. package/README.md +52 -61
  2. package/agents/git-expert.md +5 -1
  3. package/agents/github-expert.md +4 -1
  4. package/extensions/acpx-provider/runtime-models.ts +18 -6
  5. package/extensions/cli-provider/agents/claude.ts +3 -2
  6. package/extensions/cli-provider/agents/cursor.ts +3 -5
  7. package/extensions/cli-provider/agents/gemini.ts +3 -2
  8. package/extensions/cli-provider/discover.ts +7 -7
  9. package/extensions/cli-provider/index.ts +5 -0
  10. package/extensions/cli-provider/runtime-models.ts +18 -6
  11. package/extensions/cli-provider/session-reaper.ts +5 -7
  12. package/extensions/cli-provider/sessions.ts +4 -0
  13. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  14. package/extensions/coms/coms-p2p.ts +1719 -423
  15. package/extensions/coms/coms-shared.ts +261 -39
  16. package/extensions/coms/coms-wrapper.ts +100 -62
  17. package/extensions/coms/index.ts +8 -4
  18. package/extensions/coms/mixed-turn.ts +26 -0
  19. package/extensions/coms/probe-socket.ts +42 -0
  20. package/extensions/coms/themeMap.ts +0 -1
  21. package/extensions/image-gen/image-gen.ts +19 -1
  22. package/extensions/orchestrator/async-agents.ts +509 -85
  23. package/extensions/orchestrator/cron.ts +14 -8
  24. package/extensions/orchestrator/dreaming.ts +31 -24
  25. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  26. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  27. package/extensions/orchestrator/enforcement.ts +79 -25
  28. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  29. package/extensions/orchestrator/git-helpers.ts +2 -2
  30. package/extensions/orchestrator/index.ts +23 -10
  31. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  32. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  33. package/extensions/orchestrator/memory-provenance.ts +29 -25
  34. package/extensions/orchestrator/memory-scoring.ts +23 -18
  35. package/extensions/orchestrator/memory-tools.ts +10 -3
  36. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  37. package/extensions/orchestrator/pi-config-review-state.ts +44 -33
  38. package/extensions/orchestrator/project-settings.ts +220 -21
  39. package/extensions/orchestrator/review-porcelain.ts +4 -0
  40. package/extensions/orchestrator/review-ui.ts +11 -5
  41. package/extensions/orchestrator/rule-placeholders.ts +384 -1
  42. package/extensions/orchestrator/rules.ts +80 -132
  43. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  44. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  45. package/extensions/orchestrator/settings-tui.ts +636 -0
  46. package/extensions/orchestrator/state-jsonl.ts +431 -0
  47. package/extensions/orchestrator/subagent-tool.ts +105 -50
  48. package/extensions/orchestrator/sync-limit.ts +23 -0
  49. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  50. package/extensions/orchestrator/utils.ts +8 -5
  51. package/extensions/pidash/pidash.ts +193 -156
  52. package/extensions/pidiff/pidiff.ts +64 -38
  53. package/extensions/pidiff/spawn-lock.ts +62 -0
  54. package/extensions/pitasks/index.ts +430 -0
  55. package/extensions/pitasks/reminders.ts +37 -0
  56. package/extensions/pitasks/task-store.ts +467 -0
  57. package/extensions/pitasks/task-tools.ts +375 -0
  58. package/extensions/pitasks/task-widget.ts +181 -0
  59. package/extensions/pitasks/types.ts +38 -0
  60. package/extensions/providers/acpx-driver.ts +12 -2
  61. package/extensions/providers/built-in-drivers.ts +6 -6
  62. package/extensions/providers/claude-driver.ts +1 -1
  63. package/extensions/providers/cursor-cli-driver.ts +1 -1
  64. package/extensions/providers/gemini-driver.ts +1 -1
  65. package/extensions/providers/index.ts +144 -26
  66. package/extensions/providers/initialized-guard.ts +27 -0
  67. package/extensions/providers/restore-default-model.ts +596 -0
  68. package/extensions/providers/session-shutdown.ts +39 -0
  69. package/extensions/shared/coms-active.ts +23 -0
  70. package/extensions/shared/create-runtime-provider.ts +2 -0
  71. package/extensions/shared/daemon-manager.ts +0 -8
  72. package/extensions/shared/file-logger.ts +132 -25
  73. package/extensions/shared/logger.ts +50 -0
  74. package/extensions/shared/models-dev.ts +421 -0
  75. package/extensions/shared/oneshot.ts +116 -0
  76. package/extensions/shared/resolve-binary.ts +191 -0
  77. package/extensions/shared/types.ts +3 -0
  78. package/package.json +7 -2
  79. package/prompts/coderabbit-rate-limit.md +2 -0
  80. package/rules/00-orchestrator-core.md +3 -0
  81. package/rules/10-agent-routing.md +1 -1
  82. package/rules/20-code-review-loop.md +38 -16
  83. package/rules/30-prompt-templates.md +4 -0
  84. package/rules/35-memory.md +3 -3
  85. package/rules/40-critical-rules.md +3 -0
  86. package/rules/55-coms-protocol.md +84 -18
  87. package/rules/60-task-tracking.md +2 -0
  88. package/settings-keys.json +481 -20
  89. package/templates/coms-feature-manager-prompt.md +4 -6
  90. package/extensions/coms/coms-net-server.ts +0 -1642
  91. package/extensions/coms/coms-net-wrapper.ts +0 -521
  92. 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 COMS_DIR = process.env.PI_COMS_DIR || path.join(os.homedir(), ".pi", "coms");
33
- const MAX_HOPS = Number(process.env.PI_COMS_MAX_HOPS) || 5;
34
- const TIMEOUT_MS = Number(process.env.PI_COMS_TIMEOUT_MS) || 1_800_000;
35
- const PING_INTERVAL_MS = Number(process.env.PI_COMS_PING_INTERVAL_MS) || 10_000;
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
- session_id: string;
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.session_id);
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.session_id === "string") {
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.session_id);
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.session_id);
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.session_id);
381
+ removeRegistryEntry(project, entry.coms_session_id);
330
382
  continue;
331
383
  }
332
384
  } else {
333
- removeRegistryEntry(project, entry.session_id);
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) return [];
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.session_id);
353
- if (entry.endpoint.startsWith(socketsDir + path.sep) || entry.endpoint.startsWith(socketsDir + "/")) {
354
- try { fs.unlinkSync(entry.endpoint); } catch { /* best-effort */ }
355
- try { fs.unlinkSync(`${entry.endpoint}.ping`); } catch { /* best-effort */ }
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
- return live;
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
- session_id: string;
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 pingTimer: NodeJS.Timeout | null = null;
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
- try {
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: "coms-inbound",
674
- content: buildInboundContent(`[from ${env.sender_name} @ ${env.sender_cwd}]`, env.prompt, env.tasks),
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
- try {
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
- try { pi.appendEntry("coms-log", { event: "resolve_failed", msg_id: env.msg_id, reason: e?.message ?? String(e) }); } catch { /* best-effort */ }
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 responseText = formatComsResponseText(targetName, env.response, env.error ?? null, queuedMsgIds);
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: "coms-response",
730
- content: responseText,
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
- try {
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 session_id = ulid();
1140
+ const comsSessionId = (globalThis as any).__piConfigSessionId || ulid();
867
1141
 
868
- const defaultName = `agent-${session_id.slice(-6)}`;
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(session_id);
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.session_id !== session_id) {
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.session_id);
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.session_id);
1211
+ removeRegistryEntry(project, existing.coms_session_id);
931
1212
  }
932
1213
  } else {
933
1214
  // No valid endpoint — remove stale entry
934
- removeRegistryEntry(project, existing.session_id);
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") console.debug("[coms] ping worker error:", msg.message);
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
- console.debug("[coms] ping worker failed to start:", err instanceof Error ? err.message : String(err));
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
- session_id,
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
- session_id,
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
- try {
1004
- pi.appendEntry("coms-log", { event: "boot", session_id, name, project });
1005
- } catch {
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 ping + keepalive cycles (skip in one-shot modes).
1303
+ // 7. Start timers (skip in one-shot modes).
1022
1304
  if (ctx.mode !== "print" && ctx.mode !== "json") {
1023
- pingTimer = setInterval(() => { refreshPool().catch(() => {}); }, PING_INTERVAL_MS);
1024
- try { (pingTimer as any).unref?.(); } catch { /* ignore */ }
1025
- // Update ping worker card periodically
1026
- cardUpdateTimer = setInterval(updatePingWorkerCard, 5000);
1027
- try { (cardUpdateTimer as any).unref?.(); } catch { /* ignore */ }
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
- pi.appendEntry("coms-log", { event: "self_heal_socket", session_id: identity.session_id, reason: "socket file missing" });
1038
- // Close old server
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
- pingWorkerReady = true;
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 { /* best-effort */ }
1539
+ } catch {}
1069
1540
  } catch (err) {
1070
- pi.appendEntry("coms-log", { event: "self_heal_socket_failed", session_id: identity.session_id, reason: err instanceof Error ? err.message : String(err) });
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
- session_id: identity.session_id,
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
- pi.appendEntry("coms-log", { event: "self_heal", session_id: identity.session_id, reason: "registry file missing" });
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
- } catch { /* best-effort */ }
1570
+ updatePingWorkerCard();
1571
+ } catch {}
1107
1572
  }, KEEPALIVE_INTERVAL_MS);
1108
- try { (keepaliveTimer as any).unref?.(); } catch { /* ignore */ }
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.session_id) continue;
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.session_id === target);
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.session_id.localeCompare(b.session_id); // stable tiebreak
1778
+ return a.coms_session_id.localeCompare(b.coms_session_id); // stable tiebreak
1409
1779
  });
1410
- try {
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.session_id === target);
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
- const collected: { entry: RegistryEntry; project: string }[] = [];
1455
- for (const proj of projects) {
1456
- for (const entry of await pruneDeadEntries(proj)) {
1457
- if (entry.explicit && !includeExp) continue;
1458
- if (identity && entry.session_id === identity.session_id) continue;
1459
- collected.push({ entry, project: proj });
1460
- }
1461
- }
1462
-
1463
- // Ping each peer in parallel for live context usage.
1464
- const pongs = await Promise.allSettled(collected.map((c) => pingPeer(c.entry.endpoint)));
1465
-
1466
- const agents = collected.map((c, i) => {
1467
- const r = pongs[i];
1468
- const pong = r.status === "fulfilled" ? r.value : null;
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.session_id,
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: params.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
- try {
1599
- pi.appendEntry("coms-log", {
1600
- event: "outbound_prompt",
1601
- msg_id,
1602
- target: target.name,
1603
- hops,
1604
- });
1605
- } catch {
1606
- // best-effort
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.session_id, hops },
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
- // ━━ agent_end: capture turn output and dispatch response back ━━━━━━━━
2091
+ // ━━ coms queue management tools ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1689
2092
 
1690
- pi.on("agent_end", async (_event, ctx) => {
1691
- if (!currentInbound || !identity) {
1692
- // Drain any orphaned inbounds — can't send responses without identity,
1693
- // but must clear the queue to prevent permanent buildup
1694
- if (!identity) {
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
- let payload: any = lastAssistantText;
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.session_id,
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
- try {
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
- try {
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: pick the next queued (oldest unfulfilled) inbound
1774
- const next = [...inboundQueue.values()].find((i) => !i.fulfilled);
1775
- if (next) {
1776
- currentInbound = next;
1777
- try {
1778
- pi.sendMessage(
1779
- {
1780
- customType: "coms-inbound",
1781
- content: buildInboundContent(`[from ${next.sender_name} @ ${next.sender_cwd}]`, next.prompt, next.tasks),
1782
- display: true,
1783
- details: {
1784
- msg_id: next.msg_id,
1785
- sender_session: next.sender_session,
1786
- response_schema: next.response_schema ?? null,
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
- { deliverAs: "followUp", triggerTurn: true },
1790
- );
1791
- } catch (err: any) {
1792
- // Failed to inject — drain ALL remaining queued messages with error responses
1793
- try { pi.appendEntry("coms-log", { event: "fifo_drain_failed", msg_id: next.msg_id, reason: err?.message ?? String(err) }); } catch { /* best-effort */ }
1794
- const remaining = [next, ...[...inboundQueue.values()].filter(i => !i.fulfilled && i.msg_id !== next.msg_id)];
1795
- for (let idx = 0; idx < remaining.length; idx++) {
1796
- const orphan = remaining[idx];
1797
- try {
1798
- await sendEnvelope(orphan.sender_endpoint, {
1799
- type: "response",
1800
- msg_id: orphan.msg_id,
1801
- sender_session: identity!.session_id,
1802
- sender_endpoint: identity!.endpoint,
1803
- hops: 0,
1804
- timestamp: nowIso(),
1805
- response: null,
1806
- error: "injection_failed",
1807
- queued_msg_ids: remaining.slice(idx + 1).map(r => r.msg_id),
1808
- });
1809
- } catch (e: any) {
1810
- try { pi.appendEntry("coms-log", { event: "orphan_cleanup_failed", msg_id: orphan.msg_id, reason: e?.message ?? String(e) }); } catch { /* best-effort */ }
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
- orphan.fulfilled = true;
1813
- inboundQueue.delete(orphan.msg_id);
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: async (args, ctx) => {
1827
- const trimmed = (args ?? "").trim();
1828
- if (trimmed.includes("--all")) {
1829
- includeExplicit = !includeExplicit;
1830
- try { ctx.ui.notify(`coms: include_explicit = ${includeExplicit}`, "info"); } catch { /* ignore */ }
1831
- }
1832
- const projectMatch = trimmed.match(/--project\s+(\S+)/);
1833
- if (projectMatch) {
1834
- displayProject = projectMatch[1];
1835
- try { ctx.ui.notify(`coms: displaying project ${displayProject}`, "info"); } catch { /* ignore */ }
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
- await refreshPool();
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
- if (pingTimer) { try { clearInterval(pingTimer); } catch { /* ignore */ } pingTimer = null; }
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 (identity) {
3159
+ if (ident) {
1862
3160
  if (process.platform !== "win32") {
1863
- try { fs.unlinkSync(identity.endpoint); } catch { /* ignore */ }
3161
+ try { fs.unlinkSync(ident.endpoint); } catch { /* ignore */ }
1864
3162
  }
1865
- try { removeRegistryEntry(identity.project, identity.session_id); } catch { /* ignore */ }
1866
- try {
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 */ }