@selesai/code 0.13.2 → 0.13.4

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 (120) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/defaults/models.json +85 -13
  3. package/dist/extensions/cost-reconcile.test.ts +200 -4
  4. package/dist/extensions/cost-reconcile.ts +88 -102
  5. package/dist/extensions/pi-intercom/CHANGELOG.md +13 -0
  6. package/dist/extensions/pi-intercom/README.md +4 -5
  7. package/dist/extensions/pi-intercom/config.test.ts +3 -31
  8. package/dist/extensions/pi-intercom/config.ts +0 -15
  9. package/dist/extensions/pi-intercom/index.ts +9 -46
  10. package/dist/extensions/pi-intercom/intercom.integration.test.ts +49 -57
  11. package/dist/extensions/pi-intercom/package.json +1 -1
  12. package/dist/extensions/pi-intercom/reply-tracker.test.ts +20 -0
  13. package/dist/extensions/pi-intercom/reply-tracker.ts +8 -0
  14. package/dist/extensions/pi-subagents/CHANGELOG.md +27 -0
  15. package/dist/extensions/pi-subagents/docs/tool-reference.md +4 -1
  16. package/dist/extensions/pi-subagents/docs/workflows.md +2 -2
  17. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  18. package/dist/extensions/pi-subagents/package.json +1 -1
  19. package/dist/extensions/pi-subagents/skills/council-mode/SKILL.md +48 -243
  20. package/dist/extensions/pi-subagents/skills/council-mode/references/pass-contracts.md +150 -0
  21. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +87 -37
  22. package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +29 -233
  23. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +49 -8
  24. package/dist/extensions/pi-subagents/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  25. package/dist/extensions/pi-subagents/skills/pi-subagents/references/multi-lane-orchestration.md +13 -1
  26. package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +34 -27
  27. package/dist/extensions/pi-subagents/skills/pi-subagents/references/review-and-validation.md +73 -0
  28. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +157 -28
  29. package/dist/extensions/pi-subagents/src/api/shared-types.ts +2 -0
  30. package/dist/extensions/pi-subagents/src/extension/public-execution.ts +1 -0
  31. package/dist/extensions/pi-subagents/src/extension/schemas.ts +1 -0
  32. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +4 -1
  33. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +2 -2
  34. package/dist/extensions/pi-subagents/src/runs/background/async-job-tracker.ts +3 -0
  35. package/dist/extensions/pi-subagents/src/runs/background/async-status.ts +45 -2
  36. package/dist/extensions/pi-subagents/src/runs/background/control-channel.ts +3 -2
  37. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +13 -2
  38. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +5 -1
  39. package/dist/extensions/pi-subagents/src/runs/background/subagent-wait.ts +10 -2
  40. package/dist/extensions/pi-subagents/src/runs/background/wait-completions.ts +3 -0
  41. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +11 -2
  42. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +98 -1
  43. package/dist/extensions/pi-subagents/src/runs/shared/async-status-projection.ts +138 -4
  44. package/dist/extensions/pi-subagents/src/runs/shared/background-process-options.ts +9 -0
  45. package/dist/extensions/pi-subagents/src/runs/shared/mcp-direct-tool-grant.ts +2 -5
  46. package/dist/extensions/pi-subagents/src/runs/shared/mutation-evidence.ts +52 -3
  47. package/dist/extensions/pi-subagents/src/runs/shared/pi-args.ts +47 -1
  48. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +45 -18
  49. package/dist/extensions/pi-subagents/src/runs/shared/subagent-prompt-runtime.ts +21 -2
  50. package/dist/extensions/pi-subagents/src/runs/shared/workflow-graph.ts +15 -0
  51. package/dist/extensions/pi-subagents/src/shared/types.ts +34 -1
  52. package/dist/extensions/pi-subagents/src/tui/fleet-status.ts +11 -3
  53. package/dist/extensions/pi-subagents/src/tui/render-helpers.ts +31 -0
  54. package/dist/extensions/pi-subagents/src/tui/render.ts +597 -112
  55. package/dist/extensions/pi-subagents/src/watchdog/change-signature.ts +40 -1
  56. package/dist/extensions/pi-subagents/src/workflows/host-command.ts +6 -1
  57. package/dist/extensions/pi-subagents/src/workflows/scripted-workflow.ts +53 -2
  58. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +55 -6
  59. package/dist/extensions/pi-subagents/test/integration/async-status.test.ts +111 -1
  60. package/dist/extensions/pi-subagents/test/integration/render-fork-badge.test.ts +206 -38
  61. package/dist/extensions/pi-subagents/test/integration/render-widget.test.ts +522 -31
  62. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +123 -0
  63. package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +48 -0
  64. package/dist/extensions/pi-subagents/test/unit/async-status-projection.test.ts +57 -1
  65. package/dist/extensions/pi-subagents/test/unit/background-process-options.test.ts +17 -0
  66. package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +1 -1
  67. package/dist/extensions/pi-subagents/test/unit/fleet-status.test.ts +44 -4
  68. package/dist/extensions/pi-subagents/test/unit/fork-cache-key.test.ts +91 -0
  69. package/dist/extensions/pi-subagents/test/unit/host-command.test.ts +1 -0
  70. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +0 -1
  71. package/dist/extensions/pi-subagents/test/unit/mcp-direct-tool-grant.test.ts +20 -3
  72. package/dist/extensions/pi-subagents/test/unit/mutation-evidence.test.ts +27 -0
  73. package/dist/extensions/pi-subagents/test/unit/pi-args.test.ts +93 -17
  74. package/dist/extensions/pi-subagents/test/unit/public-execution.test.ts +1 -0
  75. package/dist/extensions/pi-subagents/test/unit/render-helpers.test.ts +103 -26
  76. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +58 -0
  77. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +22 -2
  78. package/dist/extensions/pi-subagents/test/unit/scripted-workflow.test.ts +21 -0
  79. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +13 -0
  80. package/dist/extensions/pi-subagents/test/unit/subagent-wait.test.ts +54 -0
  81. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +2 -0
  82. package/dist/extensions/pi-subagents/test/unit/wait-completions.test.ts +32 -0
  83. package/dist/extensions/pi-subagents/test/unit/watchdog-change-signature.test.ts +48 -1
  84. package/dist/extensions/pi-subagents/test/unit/widget-nested-render.test.ts +21 -6
  85. package/dist/extensions/pi-subagents/test/unit/windows-hide-spawn.test.ts +11 -0
  86. package/dist/extensions/pi-web-agent/CHANGELOG.md +410 -0
  87. package/dist/extensions/pi-web-agent/README.md +131 -0
  88. package/dist/extensions/pi-web-agent/package.json +4 -2
  89. package/dist/extensions/pi-web-agent/src/backends/config.ts +62 -5
  90. package/dist/extensions/pi-web-agent/src/backends/doctor.ts +136 -0
  91. package/dist/extensions/pi-web-agent/src/backends/factory.ts +96 -6
  92. package/dist/extensions/pi-web-agent/src/commands/web-agent-config.ts +183 -47
  93. package/dist/extensions/pi-web-agent/src/extension.ts +62 -25
  94. package/dist/extensions/pi-web-agent/src/extract/readability.ts +19 -11
  95. package/dist/extensions/pi-web-agent/src/orchestration/candidate-selector.ts +5 -4
  96. package/dist/extensions/pi-web-agent/src/orchestration/direct-url.ts +2 -25
  97. package/dist/extensions/pi-web-agent/src/orchestration/evidence-quality.ts +5 -2
  98. package/dist/extensions/pi-web-agent/src/orchestration/evidence-ranker.ts +2 -0
  99. package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +72 -7
  100. package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +8 -1
  101. package/dist/extensions/pi-web-agent/src/orchestration/research-worker.ts +28 -3
  102. package/dist/extensions/pi-web-agent/src/orchestration/source-profile.ts +4 -0
  103. package/dist/extensions/pi-web-agent/src/orchestration/url.ts +35 -0
  104. package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +18 -6
  105. package/dist/extensions/pi-web-agent/src/presentation/search-presentation.ts +14 -2
  106. package/dist/extensions/pi-web-agent/src/readers/github-reader.ts +150 -0
  107. package/dist/extensions/pi-web-agent/src/readers/limits.ts +3 -0
  108. package/dist/extensions/pi-web-agent/src/readers/pdf-reader.ts +87 -0
  109. package/dist/extensions/pi-web-agent/src/readers/resolver.ts +25 -0
  110. package/dist/extensions/pi-web-agent/src/readers/types.ts +11 -0
  111. package/dist/extensions/pi-web-agent/src/readers/youtube-reader.ts +79 -0
  112. package/dist/extensions/pi-web-agent/src/search/duckduckgo.ts +32 -5
  113. package/dist/extensions/pi-web-agent/src/search/exa.ts +109 -0
  114. package/dist/extensions/pi-web-agent/src/search/fanout.ts +154 -0
  115. package/dist/extensions/pi-web-agent/src/search/tavily.ts +113 -0
  116. package/dist/extensions/pi-web-agent/src/search/youcom.ts +109 -0
  117. package/dist/extensions/pi-web-agent/src/tools/web-search.ts +22 -9
  118. package/dist/extensions/pi-web-agent/src/types.ts +19 -4
  119. package/dist/extensions/tokenin-onboarding.ts +302 -0
  120. package/package.json +1 -1
