pi-orchestrator-config 4.2.0 → 4.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +76 -67
  2. package/agents/code-reviewer-docs.md +11 -1
  3. package/agents/code-reviewer-guidelines.md +11 -1
  4. package/agents/code-reviewer-quality.md +11 -1
  5. package/agents/code-reviewer-security.md +11 -1
  6. package/agents/code-reviewer-spec.md +11 -1
  7. package/agents/git-expert.md +42 -3
  8. package/agents/github-expert.md +11 -0
  9. package/extensions/acpx-provider/runtime-models.ts +18 -6
  10. package/extensions/cli-provider/agents/claude.ts +3 -2
  11. package/extensions/cli-provider/agents/cursor.ts +3 -5
  12. package/extensions/cli-provider/agents/gemini.ts +3 -2
  13. package/extensions/cli-provider/discover.ts +7 -7
  14. package/extensions/cli-provider/index.ts +5 -0
  15. package/extensions/cli-provider/runtime-models.ts +18 -6
  16. package/extensions/cli-provider/session-reaper.ts +5 -7
  17. package/extensions/cli-provider/sessions.ts +4 -0
  18. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  19. package/extensions/coms/coms-p2p.ts +1719 -423
  20. package/extensions/coms/coms-shared.ts +261 -39
  21. package/extensions/coms/coms-wrapper.ts +100 -62
  22. package/extensions/coms/index.ts +8 -4
  23. package/extensions/coms/mixed-turn.ts +26 -0
  24. package/extensions/coms/probe-socket.ts +42 -0
  25. package/extensions/coms/themeMap.ts +0 -1
  26. package/extensions/image-gen/image-gen.ts +19 -1
  27. package/extensions/orchestrator/async-agents.ts +563 -102
  28. package/extensions/orchestrator/async-capability.ts +8 -8
  29. package/extensions/orchestrator/cron.ts +15 -9
  30. package/extensions/orchestrator/dreaming.ts +32 -25
  31. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  32. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  33. package/extensions/orchestrator/enforcement.ts +80 -27
  34. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  35. package/extensions/orchestrator/git-helpers.ts +2 -2
  36. package/extensions/orchestrator/index.ts +78 -10
  37. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  38. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  39. package/extensions/orchestrator/memory-provenance.ts +29 -25
  40. package/extensions/orchestrator/memory-scoring.ts +23 -18
  41. package/extensions/orchestrator/memory-tools.ts +10 -3
  42. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  43. package/extensions/orchestrator/parse-model-override.ts +29 -0
  44. package/extensions/orchestrator/pi-config-review-state.ts +57 -33
  45. package/extensions/orchestrator/project-settings.ts +333 -127
  46. package/extensions/orchestrator/resolve-agent-model.ts +24 -13
  47. package/extensions/orchestrator/review-porcelain.ts +4 -0
  48. package/extensions/orchestrator/review-ui.ts +63 -15
  49. package/extensions/orchestrator/rule-placeholders.ts +411 -2
  50. package/extensions/orchestrator/rules.ts +80 -132
  51. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  52. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  53. package/extensions/orchestrator/settings-tui.ts +636 -0
  54. package/extensions/orchestrator/state-jsonl.ts +431 -0
  55. package/extensions/orchestrator/subagent-tool.ts +157 -59
  56. package/extensions/orchestrator/sync-limit.ts +23 -0
  57. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  58. package/extensions/orchestrator/utils.ts +8 -5
  59. package/extensions/pidash/pidash.ts +193 -156
  60. package/extensions/pidiff/pidiff.ts +96 -38
  61. package/extensions/pidiff/spawn-lock.ts +62 -0
  62. package/extensions/pitasks/index.ts +430 -0
  63. package/extensions/pitasks/reminders.ts +37 -0
  64. package/extensions/pitasks/task-store.ts +467 -0
  65. package/extensions/pitasks/task-tools.ts +375 -0
  66. package/extensions/pitasks/task-widget.ts +181 -0
  67. package/extensions/pitasks/types.ts +38 -0
  68. package/extensions/providers/acpx-driver.ts +12 -2
  69. package/extensions/providers/built-in-drivers.ts +6 -6
  70. package/extensions/providers/claude-driver.ts +1 -1
  71. package/extensions/providers/cursor-cli-driver.ts +1 -1
  72. package/extensions/providers/gemini-driver.ts +1 -1
  73. package/extensions/providers/index.ts +144 -26
  74. package/extensions/providers/initialized-guard.ts +27 -0
  75. package/extensions/providers/restore-default-model.ts +596 -0
  76. package/extensions/providers/session-shutdown.ts +39 -0
  77. package/extensions/shared/coms-active.ts +23 -0
  78. package/extensions/shared/create-runtime-provider.ts +2 -0
  79. package/extensions/shared/daemon-manager.ts +0 -8
  80. package/extensions/shared/file-logger.ts +132 -25
  81. package/extensions/shared/logger.ts +50 -0
  82. package/extensions/shared/models-dev.ts +421 -0
  83. package/extensions/shared/oneshot.ts +116 -0
  84. package/extensions/shared/resolve-binary.ts +191 -0
  85. package/extensions/shared/types.ts +3 -0
  86. package/package.json +9 -3
  87. package/prompts/coderabbit-rate-limit.md +2 -0
  88. package/rules/00-orchestrator-core.md +3 -0
  89. package/rules/10-agent-routing.md +12 -1
  90. package/rules/20-code-review-loop.md +52 -25
  91. package/rules/30-prompt-templates.md +4 -0
  92. package/rules/35-memory.md +3 -3
  93. package/rules/40-critical-rules.md +3 -0
  94. package/rules/55-coms-protocol.md +84 -18
  95. package/rules/60-task-tracking.md +2 -0
  96. package/settings-keys.json +563 -0
  97. package/templates/coms-feature-manager-prompt.md +4 -6
  98. package/extensions/coms/coms-net-server.ts +0 -1642
  99. package/extensions/coms/coms-net-wrapper.ts +0 -521
  100. package/extensions/coms/coms-net.ts +0 -1589