@@ -9,19 +9,19 @@
9
9
  *
10
10
  * Extensions cannot hook the response body through the event API
11
11
  * (`after_provider_response` exposes only status/headers), but provider SDKs
12
- * fall back to `globalThis.fetch`. This extension installs a tee-wrapping
13
- * fetch once per process: LLM API responses are scanned for a
14
- * provider-reported cost (LiteLLM `x-litellm-response-cost` header,
15
- * OpenRouter `usage.cost`, `total_cost`, plus object-shaped `cost.total`)
16
- * and its response id, then recorded as a custom session entry on
17
- * `message_end`.
12
+ * fall back to `globalThis.fetch`. This extension installs a stream-wrapping
13
+ * fetch once per process: LLM API responses are scanned for a provider-reported
14
+ * cost (LiteLLM `x-litellm-response-cost` header, OpenRouter `usage.cost`,
15
+ * `total_cost`, plus object-shaped `cost.total`) and response id. The wrapper
16
+ * finishes capture before the provider SDK finalizes its assistant message, so
17
+ * `message_end` can replace `usage.cost.total` before session persistence.
18
18
  *
19
- * Providers whose payloads carry no cost (first-party OpenAI/Anthropic due to
20
- * their usage APIs) simply record nothing — their rate-card estimate matches
21
- * their bill anyway when catalog prices are configured.
22
- *
23
- * The zentui footer prefers reconciled entries over the rate-card total when
24
- * both exist for the same response id.
19
+ * Streamed bodies repeat the response id across chunks and also carry
20
+ * tool-call ids (`call_*`), so the billed cost is keyed by every id found and
21
+ * `message_end` consumes only the one matching the finalized message's
22
+ * `responseId`. Providers whose payloads carry no cost retain their rate-card
23
+ * estimate. Reconciled entries remain persisted for zentui and existing-session
24
+ * compatibility.
25
25
  */