@@ -1,5 +1,5 @@
1
1
  /**
2
- * coms-shared.ts — Shared utilities for coms and coms-net wrappers.
2
+ * coms-shared.ts — Shared utilities for coms wrapper.
3
3
  *
4
4
  * Handles reload resilience: when coms is active and the user runs /reload,
5
5
  * session_shutdown fires (upstream cleans up the old socket/connection),
@@ -13,6 +13,10 @@ import * as net from "node:net";
13
13
  import * as path from "node:path";
14
14
  import * as crypto from "node:crypto";
15
15
  import * as os from "node:os";
16
+ import { setComsActive } from "../shared/coms-active.js";
17
+ import { createLogger } from "../shared/logger.js";
18
+
19
+ const log = createLogger("coms");
16
20
 
17
21
  /**
18
22
  * Tokenize a command string respecting double and single quotes.
@@ -73,7 +77,7 @@ export interface DeferredUpstream {
73
77
  flagValues: Map<string, any>;
74
78
  /** Whether the upstream extension is active */
75
79
  active: boolean;
76
- /** Extra persisted state (e.g., serverStartedByUs for coms-net) */
80
+ /** Extra persisted state */
77
81
  extra?: Record<string, any>;
78
82
  }
79
83
 
@@ -101,11 +105,33 @@ export function createDeferredProxy(
101
105
  case 'getFlag':
102
106
  return (name: string) => state.flagValues.get(name);
103
107
  case 'registerCommand':
104
- return () => {};
108
+ // Upstream /coms is owned by the wrapper — swallow it.
109
+ // Pass through utility commands (e.g. /coms-queue) so they
110
+ // are always available on the real pi, with an active-gate in the handler.
111
+ return (name: string, def: any) => {
112
+ if (name === 'coms') return;
113
+ const origHandler = def?.handler;
114
+ if (typeof origHandler === 'function') {
115
+ def.handler = async (args: string, ctx: any) => {
116
+ if (!state.active) {
117
+ try {
118
+ ctx.ui.notify(
119
+ name.includes('net')
120
+ ? '📡 coms not active — no queue. Run /coms start first.'
121
+ : '📡 coms not active — no queue. Run /coms start first.',
122
+ 'info',
123
+ );
124
+ } catch { /* ignore */ }
125
+ return;
126
+ }
127
+ return origHandler(args, ctx);
128
+ };
129
+ }
130
+ return target.registerCommand(name, def);
131
+ };
105
132
  case 'registerTool':
106
133
  return (tool: any) => {
107
134
  // Inject anti-loop warning into coms_send description
108
- // (upstream coms_net_send has this but coms_send doesn't)
109
135
  if (tool.name === "coms_send" && tool.description && !tool.description.includes("DO NOT")) {
110
136
  tool.description +=
111
137
  "\n\n\u26a0\ufe0f DO NOT call this tool to REPLY to an inbound message. " +
@@ -161,8 +187,14 @@ export function createDeferredProxy(
161
187
  try {
162
188
  await handler(evt, ctx);
163
189
  state.active = true;
190
+ setComsActive(true);
164
191
  } catch (err) {
165
- console.error(`[coms] reload reactivation failed:`, err);
192
+ state.active = false;
193
+ setComsActive(false);
194
+ persistState(target, persistKey, state);
195
+ const msg = err instanceof Error ? err.message : String(err);
196
+ const error = err instanceof Error ? err : new Error(msg);
197
+ log.error("reload reactivation failed", msg, error);
166
198
  }
167
199
  }
168
200
  });
@@ -199,21 +231,21 @@ export function persistState(pi: ExtensionAPI, persistKey: string, state: Deferr
199
231
 
200
232
  /**
201
233
  * Prune stale coms registry entries on startup — removes entries with dead PIDs.
202
- * Call from session_start in both coms.ts and coms-net.ts.
234
+ * Call from session_start in coms.ts.
203
235
  */
204
236
  let _pruned = false;
205
237
  /**
206
238
  * Prune stale coms registry entries — non-blocking.
207
- * Runs once per session (coms + coms-net both call this).
239
+ * Runs once per session.
208
240
  */
209
- export function pruneStaleRegistry(): void {
241
+ export function pruneStaleRegistry(comsDir?: string): void {
210
242
  if (_pruned) return;
211
243
  _pruned = true;
244
+ const resolvedComsDir = comsDir || path.join(os.homedir(), ".pi", "coms");
212
245
  // Run async to avoid blocking session_start
213
246
  setImmediate(() => {
214
247
  try {
215
- const comsDir = process.env.PI_COMS_DIR || path.join(os.homedir(), ".pi", "coms");
216
- const projectsDir = path.join(comsDir, "projects");
248
+ const projectsDir = path.join(resolvedComsDir, "projects");
217
249
  if (!fs.existsSync(projectsDir)) return;
218
250
  let dirs: string[];
219
251
  try { dirs = fs.readdirSync(projectsDir); } catch { return; }
@@ -280,7 +312,7 @@ export function pruneStaleRegistry(): void {
280
312
  } catch { /* skip unreadable project directory */ }
281
313
  }
282
314
  // Cleanup orphan sockets (no matching registry entry)
283
- const socketsDir = path.join(comsDir, "sockets");
315
+ const socketsDir = path.join(resolvedComsDir, "sockets");
284
316
  if (fs.existsSync(socketsDir)) {
285
317
  try {
286
318
  const allEndpoints = new Set<string>();
@@ -316,7 +348,7 @@ export function pruneStaleRegistry(): void {
316
348
  });
317
349
  }
318
350
 
319
- // ━━ Shared helpers (used by both coms-p2p.ts and coms-net.ts) ━━━━━━━━━━━━━━
351
+ // ━━ Shared helpers (used by coms-p2p.ts) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
320
352
 
321
353
  export const FALLBACK_PALETTE = [
322
354
  "#72F1B8", "#36F9F6", "#FF7EDB", "#FEDE5D",
@@ -429,40 +461,48 @@ export function readFrontmatterFromArgv(argv: string[]): { name?: string; descri
429
461
  }
430
462
  }
431
463
 
432
- export function readTaskSummary(cwd: string, sessionId?: string): { total: number; completed: number; in_progress: number } | null {
464
+ export function readTaskSummary(_cwd: string, _sessionId?: string): { total: number; completed: number; in_progress: number } | null {
433
465
  try {
434
- const tasksDir = path.join(cwd, ".pi", "tasks");
435
- if (!fs.existsSync(tasksDir)) return null;
436
- const candidates = sessionId
437
- ? [`tasks-${sessionId}.json`, "tasks.json"]
438
- : ["tasks.json"];
439
- for (const candidate of candidates) {
440
- const filePath = path.join(tasksDir, candidate);
441
- if (!fs.existsSync(filePath)) continue;
442
- try {
443
- const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
444
- if (!Array.isArray(data.tasks) || data.tasks.length === 0) continue;
445
- let total = 0, completed = 0, in_progress = 0;
446
- for (const t of data.tasks) {
447
- total++;
448
- if (t.status === "completed") completed++;
449
- else if (t.status === "in_progress") in_progress++;
450
- }
451
- return { total, completed, in_progress };
452
- } catch { /* skip corrupt file */ }
466
+ const { listTasks } = require("../pitasks/index.js");
467
+ const tasks = listTasks();
468
+ if (!tasks || tasks.length === 0) return null;
469
+ let total = 0, completed = 0, in_progress = 0;
470
+ for (const t of tasks) {
471
+ total++;
472
+ if (t.status === "completed") completed++;
473
+ else if (t.status === "in_progress") in_progress++;
453
474
  }
454
- return null;
475
+ return { total, completed, in_progress };
455
476
  } catch { return null; }
456
477
  }
457
478
 
479
+ /** Build the customType string for coms inbound display (shown as the header by pi).
480
+ * Sanitizes all inputs since senderName/senderCwd come from untrusted peers. */
481
+ export function formatComsInboundType(senderName: string, selfName: string, senderCwd: string): string {
482
+ const safeSender = sanitizeComsName(senderName);
483
+ const safeSelf = sanitizeComsName(selfName);
484
+ const safeCwd = senderCwd.replace(/[\x00-\x1f\x7f-\x9f\[\]]/g, "").trim() || "?";
485
+ return `from ${safeSender} → ${safeSelf} @ ${safeCwd}`;
486
+ }
487
+
458
488
  export function buildInboundContent(
459
489
  header: string,
460
490
  prompt: string,
461
491
  tasks?: Array<{ subject: string; description: string }> | null,
492
+ senderName?: string,
493
+ senderCwd?: string,
462
494
  ): string {
463
- let content = `${header}\n\n${prompt}`;
495
+ // Sanitize peer-controlled values — strip control chars and limit length
496
+ const safeName = senderName ? senderName.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 100) : undefined;
497
+ const safeCwd = senderCwd ? senderCwd.replace(/[\x00-\x1f\x7f]/g, '').slice(0, 200) : undefined;
498
+ // Machine-readable prefix so the LLM can distinguish coms messages from user input
499
+ const fromTag = safeName ? `[from ${safeName}${safeCwd ? ` @ ${safeCwd}` : ''}] ` : '';
500
+ let content = header ? `${fromTag}${header}\n\n${prompt}` : `${fromTag}${prompt}`;
501
+ if (safeName) {
502
+ content += `\n\nReply to me via coms_send(target="${safeName}")`;
503
+ }
464
504
  if (tasks && tasks.length > 0) {
465
- content += `\n\n## Assigned Tasks\n\nThe sender has assigned you these tasks. Use TaskCreate to add them to your task list, then work through them:\n\n`;
505
+ content += `\n\n## Assigned Tasks\n\nThese tasks have been added to your task list. Work through them in order:\n\n`;
466
506
  for (const task of tasks) {
467
507
  content += `- **${task.subject}**: ${task.description}\n`;
468
508
  }
@@ -477,13 +517,47 @@ export interface TasksSummary {
477
517
  }
478
518
 
479
519
  /** Format the auto-delivered coms response text, including queued message IDs when > 0. */
480
- export function formatComsResponseText(targetName: string, response: any, error: string | null, queuedMsgIds: string[]): string {
520
+ /** Build the customType string for coms response display (shown as the header by pi). */
521
+ export function formatComsResponseType(targetName: string, selfName?: string, queuedMsgIds?: string[]): string {
522
+ const safeTarget = sanitizeComsName(targetName);
523
+ const arrow = selfName ? ` → ${sanitizeComsName(selfName)}` : "";
524
+ const ids = (queuedMsgIds ?? []).length <= 5
525
+ ? (queuedMsgIds ?? []).join(", ")
526
+ : `${(queuedMsgIds ?? []).slice(0, 5).join(", ")} and ${(queuedMsgIds ?? []).length - 5} more`;
527
+ const queueNote = (queuedMsgIds ?? []).length > 0 ? ` (${(queuedMsgIds ?? []).length} more queued: ${ids})` : "";
528
+ return `coms response from ${safeTarget}${arrow}${queueNote}`;
529
+ }
530
+
531
+ /** Format the coms response content body (without the header — header is in customType). */
532
+ export function formatComsResponseBody(response: any, error: string | null): string {
533
+ if (error) return `Error: ${error}`;
534
+ if (typeof response === "string") return response;
535
+ if (response === undefined || response === null) return "(no response)";
536
+ return JSON.stringify(response, null, 2) ?? String(response);
537
+ }
538
+
539
+ /**
540
+ * Format the full auto-delivered coms response text, including queued message IDs.
541
+ * @deprecated Use formatComsResponseType + formatComsResponseBody instead for customType display.
542
+ */
543
+ export function formatComsResponseText(targetName: string, response: any, error: string | null, queuedMsgIds: string[], selfName?: string): string {
481
544
  const ids = queuedMsgIds.length <= 5 ? queuedMsgIds.join(", ") : `${queuedMsgIds.slice(0, 5).join(", ")} and ${queuedMsgIds.length - 5} more`;
482
545
  const queueNote = queuedMsgIds.length > 0 ? ` (${queuedMsgIds.length} more queued: ${ids})` : "";
483
- if (error) return `[coms response from ${targetName}${queueNote}] Error: ${error}`;
546
+ const arrow = selfName ? ` → ${selfName}` : "";
547
+ if (error) return `[coms response from ${targetName}${arrow}${queueNote}] Error: ${error}`;
484
548
  return typeof response === "string"
485
- ? `[coms response from ${targetName}${queueNote}] ${response}`
486
- : `[coms response from ${targetName}${queueNote}] ${JSON.stringify(response, null, 2)}`;
549
+ ? `[coms response from ${targetName}${arrow}${queueNote}] ${response}`
550
+ : `[coms response from ${targetName}${arrow}${queueNote}] ${JSON.stringify(response, null, 2)}`;
551
+ }
552
+
553
+ /** Sanitize a coms identity name for safe embedding in header strings.
554
+ * Strips all control characters (C0/C1), brackets; replaces whitespace with hyphens. */
555
+ export function sanitizeComsName(name: string): string {
556
+ return name
557
+ .replace(/[\x00-\x1f\x7f-\x9f\[\]]/g, "") // strip C0/C1 control chars + brackets
558
+ .trim()
559
+ .replace(/\s+/g, "-") // whitespace → hyphens (keep as single token)
560
+ || "?";
487
561
  }
488
562
 
489
563
  /** Return " 📨N" suffix string for queue depth, or "" when zero/absent. */
@@ -508,3 +582,151 @@ export function renderTasksPart(tasks: TasksSummary | null | undefined, theme: a
508
582
  if (pending > 0) parts.push(theme.fg("dim", `${pending}◻`));
509
583
  return theme.fg("dim", " ") + parts.join(theme.fg("dim", " "));
510
584
  }
585
+
586
+ /** Minimal branch entry shape needed by detectMixedTurn. */
587
+ export interface BranchEntry {
588
+ type: string;
589
+ message?: { role: string };
590
+ customType?: string;
591
+ details?: { msg_id?: string };
592
+ }
593
+
594
+ /**
595
+ * Detect whether a user message appeared in the current turn alongside
596
+ * a coms inbound injection — indicating the assistant's response may be
597
+ * directed at the user, not the peer (#731).
598
+ *
599
+ * Walks the branch backwards: skips trailing assistant messages, then
600
+ * checks if a user message appears before reaching the specific coms
601
+ * inbound injection (identified by customType + msg_id).
602
+ *
603
+ * @param branch The session branch array (oldest → newest).
604
+ * @param inboundMsgId The msg_id of the currently-processing inbound.
605
+ * @param inboundCustomType The customType of the coms inbound injection
606
+ * ("coms-inbound" for p2p).
607
+ * @returns true if a user message was found between the last assistant
608
+ * message and the coms inbound injection (mixed turn).
609
+ */
610
+ export function detectMixedTurn(
611
+ branch: ReadonlyArray<BranchEntry>,
612
+ inboundMsgId: string,
613
+ inboundCustomType: string,
614
+ ): boolean {
615
+ let passedAssistant = false;
616
+ for (let i = branch.length - 1; i >= 0; i--) {
617
+ const entry = branch[i];
618
+ if (entry.type === "message" && entry.message?.role === "assistant") {
619
+ passedAssistant = true;
620
+ continue;
621
+ }
622
+ if (!passedAssistant) continue; // still in trailing tool results etc.
623
+ if (entry.type === "custom_message" && entry.customType === inboundCustomType
624
+ && entry.details?.msg_id === inboundMsgId) {
625
+ // Reached the coms inbound injection for this message — no user message in between.
626
+ return false;
627
+ }
628
+ if (entry.type === "message" && entry.message?.role === "user") {
629
+ return true;
630
+ }
631
+ }
632
+ return false;
633
+ }
634
+
635
+ // ── Task store integration (direct import from owned pitasks) ──────────
636
+
637
+ export interface ComsOrigin {
638
+ sender_session: string;
639
+ sender_name: string;
640
+ sender_endpoint?: string;
641
+ }
642
+
643
+ /** Max tasks accepted from a single inbound message to prevent abuse. */
644
+ const MAX_INBOUND_TASKS = 20;
645
+ /** Max length for task subject/description fields. */
646
+ const MAX_TASK_FIELD_LEN = 2000;
647
+
648
+ /**
649
+ * Auto-create tasks on a peer's task store via owned TaskStore API.
650
+ * Runs on the SENDER side — uses createTaskForSession with the target's session ID.
651
+ * TaskStore handles locking, file format, everything.
652
+ */
653
+ export function createComsInboundTasks(
654
+ tasks: Array<{ subject: string; description: string }>,
655
+ origin: ComsOrigin,
656
+ targetSessionId: string,
657
+ targetCwd?: string,
658
+ ): number {
659
+ if (!tasks || tasks.length === 0 || !targetSessionId) return 0;
660
+
661
+ let createTaskForSession: any;
662
+ try {
663
+ createTaskForSession = require("../pitasks/index.js").createTaskForSession;
664
+ } catch { return 0; }
665
+ if (!createTaskForSession) return 0;
666
+
667
+ let created = 0;
668
+ const capped = tasks.slice(0, MAX_INBOUND_TASKS);
669
+ for (const task of capped) {
670
+ const subject = typeof task.subject === "string" ? task.subject.slice(0, MAX_TASK_FIELD_LEN) : "";
671
+ const description = typeof task.description === "string" ? task.description.slice(0, MAX_TASK_FIELD_LEN) : "";
672
+ if (!subject) continue;
673
+ try {
674
+ const createdBy = {
675
+ type: "coms" as const,
676
+ origin: origin.sender_name,
677
+ session: origin.sender_session,
678
+ project: "",
679
+ };
680
+ const meta = origin.sender_endpoint ? { sender_endpoint: origin.sender_endpoint } : undefined;
681
+ createTaskForSession(targetSessionId, subject, description, createdBy, meta, targetCwd);
682
+ created++;
683
+ } catch { /* best-effort */ }
684
+ }
685
+ return created;
686
+ }
687
+
688
+ /**
689
+ * Look up a task by ID and return it if created by a coms peer.
690
+ */
691
+ export async function getComsOriginTask(
692
+ taskId: string,
693
+ ): Promise<{ subject: string; createdBy: { session: string; name: string; endpoint?: string } } | null> {
694
+ if (!taskId || taskId === "-1") return null;
695
+ try {
696
+ const { getTask } = await import("../pitasks/index.js");
697
+ const task = getTask(taskId);
698
+ if (task?.createdBy?.type === "coms") {
699
+ return {
700
+ subject: task.subject,
701
+ createdBy: {
702
+ session: task.createdBy.session,
703
+ name: task.createdBy.origin,
704
+ endpoint: task.metadata?.sender_endpoint,
705
+ },
706
+ };
707
+ }
708
+ } catch { /* ignore */ }
709
+ return null;
710
+ }
711
+
712
+ /**
713
+ * Get all tasks created by coms peers (for keepalive heartbeat).
714
+ */
715
+ export async function getComsOriginTasks(): Promise<Array<{ taskId: string; subject: string; status: string; createdBy: { session: string; name: string; endpoint?: string } }>> {
716
+ try {
717
+ const { listTasks } = await import("../pitasks/index.js");
718
+ const tasks = listTasks();
719
+ return tasks
720
+ .filter((t: any) => t.createdBy?.type === "coms" && t.status !== "deleted")
721
+ .map((t: any) => ({
722
+ taskId: t.id,
723
+ subject: t.subject,
724
+ status: t.status,
725
+ createdBy: {
726
+ session: t.createdBy.session,
727
+ name: t.createdBy.origin,
728
+ endpoint: t.metadata?.sender_endpoint,
729
+ },
730
+ }));
731
+ } catch { return []; }
732
+ }
@@ -8,8 +8,15 @@
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
9
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
10
10
  import { fuzzyFilter } from "@earendil-works/pi-tui";
11
+ import * as path from "node:path";
12
+ import * as os from "node:os";
11
13
  import { parseFlags, tokenizeArgs, createDeferredProxy, persistState, pruneStaleRegistry, type DeferredUpstream } from "./coms-shared.js";
12
14
  import upstreamComsInit from "./coms-p2p.js";
15
+ import { getSetting } from "../orchestrator/project-settings.js";
16
+ import { setComsActive } from "../shared/coms-active.js";
17
+ import { createLogger } from "../shared/logger.js";
18
+
19
+ const log = createLogger("coms");
13
20
 
14
21
  function fuzzy(items: AutocompleteItem[], query: string): AutocompleteItem[] | null {
15
22
  if (!query.trim()) return items.length > 0 ? items : null;
@@ -35,14 +42,94 @@ export function registerComs(pi: ExtensionAPI) {
35
42
 
36
43
  // Prune stale registry entries on session start (cleans up after crashes)
37
44
  // Also reset coms state on fresh starts (non-reload) to clear phantom peers
38
- pi.on("session_start", (evt: any) => {
39
- try { pruneStaleRegistry(); } catch (e: any) { console.debug("[coms] stale cleanup:", e?.message?.slice(0, 100)); }
45
+ pi.on("session_start", (evt: any, ctx: any) => {
46
+ try {
47
+ const cwd = ctx?.cwd || process.cwd();
48
+ const comsDir = getSetting(cwd, "coms_dir") || path.join(os.homedir(), ".pi", "coms");
49
+ pruneStaleRegistry(comsDir);
50
+ } catch (e: any) {
51
+ const err = e instanceof Error ? e : new Error(String(e?.message ?? e));
52
+ log.warn("stale cleanup failed", err.message?.slice(0, 100), err);
53
+ }
40
54
  if (evt?.reason !== "reload") {
41
55
  state.active = false;
56
+ setComsActive(false);
42
57
  persistState(pi, PERSIST_KEY, state);
43
58
  }
44
59
  });
45
60
 
61
+ const comsCmdHandler = async (args: string, ctx: any) => {
62
+ const trimmed = (args || "").trim();
63
+ const parts = tokenizeArgs(trimmed);
64
+ const subcommand = parts[0] || "status";
65
+
66
+ if (subcommand === "start") {
67
+ if (state.active) {
68
+ try { ctx.ui.notify("📡 coms already active", "warning"); } catch {}
69
+ return;
70
+ }
71
+ state.flagValues = new Map();
72
+ parseFlags(parts.slice(1), state.flagValues);
73
+
74
+ if (state.flagValues.has("name")) {
75
+ try { ctx.ui.notify("📡 coms: use --cname instead of --name", "error"); } catch {}
76
+ return;
77
+ }
78
+
79
+ // Default project to cwd so sessions in different dirs are isolated
80
+ if (!state.flagValues.has("project")) {
81
+ const cwd = ctx.cwd || "";
82
+ const proj = cwd.replace(/^[\\/]+/,"").replace(/[\\/]+/g, "__");
83
+ if (!proj) {
84
+ try { ctx.ui.notify("📡 coms: cannot start from /. Run from a project directory.", "error"); } catch {}
85
+ return;
86
+ }
87
+ state.flagValues.set("project", proj);
88
+ }
89
+
90
+ if (!state.capturedSessionStart) {
91
+ try { ctx.ui.notify("📡 coms: internal error — no session handler captured", "error"); } catch {}
92
+ return;
93
+ }
94
+
95
+ try {
96
+ await state.capturedSessionStart({}, ctx);
97
+ state.active = true;
98
+ setComsActive(true);
99
+ persistState(pi, PERSIST_KEY, state);
100
+ } catch (err: any) {
101
+ setComsActive(false);
102
+ const msg = err?.message ?? String(err);
103
+ const error = err instanceof Error ? err : new Error(msg);
104
+ log.error("/coms start failed", msg, error);
105
+ try { ctx.ui.notify(`📡 coms start failed: ${msg}`, "error"); } catch {}
106
+ }
107
+ } else if (subcommand === "stop") {
108
+ if (!state.active) {
109
+ try { ctx.ui.notify("📡 coms not active", "info"); } catch {}
110
+ return;
111
+ }
112
+ if (state.capturedSessionShutdown) {
113
+ try {
114
+ await state.capturedSessionShutdown();
115
+ } catch (err: any) {
116
+ const msg = err?.message ?? String(err);
117
+ const error = err instanceof Error ? err : new Error(msg);
118
+ log.warn("/coms stop: session shutdown threw", msg, error);
119
+ }
120
+ }
121
+ state.active = false;
122
+ setComsActive(false);
123
+ persistState(pi, PERSIST_KEY, state);
124
+ try { ctx.ui.notify("📡 coms stopped", "info"); } catch {}
125
+ } else if (subcommand === "status") {
126
+ try {
127
+ ctx.ui.notify(state.active ? "📡 coms: active (P2P)" : "📡 coms: inactive — run /coms start", "info");
128
+ } catch {}
129
+ } else {
130
+ try { ctx.ui.notify(`📡 coms: unknown subcommand "${subcommand}". Use: start | stop | status`, "warning"); } catch {}
131
+ }
132
+ };
46
133
  pi.registerCommand("coms", {
47
134
  description: "P2P agent communication: /coms start | stop | status",
48
135
  getArgumentCompletions: (prefix: string) => {
@@ -74,65 +161,16 @@ export function registerComs(pi: ExtensionAPI) {
74
161
  }
75
162
  return null;
76
163
  },
77
- handler: async (args: string, ctx: any) => {
78
- const trimmed = (args || "").trim();
79
- const parts = tokenizeArgs(trimmed);
80
- const subcommand = parts[0] || "status";
81
-
82
- if (subcommand === "start") {
83
- if (state.active) {
84
- try { ctx.ui.notify("📡 coms already active", "warning"); } catch {}
85
- return;
86
- }
87
- state.flagValues = new Map();
88
- parseFlags(parts.slice(1), state.flagValues);
89
-
90
- if (state.flagValues.has("name")) {
91
- try { ctx.ui.notify("📡 coms: use --cname instead of --name", "error"); } catch {}
92
- return;
93
- }
94
-
95
- // Default project to cwd so sessions in different dirs are isolated
96
- if (!state.flagValues.has("project")) {
97
- const cwd = ctx.cwd || "";
98
- const proj = cwd.replace(/^[\\/]/,"").replace(/[\\/]/g, "__");
99
- if (!proj) {
100
- try { ctx.ui.notify("📡 coms: cannot start from /. Run from a project directory.", "error"); } catch {}
101
- return;
102
- }
103
- state.flagValues.set("project", proj);
104
- }
105
-
106
- if (!state.capturedSessionStart) {
107
- try { ctx.ui.notify("📡 coms: internal error — no session handler captured", "error"); } catch {}
108
- return;
109
- }
110
-
111
- try {
112
- await state.capturedSessionStart({}, ctx);
113
- state.active = true;
114
- persistState(pi, PERSIST_KEY, state);
115
- } catch (err: any) {
116
- try { ctx.ui.notify(`📡 coms start failed: ${err?.message ?? String(err)}`, "error"); } catch {}
117
- }
118
- } else if (subcommand === "stop") {
119
- if (!state.active) {
120
- try { ctx.ui.notify("📡 coms not active", "info"); } catch {}
121
- return;
122
- }
123
- if (state.capturedSessionShutdown) {
124
- try { await state.capturedSessionShutdown(); } catch {}
125
- }
126
- state.active = false;
127
- persistState(pi, PERSIST_KEY, state);
128
- try { ctx.ui.notify("📡 coms stopped", "info"); } catch {}
129
- } else if (subcommand === "status") {
130
- try {
131
- ctx.ui.notify(state.active ? "📡 coms: active (P2P)" : "📡 coms: inactive — run /coms start", "info");
132
- } catch {}
133
- } else {
134
- try { ctx.ui.notify(`📡 coms: unknown subcommand "${subcommand}". Use: start | stop | status`, "warning"); } catch {}
135
- }
136
- },
164
+ handler: comsCmdHandler,
137
165
  });
166
+ // Expose handler to pidash for browser command dispatch
167
+ try {
168
+ if ((globalThis as any).__coms_pidash_listener) {
169
+ try { pi.events.removeListener("pidash:request-commands", (globalThis as any).__coms_pidash_listener); } catch {}
170
+ }
171
+ const registerWithPidash = () => pi.events.emit("pidash:register-command", { name: "coms", handler: comsCmdHandler });
172
+ (globalThis as any).__coms_pidash_listener = registerWithPidash;
173
+ registerWithPidash();
174
+ pi.events.on("pidash:request-commands", registerWithPidash);
175
+ } catch {}
138
176
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Coms Extension for pi
3
3
  *
4
- * Inter-agent communication — P2P (coms) and networked (coms-net).
5
- * Activated on-demand via /coms and /coms-net slash commands.
4
+ * Inter-agent communication — P2P (coms).
5
+ * Activated on-demand via /coms slash command.
6
6
  *
7
7
  * Standalone extension so it can be loaded independently by pi-sidecar
8
8
  * without pulling in the full orchestrator.
@@ -11,10 +11,14 @@
11
11
  */
12
12
 
13
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
+ import { createLogger } from "../shared/logger.js";
15
+ import { shouldSkipOneshotRegister } from "../shared/oneshot.js";
14
16
  import { registerComs } from "./coms-wrapper.js";
15
- import { registerComsNet } from "./coms-net-wrapper.js";
17
+
18
+ const log = createLogger("coms");
16
19
 
17
20
  export default function (pi: ExtensionAPI) {
21
+ if (shouldSkipOneshotRegister(log)) return;
22
+ log.debug("register coms");
18
23
  registerComs(pi);
19
- registerComsNet(pi);
20
24
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * extensions/coms/mixed-turn.ts — Pure helpers for coms mixed-turn detection.
3
+ * Kept dependency-free so both the coms extension handlers and unit tests can
4
+ * import the SAME production logic (no mirrored copies). The shared logger is
5
+ * dependency-light (node builtins only via file-logger) so importing it here
6
+ * does not pull in pi-tui and keeps the module test-importable — the same
7
+ * pattern used by extensions/pitasks/reminders.ts.
8
+ */
9
+
10
+ import { createLogger } from "../shared/logger.js";
11
+
12
+ const log = createLogger("coms-mixed-turn");
13
+
14
+ /** True when a real user message (role "user") arrives while a coms inbound turn is being processed (#741). */
15
+ export function isUserMessageDuringInbound(processingInbound: boolean, role: string | undefined): boolean {
16
+ const result = processingInbound === true && role === "user";
17
+ log.debug("isUserMessageDuringInbound", processingInbound, role, result);
18
+ return result;
19
+ }
20
+
21
+ /** True when the turn must be handled as a mixed turn (re-inject the inbound instead of auto-capturing assistant text). */
22
+ export function computeMixedTurn(inboundSetDuringUserTurn: boolean, userMessageDuringInbound: boolean): boolean {
23
+ const result = inboundSetDuringUserTurn || userMessageDuringInbound;
24
+ log.debug("computeMixedTurn", inboundSetDuringUserTurn, userMessageDuringInbound, result);
25
+ return result;
26
+ }