26
26
 
27
27
  import type { ExtensionAPI } from "@selesai/code";
@@ -31,11 +31,10 @@ const ENTRY_VERSION = 1 as const;
31
31
 
32
32
  // Guard against pathological bodies; real usage chunks arrive well under 1MB.
33
33
  const MAX_CAPTURE_BYTES = 4 * 1024 * 1024;
34
- // Only LLM API paths are tee'd and scanned; unrelated fetches (web fetch tool,
35
- // model catalog refreshes) pass through untouched.
34
+ // Only LLM API paths are scanned; unrelated fetches (web fetch tool, model
35
+ // catalog refreshes) pass through untouched.
36
36
  const LLM_PATH_RE = /(\/chat\/completions|\/responses|\/messages\b|:streamGenerateContent)/;
37
37
  const MAX_CAPTURED_IDS = 50;
38
- const MAX_PENDING_LOOKUPS = 200;
39
38
  const MAX_SESSION_ENTRIES = 2_000;
40
39
 
41
40
  type ReconcileEntry = {
@@ -49,46 +48,34 @@ type ReconcileEntry = {
49
48
  source: "payload";
50
49
  };
51
50
 
52
- /**
53
- * Scan a captured SSE/JSON body for provider-reported cost and response ids.
54
- * Exported for tests.
55
- *
56
- * In streams, usage lands in the final chunk, so the LAST cost match wins.
57
- */
58
51
  type MessageInfo = {
59
52
  provider: string;
60
53
  model: string;
61
54
  responseId: string;
62
55
  };
63
56
 
64
- // Process-wide capture state. The fetch patch is installed once per process
65
- // and must not capture session-bound `pi`; per-session message_end handlers
66
- // read these maps and write entries through their own session's appendEntry.
67
- const processCaptured = new Map<string, number>();
68
- const processWaiting = new Map<
69
- string,
70
- { info: MessageInfo; write: (info: MessageInfo, cost: number) => void }
71
- >();
57
+ // Process-wide capture state. The fetch patch is installed once per process;
58
+ // per-session message_end handlers consume the matching response id. A duplicate
59
+ // capture for the same id is marked ambiguous rather than assigning either bill.
60
+ const processCaptured = new Map<string, number | null>();
72
61
 
73
- /**
74
- * Scan a captured SSE/JSON body for provider-reported cost and response ids.
75
- * Pure function; exported for tests.
76
- *
77
- * In streams, usage lands in the final chunk, so the LAST cost match wins.
78
- */
79
62
  /**
80
63
  * Parse a LiteLLM `x-litellm-response-cost` header value (a USD float,
81
64
  * possibly in scientific notation). Exported for tests.
82
65
  */
83
66
  export function parseCostHeader(value: string | null): number | undefined {
84
- if (value === null) return undefined;
85
- const cost = Number(value);
67
+ const trimmed = value?.trim();
68
+ if (!trimmed) return undefined;
69
+ const cost = Number(trimmed);
86
70
  return Number.isFinite(cost) && cost >= 0 ? cost : undefined;
87
71
  }
88
72
 
89
- export function extractCosts(
90
- text: string,
91
- ): { ids: string[]; cost?: number } {
73
+ /**
74
+ * Scan a captured SSE/JSON body for provider-reported cost and response ids.
75
+ * Exported for tests. In streams, usage lands in the final chunk, so the LAST
76
+ * valid cost match wins.
77
+ */
78
+ export function extractCosts(text: string): { ids: string[]; cost?: number } {
92
79
  const ids = new Set<string>();
93
80
  for (const match of text.matchAll(/"id"\s*:\s*"([^"]{1,200})"/g)) {
94
81
  ids.add(match[1]);
@@ -151,25 +138,25 @@ export default function costReconcileExtension(pi: ExtensionAPI): void {
151
138
  try {
152
139
  pi.appendEntry(ENTRY_TYPE, entry);
153
140
  } catch {
154
- // transcript persistence failure must not break the session
141
+ // Transcript persistence failure must not break the session.
155
142
  }
156
143
  };
157
144
 
158
145
  const finishCapture = (rawBody: string, headerCost?: number): void => {
159
- // ponytail: one naive regex pass over the whole body; a streaming
160
- // parser only matters if bodies grow past the 4MB cap.
146
+ // Ponytail: one naive regex pass over the whole body; a streaming parser
147
+ // only matters if bodies grow past the 4MB cap.
161
148
  const { ids, cost } = extractCosts(rawBody);
162
- // LiteLLM's header is the amount the gateway billed; prefer it over
163
- // any cost the upstream payload reports.
149
+ // LiteLLM's header is the amount the gateway billed; prefer it over any
150
+ // cost the upstream payload reports.
164
151
  const effectiveCost = headerCost ?? cost;
165
- if (effectiveCost === undefined) return;
152
+ if (effectiveCost === undefined || ids.length === 0) return;
153
+ // A streamed response repeats its own id across chunks while tool-call
154
+ // ids (`call_*`) appear once, so a body legitimately carries several ids.
155
+ // Key the billed cost by every id; message_end consumes only the one
156
+ // matching the finalized message's responseId. A duplicate capture of the
157
+ // same id (retry) is marked ambiguous rather than assigning either bill.
166
158
  for (const responseId of ids) {
167
- processCaptured.set(responseId, effectiveCost);
168
- const waiting = processWaiting.get(responseId);
169
- if (waiting) {
170
- processWaiting.delete(responseId);
171
- waiting.write(waiting.info, effectiveCost);
172
- }
159
+ processCaptured.set(responseId, processCaptured.has(responseId) ? null : effectiveCost);
173
160
  }
174
161
  if (processCaptured.size > 500) {
175
162
  for (const key of [...processCaptured.keys()].slice(0, processCaptured.size - 500))
@@ -191,7 +178,7 @@ export default function costReconcileExtension(pi: ExtensionAPI): void {
191
178
  if (typeof responseId === "string") seen.add(responseId);
192
179
  }
193
180
  } catch {
194
- // best effort only
181
+ // Best effort only.
195
182
  }
196
183
 
197
184
  const globalFetch = globalThis as typeof globalThis & { [FETCH_PATCHED]?: boolean };
@@ -205,26 +192,36 @@ export default function costReconcileExtension(pi: ExtensionAPI): void {
205
192
  const contentType = response.headers.get("content-type") ?? "";
206
193
  if (!contentType.includes("json") && !contentType.includes("event-stream")) return response;
207
194
  if (!response.body) return response;
195
+
208
196
  const headerCost = parseCostHeader(response.headers.get("x-litellm-response-cost"));
209
- const [main, tee] = response.body.tee();
210
- void (async () => {
211
- let raw = "";
212
- try {
213
- const reader = tee.getReader();
214
- const decoder = new TextDecoder();
215
- for (;;) {
216
- const { done, value } = await reader.read();
217
- if (done) break;
218
- raw += decoder.decode(value, { stream: true });
219
- if (raw.length > MAX_CAPTURE_BYTES) break;
197
+ let raw = "";
198
+ let captureFailed = false;
199
+ const decoder = new TextDecoder();
200
+ const capture = new TransformStream<Uint8Array, Uint8Array>({
201
+ transform(chunk, controller) {
202
+ controller.enqueue(chunk);
203
+ if (captureFailed) return;
204
+ try {
205
+ raw += decoder.decode(chunk, { stream: true });
206
+ if (raw.length > MAX_CAPTURE_BYTES) captureFailed = true;
207
+ } catch {
208
+ captureFailed = true;
220
209
  }
221
- } catch {
222
- // capture is best-effort; the main branch is unaffected
223
- }
224
- finishCapture(raw, headerCost);
225
- })();
226
- // Preserve response identity as seen by the SDK: body swapped, rest identical.
227
- return new Response(main, {
210
+ },
211
+ flush() {
212
+ if (captureFailed) return;
213
+ try {
214
+ raw += decoder.decode();
215
+ finishCapture(raw, headerCost);
216
+ } catch {
217
+ // Capture is best-effort; the response stream is unaffected.
218
+ }
219
+ },
220
+ });
221
+ // flush() runs before the SDK observes EOF, so message_end can safely
222
+ // replace the finalized message usage without waiting on another fetch.
223
+ const body = response.body.pipeThrough(capture);
224
+ return new Response(body, {
228
225
  status: response.status,
229
226
  statusText: response.statusText,
230
227
  headers: response.headers,
@@ -236,43 +233,32 @@ export default function costReconcileExtension(pi: ExtensionAPI): void {
236
233
  });
237
234
 
238
235
  pi.on("message_end", (event) => {
239
- const message = event.message as
240
- | {
241
- role?: string;
242
- provider?: string;
243
- model?: string;
244
- responseId?: string;
245
- stopReason?: string;
246
- }
247
- | undefined;
248
- if (!message || message.role !== "assistant") return;
236
+ const message = event.message;
237
+ if (message.role !== "assistant") return;
249
238
  if (message.stopReason === "error" || message.stopReason === "aborted") return;
250
239
  const responseId = message.responseId;
251
240
  if (typeof responseId !== "string" || responseId.length === 0) return;
252
241
 
253
- const info: MessageInfo = {
254
- provider: message.provider ?? "",
255
- model: message.responseModel ?? message.model ?? "",
256
- responseId,
257
- };
258
242
  const cost = processCaptured.get(responseId);
259
- if (cost !== undefined) {
260
- writeEntry(info, cost);
243
+ if (cost === undefined || cost === null || !message.usage?.cost) {
244
+ processCaptured.delete(responseId);
261
245
  return;
262
246
  }
263
- // The tee branch may finish after message_end; remember the message so
264
- // the capture can match it on completion and write via this session.
265
- if (!seen.has(responseId) && !processWaiting.has(responseId)) {
266
- waitingLimitGuard();
267
- processWaiting.set(responseId, { info, write: writeEntry });
268
- }
247
+ processCaptured.delete(responseId);
248
+
249
+ writeEntry(
250
+ {
251
+ provider: message.provider ?? "",
252
+ model: message.responseModel ?? message.model ?? "",
253
+ responseId,
254
+ },
255
+ cost,
256
+ );
257
+ return {
258
+ message: {
259
+ ...message,
260
+ usage: { ...message.usage, cost: { ...message.usage.cost, total: cost } },
261
+ },
262
+ };
269
263
  });
270
264
  }
271
-
272
- // Trim oldest waiting entries so unclaimed captures cannot grow unbounded.
273
- function waitingLimitGuard(): void {
274
- if (processWaiting.size >= MAX_PENDING_LOOKUPS) {
275
- for (const key of [...processWaiting.keys()].slice(0, processWaiting.size - MAX_PENDING_LOOKUPS + 1))
276
- processWaiting.delete(key);
277
- }
278
- }
@@ -4,6 +4,19 @@ All notable changes to the `pi-intercom` extension will be documented in this fi
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.12.1] - 2026-08-29
8
+
9
+ ### Highlights
10
+ - Replies to inbound asks are now harder to send to the wrong local session by mistake.
11
+ - The `intercom` tool now stays in the active tool set, which avoids a late-session prompt-cache reset when intercom first becomes useful.
12
+ - Existing configs that still mention `toolVisibility` keep loading; the old setting is simply ignored.
13
+
14
+ ### Fixed
15
+ - Refuse non-reply `send` calls to a different target during a turn triggered by an inbound ask, preventing CWD hierarchy or roster guesses from misdirecting replies. Thanks to [@yceachan](https://github.com/yceachan) for issue #117.
16
+
17
+ ### Removed
18
+ - Removed `toolVisibility` and the `after-first-use` reveal path. The generic `intercom` schema and prompt snippet now stay stable for provider prompt caches, and existing `toolVisibility` config keys are ignored. Thanks to [@XWIlluDelu](https://github.com/XWIlluDelu) for issue #118.
19
+
7
20
  ## [0.12.0] - 2026-08-22
8
21
 
9
22
  ### Highlights
@@ -218,7 +218,7 @@ This matters because the agent receiving the message doesn't need to reconstruct
218
218
 
219
219
  ### `send` vs `ask`
220
220
 
221
- `send` is fire-and-forget — the tool returns immediately after delivery. When the destination has exactly one pending inbound ask, `send` infers that it is the answer, attaches the ask's `replyTo`, and reports `Reply sent to <target> (inferred from pending ask)`. With zero or multiple matching asks, it remains an ordinary unthreaded send. An inferred answer still uses the `confirmSend` dialog when configured; only a caller-supplied `replyTo` skips confirmation.
221
+ `send` is fire-and-forget — the tool returns immediately after delivery. When the destination has exactly one pending inbound ask, `send` infers that it is the answer, attaches the ask's `replyTo`, and reports `Reply sent to <target> (inferred from pending ask)`. During a turn triggered by an inbound ask, `send` refuses a different non-reply target instead of treating CWD, roster position, or project hierarchy as reply authority. With zero or multiple matching asks, it remains an ordinary unthreaded send. An inferred answer still uses the `confirmSend` dialog when configured; only a caller-supplied `replyTo` skips confirmation.
222
222
 
223
223
  `ask` requires a currently connected recipient, then blocks until it responds (10-minute timeout by default; set `PI_INTERCOM_ASK_TIMEOUT_MS` to a positive millisecond value to change it). If the target is disconnected, `ask` fails immediately; use `send` when queued, non-blocking mailbox delivery is appropriate. The reply comes back as the tool result, so the agent continues in the same turn with full context. No confirmation dialog — if you're asking and waiting, the intent is clear.
224
224
 
@@ -366,7 +366,7 @@ Only registered in sessions where `pi-subagents` supplied the required child bri
366
366
 
367
367
  **`list`** — Returns the current session plus other active intercom-connected sessions with name, short ID, working directory, model, and live status. Status is derived automatically from Selesai lifecycle events: `idle`, `thinking`, or `tool:<name>`.
368
368
 
369
- **`send`** — Sends a message to the specified session and returns immediately after delivery. If the destination has exactly one pending inbound ask, `send` infers the message is its answer and returns `Reply sent to <target> (inferred from pending ask)`; zero or multiple matches remain unthreaded sends. Set `confirmSend: true` to confirm ordinary and inferred sends. A caller-supplied `replyTo` skips confirmation. `to` alone resolves globally across all live sessions. `cwd` alone targets the sole live peer in that directory. `to` plus `cwd` requires that peer to be in the directory. With `openProjectPaneIfMissing: true`, pi-intercom opens a visible Herdr project pane, starts Selesai there, waits for that session to register, then delivers the message through normal intercom routing.
369
+ **`send`** — Sends a message to the specified session and returns immediately after delivery. If the destination has exactly one pending inbound ask, `send` infers the message is its answer and returns `Reply sent to <target> (inferred from pending ask)`. During a turn triggered by an inbound ask, a non-reply `send` to a different target is rejected so a guessed parent/root CWD cannot receive an accidental reply. Zero or multiple pending-ask matches remain unthreaded sends outside the active ask turn. Set `confirmSend: true` to confirm ordinary and inferred sends. A caller-supplied `replyTo` skips confirmation. `to` alone resolves globally across all live sessions. `cwd` alone targets the sole live peer in that directory. `to` plus `cwd` requires that peer to be in the directory. With `openProjectPaneIfMissing: true`, pi-intercom opens a visible Herdr project pane, starts Selesai there, waits for that session to register, then delivers the message through normal intercom routing.
370
370
 
371
371
  **`ask`** — Requires a currently connected recipient, sends a message, and waits for the recipient to reply (10-minute timeout by default; configurable with `PI_INTERCOM_ASK_TIMEOUT_MS`). A disconnected target fails immediately rather than queueing a blocking request. The reply is returned as the tool result. No confirmation dialog. Only one pending `ask` is allowed per session at a time. Use this when the agent needs the answer to continue working. The same `to`, `cwd`, and `openProjectPaneIfMissing` targeting rules apply.
372
372
 
@@ -397,7 +397,6 @@ Create `~/.selesai/agent/intercom/config.json`:
397
397
  "brokerArgs": ["--no-install", "tsx"],
398
398
  "confirmSend": false,
399
399
  "inboundTrigger": "always",
400
- "toolVisibility": "always",
401
400
  "enabled": true,
402
401
  "replyHint": true,
403
402
  "status": "researching"
@@ -410,12 +409,12 @@ Create `~/.selesai/agent/intercom/config.json`:
410
409
  | `brokerArgs` | `["--no-install", "tsx"]` | Advanced trusted arguments passed to custom `brokerCommand` before the broker script path |
411
410
  | `confirmSend` | false | Show a confirmation dialog before ordinary or inferred sends from an interactive session with UI; caller-supplied `replyTo` skips it |
412
411
  | `inboundTrigger` | `"always"` | Auto-trigger policy for inbound broker messages: `"always"`, `"replies"`, or `"never"`. Local in-process subagent relay events still trigger the addressed session. |
413
- | `toolVisibility` | `"always"` | When the generic `intercom` tool enters the active model tool set: `"always"` or `"after-first-use"`. Lazy visibility reveals it after an inbound broker message, a successful overlay send, or loading the bundled skill. It does not hide the child-only `contact_supervisor` tool. |
414
412
  | `enabled` | true | Enable/disable intercom entirely |
415
413
  | `replyHint` | true | Include reply instruction in incoming messages |
416
414
  | `status` | — | Optional custom status suffix shown after the automatic lifecycle status, for example `thinking · researching` |
417
415
 
418
416
  If `config.json` cannot be parsed or contains an invalid value, pi-intercom logs the error and fails closed for inbound broker auto-triggering by using `inboundTrigger: "never"` until the config is fixed.
417
+ Obsolete `toolVisibility` values are ignored; the generic `intercom` tool remains stable in the active tool set for prompt-cache friendliness.
419
418
 
420
419
  Custom broker commands are trusted local configuration: anyone who can edit this config can choose the executable used for future broker auto-spawns. For example, if you have Bun installed and want it to start the broker directly, use:
421
420
 
@@ -542,7 +541,7 @@ Runtime files live at `~/.selesai/agent/intercom/` by default, or `$SELESAI_CODI
542
541
  - `broker.port.json` — Dynamic localhost TCP endpoint, only when Windows TCP transport is explicitly enabled
543
542
  - `config.json` — User configuration
544
543
 
545
- Supported `config.json` keys include `stableId` for restart-stable addressing, `status` for a custom status suffix, `inboundTrigger` (`always`, `replies`, or `never`), `toolVisibility` (`always` or `after-first-use`), `replyHint`, `confirmSend`, and advanced broker launch overrides.
544
+ Supported `config.json` keys include `stableId` for restart-stable addressing, `status` for a custom status suffix, `inboundTrigger` (`always`, `replies`, or `never`), `replyHint`, `confirmSend`, and advanced broker launch overrides.
546
545
 
547
546
  ## Design Decisions
548
547
 
@@ -60,24 +60,13 @@ test("loadConfig accepts inboundTrigger replies policy", async () => {
60
60
  }
61
61
  });
62
62
 
63
- test("loadConfig defaults intercom tool visibility to always", async () => {
64
- const root = mkdtempSync(join(tmpdir(), "pi-intercom-config-"));
65
- try {
66
- await withAgentDir(root, () => {
67
- assert.equal(loadConfig().toolVisibility, "always");
68
- });
69
- } finally {
70
- rmSync(root, { recursive: true, force: true });
71
- }
72
- });
73
-
74
- test("loadConfig accepts lazy intercom tool visibility", async () => {
63
+ test("loadConfig ignores obsolete toolVisibility values", async () => {
75
64
  const root = mkdtempSync(join(tmpdir(), "pi-intercom-config-"));
76
65
  try {
77
66
  mkdirSync(join(root, "intercom"), { recursive: true });
78
- writeFileSync(join(root, "intercom", "config.json"), JSON.stringify({ toolVisibility: "after-first-use" }));
67
+ writeFileSync(join(root, "intercom", "config.json"), JSON.stringify({ toolVisibility: "lazy", replyHint: false }));
79
68
  await withAgentDir(root, () => {
80
- assert.equal(loadConfig().toolVisibility, "after-first-use");
69
+ assert.equal(loadConfig().replyHint, false);
81
70
  });
82
71
  } finally {
83
72
  rmSync(root, { recursive: true, force: true });
@@ -97,23 +86,6 @@ test("loadConfig accepts a restart-stable intercom id", async () => {
97
86
  }
98
87
  });
99
88
 
100
- test("loadConfig rejects invalid toolVisibility values", async () => {
101
- const root = mkdtempSync(join(tmpdir(), "pi-intercom-config-"));
102
- try {
103
- mkdirSync(join(root, "intercom"), { recursive: true });
104
- writeFileSync(join(root, "intercom", "config.json"), JSON.stringify({ toolVisibility: "lazy" }));
105
-
106
- await withAgentDir(root, () => {
107
- assert.throws(
108
- () => loadConfig(),
109
- /Failed to load intercom config.*"toolVisibility" must be "always" or "after-first-use"/,
110
- );
111
- });
112
- } finally {
113
- rmSync(root, { recursive: true, force: true });
114
- }
115
- });
116
-
117
89
  test("loadConfig rejects invalid inboundTrigger values", async () => {
118
90
  const root = mkdtempSync(join(tmpdir(), "pi-intercom-config-"));
119
91
  try {
@@ -24,7 +24,6 @@ export function getIntercomScopeId(env: NodeJS.ProcessEnv = process.env): string
24
24
  }
25
25
 
26
26
  export type InboundTriggerPolicy = "always" | "replies" | "never";
27
- export type IntercomToolVisibility = "always" | "after-first-use";
28
27
 
29
28
  export interface IntercomConfig {
30
29
  /** Broker command used to spawn the broker process (e.g. "npx" or "bun") */
@@ -39,9 +38,6 @@ export interface IntercomConfig {
39
38
  /** Controls whether inbound broker messages may automatically trigger a model turn */
40
39
  inboundTrigger: InboundTriggerPolicy;
41
40
 
42
- /** Controls when the intercom tool enters the active model tool set */
43
- toolVisibility: IntercomToolVisibility;
44
-
45
41
  /** Optional custom status suffix shown after automatic lifecycle status */
46
42
  status?: string;
47
43
 
@@ -64,7 +60,6 @@ const defaults: IntercomConfig = {
64
60
  brokerArgs: ["--no-install", "tsx"],
65
61
  confirmSend: false,
66
62
  inboundTrigger: "always",
67
- toolVisibility: "always",
68
63
  enabled: true,
69
64
  replyHint: true,
70
65
  };
@@ -135,16 +130,6 @@ export function loadConfig(): IntercomConfig {
135
130
  config.inboundTrigger = parsedConfig.inboundTrigger;
136
131
  }
137
132
 
138
- if (Object.hasOwn(parsedConfig, "toolVisibility")) {
139
- if (
140
- parsedConfig.toolVisibility !== "always"
141
- && parsedConfig.toolVisibility !== "after-first-use"
142
- ) {
143
- throw new Error(`"toolVisibility" must be "always" or "after-first-use"`);
144
- }
145
- config.toolVisibility = parsedConfig.toolVisibility;
146
- }
147
-
148
133
  if (Object.hasOwn(parsedConfig, "replyHint")) {
149
134
  if (typeof parsedConfig.replyHint !== "boolean") {
150
135
  throw new Error(`"replyHint" must be a boolean`);
@@ -27,15 +27,12 @@ import {
27
27
  type IntercomOutboxResultV1,
28
28
  } from "./extension-api.ts";
29
29
  import { ReplyTracker } from "./reply-tracker.ts";
30
- import { realpathSync } from "node:fs";
31
30
  import { resolve as resolvePath } from "node:path";
32
- import { fileURLToPath } from "node:url";
33
31
  import { sameCwd } from "./cwd.ts";
34
32
  import { formatContextUsage } from "./format-context.ts";
35
33
  import { openProjectPane, resolveTargetInCwd, waitForProjectSession, type ProjectPaneLaunch } from "./project-agent.ts";
36
34
 
37
35
  const INTERCOM_TOOL_NAME = "intercom";
38
- const INTERCOM_SKILL_PATH = realpathSync(fileURLToPath(new URL("./skills/pi-intercom/SKILL.md", import.meta.url)));
39
36
  const SUBAGENT_CONTROL_INTERCOM_EVENT = "subagent:control-intercom";
40
37
  const SUBAGENT_RESULT_INTERCOM_EVENT = "subagent:result-intercom";
41
38
  const SUBAGENT_RESULT_INTERCOM_DELIVERY_EVENT = "subagent:result-intercom-delivery";
@@ -116,18 +113,6 @@ function getErrorMessage(error: unknown): string {
116
113
  return error instanceof Error ? error.message : String(error);
117
114
  }
118
115
 
119
- function isIntercomSkillRead(input: unknown, cwd: string): boolean {
120
- if (!input || typeof input !== "object") return false;
121
- const inputPath = Reflect.get(input, "path");
122
- if (typeof inputPath !== "string") return false;
123
- const normalizedPath = inputPath.startsWith("@") ? inputPath.slice(1) : inputPath;
124
- try {
125
- return realpathSync(resolvePath(cwd, normalizedPath)) === INTERCOM_SKILL_PATH;
126
- } catch {
127
- return false;
128
- }
129
- }
130
-
131
116
  function deliveryDetails(result: SendResult): Record<string, unknown> {
132
117
  return {
133
118
  messageId: result.id,
@@ -622,23 +607,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
622
607
  let runtimeGeneration = 0;
623
608
  let agentRunning = false;
624
609
  const activeTools = new Map<string, string>();
625
- let intercomToolHiddenByPolicy = false;
626
610
  const replyTracker = new ReplyTracker();
627
- function hideIntercomTool(): void {
628
- if (config.toolVisibility !== "after-first-use") return;
629
- const activeToolNames = pi.getActiveTools();
630
- if (!activeToolNames.includes(INTERCOM_TOOL_NAME)) return;
631
- pi.setActiveTools(activeToolNames.filter((name) => name !== INTERCOM_TOOL_NAME));
632
- intercomToolHiddenByPolicy = true;
633
- }
634
- function activateIntercomTool(): void {
635
- if (!intercomToolHiddenByPolicy) return;
636
- const activeToolNames = pi.getActiveTools();
637
- if (!activeToolNames.includes(INTERCOM_TOOL_NAME)) {
638
- pi.setActiveTools([...activeToolNames, INTERCOM_TOOL_NAME]);
639
- }
640
- intercomToolHiddenByPolicy = false;
641
- }
642
611
 
643
612
  const seenInboundMessages = new Map<string, number>();
644
613
  const latestOutboundReceipts = new Map<string, { status: MessageReceiptStatus; timestamp: number; detail?: string }>();
@@ -1221,7 +1190,6 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1221
1190
  );
1222
1191
  }
1223
1192
  function sendIncomingBrokerMessage(entry: InboundMessageEntry, delivery: "trigger" | "steer", generation = runtimeGeneration): void {
1224
- activateIntercomTool();
1225
1193
  sendIncomingMessage(entry, delivery, generation);
1226
1194
  }
1227
1195
  function handleIncomingMessage(ctx: ExtensionContext, from: SessionInfo, message: Message): void {
@@ -1572,7 +1540,6 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1572
1540
  });
1573
1541
  }
1574
1542
  function startSessionRuntime(ctx: ExtensionContext): void {
1575
- hideIntercomTool();
1576
1543
  const previousClient = client;
1577
1544
  failPendingOutboxRequests(runtimeGeneration, "session_ended", "Session replaced");
1578
1545
  shuttingDown = false;
@@ -1819,17 +1786,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1819
1786
  return new InlineMessageComponent(details.from, details.message, theme, details.replyCommand, details.bodyText, !options.expanded);
1820
1787
  });
1821
1788
 
1822
- pi.on("input", (event) => {
1823
- if (/^\/skill: pi-intercom(?:\s|$)/u.test(event.text.trimStart())) {
1824
- activateIntercomTool();
1825
- }
1826
- });
1827
-
1828
- pi.on("tool_result", (event, ctx) => {
1829
- if (event.toolName === "read" && event.isError !== true && isIntercomSkillRead(event.input, ctx.cwd)) {
1830
- activateIntercomTool();
1831
- return;
1832
- }
1789
+ pi.on("tool_result", (event) => {
1833
1790
  if (event.toolName !== "intercom" && event.toolName !== "contact_supervisor") {
1834
1791
  return;
1835
1792
  }
@@ -2181,7 +2138,6 @@ Usage:
2181
2138
  }),
2182
2139
 
2183
2140
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2184
- activateIntercomTool();
2185
2141
  let connectedClient: IntercomClient;
2186
2142
  try {
2187
2143
  connectedClient = await ensureConnected("tool");
@@ -2350,6 +2306,14 @@ Usage:
2350
2306
  details: { error: true },
2351
2307
  };
2352
2308
  }
2309
+ const activeReplyMismatch = replyTo ? null : replyTracker.findActiveReplyTargetMismatch(sendTo);
2310
+ if (activeReplyMismatch) {
2311
+ const senderLabel = activeReplyMismatch.from.name || activeReplyMismatch.from.id;
2312
+ return {
2313
+ content: [{ type: "text", text: `This turn is responding to an intercom ask from "${senderLabel}". Use intercom({ action: "reply", message: "..." }) or set replyTo: "${activeReplyMismatch.message.id}". Refusing non-reply send to "${targetDisplay}" to avoid a misdirected reply.` }],
2314
+ details: { error: true, replyTo: activeReplyMismatch.message.id },
2315
+ };
2316
+ }
2353
2317
  const inferredAsk = replyTo ? null : replyTracker.findUniquePendingAskFrom(sendTo);
2354
2318
  const effectiveReplyTo = replyTo ?? inferredAsk?.message.id;
2355
2319
  if (confirmSend && !(cwd && openProjectPaneIfMissing)) {
@@ -2761,7 +2725,6 @@ Usage:
2761
2725
  ).catch(() => undefined);
2762
2726
 
2763
2727
  if (result?.sent && result.messageId && result.text && getLiveContext(ctx, overlayGeneration)) {
2764
- activateIntercomTool();
2765
2728
  pi.appendEntry("intercom_sent", {
2766
2729
  to: selectedSession.name || selectedSession.id,
2767
2730
  message: { text: result.text },