@ctliz/agent-intercom-pi 0.12.0-connect.9 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,6 +17,35 @@
17
17
  | AGY | [`agent-intercom-agy`](https://github.com/ctliz/agent-intercom-agy) |
18
18
  | Fleet lifecycle | [`agent-intercom-orchestrator`](https://github.com/ctliz/agent-intercom-orchestrator) |
19
19
 
20
+ ## Pi 0.99 and codemode
21
+
22
+ Version 0.13.0 requires Pi 0.99.1 or newer in the 0.99 release line. It keeps the existing protocol v4 broker, durable queues, acknowledgements, and cross-harness routing unchanged.
23
+
24
+ All Intercom tools remain directly callable and are also available through Pi's built-in codemode when active. Scripts receive `{ ok, text, data }` rather than a display string. `data` contains the tool's structured details: delivery flags and message ID, session lists, team roster, pending asks, or connection status. Returned failures set `isError: true` and retain structured data; a deferred ask is successful with `data.pending === true`. Always check `ok` before continuing with dependent actions. Blocked calls, invalid arguments, and thrown exceptions can still reject, so use `try/catch` or `Promise.allSettled()` when appropriate.
25
+
26
+ ```javascript
27
+ const sessions = await tools.intercom_list({});
28
+ if (!sessions.ok) throw new Error(sessions.text);
29
+ const target = sessions.data.sessions.find(s => s.name === "worker");
30
+ if (!target) throw new Error("Worker is offline");
31
+ const result = await tools.intercom_send({ to: target.id, message: "Tests passed." });
32
+ return { ok: result.ok, accepted: result.data.accepted, delivered: result.data.delivered };
33
+ ```
34
+
35
+ Concurrent sends and independent asks are supported; do not create two unresolved asks to the same recipient. Team joins execute sequentially because they change the caller's routing scope. Receiving a delivery acknowledgement means the message is durably queued, not that the recipient finished its task. Busy sessions wait until `ctx.isIdle()`; `agent_settled` updates final idle status after automatic retries and compaction.
36
+
37
+ ### Send from a shell or release script
38
+
39
+ The package includes an `intercom-send` executable. Install the alias globally for a shell command, or run it without relying on Pi's private installation path:
40
+
41
+ ```bash
42
+ npm exec --yes --package=@ctliz/pi-intercom@0.13.0 -- intercom-send worker 'Tests passed.'
43
+ ```
44
+
45
+ It prints one JSON result with `accepted`, `delivered`, `messageId`, and optional failure `code`/`reason`. Exit status is zero only for acknowledged delivery. It inherits the routing scope, but never inherits `PI_INTERCOM_SESSION_ID` or `AGENT_INTERCOM_SESSION_ID`: every invocation registers an independent sender, leaves running Pi sessions intact, and disconnects after sending. It is send-only; use the session tools for reply-tracked asks.
46
+
47
+ When a second runtime claims the same stable session ID, Intercom reports `SESSION_ID_IN_USE`, pauses automatic reconnect, and preserves the original owner. Switch to a different session, or release the duplicate owner and `/reload`. `intercom_status` exposes the conflict as structured data rather than silently treating it as a temporary outage. Updating this adapter does not require restarting a compatible v4 broker.
48
+
20
49
  ## Grok Build and AGY support
21
50
 
22
51
  Grok Build and AGY are supported as first-class protocol peers through two dedicated npm packages:
@@ -93,7 +122,7 @@ Each pi session that has `pi-intercom` loaded and enabled connects to a tiny loc
93
122
  ## Install
94
123
 
95
124
  ```bash
96
- pi install git:github.com/ctliz/agent-intercom-pi@v0.12.0-connect.9
125
+ pi install git:github.com/ctliz/agent-intercom-pi@v0.12.2
97
126
  ```
98
127
 
99
128
  If you are coming from `connect.1`, read [Upgrading from `connect.1`](#upgrading-from-connect1-to-connect2) first — the package namespace changed and the two versions must not be installed side by side.
@@ -141,7 +170,7 @@ A session becomes intercom-connected when all of these are true:
141
170
 
142
171
  The session list only shows intercom-connected sessions, not every open Pi process on the machine.
143
172
 
144
- If you upgrade pi-intercom or the orchestrator while sessions are already open, run `/reload` in each open Pi session (and restart any companion `coi`, `cci`, or OpenCode adapter). Update the packages by reinstalling the exact release tags with `pi install git:github.com/ctliz/agent-intercom-pi@v0.12.0-connect.9` and, only where Orchestrator is actually installed, `pi install git:github.com/ctliz/agent-intercom-orchestrator@v0.12.0-connect.5`. Extensions are loaded into the running host process, so an existing session cannot adopt new broker/discovery code until it reloads. This is especially important when upgrading from a release that allowed multiple broker processes to form separate session-list "islands": the broker ownership fix prevents new splits, but it cannot move clients that are still running the old code. After every host has reloaded once, they converge on the same broker automatically.
173
+ If you upgrade pi-intercom or the orchestrator while sessions are already open, run `/reload` in each open Pi session (and restart any companion `coi`, `cci`, or OpenCode adapter). Update the packages by reinstalling the exact release tags with `pi install git:github.com/ctliz/agent-intercom-pi@v0.12.2` and, only where Orchestrator is actually installed, `pi install git:github.com/ctliz/agent-intercom-orchestrator@v0.12.0-connect.5`. Extensions are loaded into the running host process, so an existing session cannot adopt new broker/discovery code until it reloads. This is especially important when upgrading from a release that allowed multiple broker processes to form separate session-list "islands": the broker ownership fix prevents new splits, but it cannot move clients that are still running the old code. After every host has reloaded once, they converge on the same broker automatically.
145
174
 
146
175
  If `/intercom` still reports no peers, first confirm the other Pi windows have pi-intercom loaded and have also been reloaded. Open Pi processes without the extension, disabled sessions, and sessions using a different `PI_CODING_AGENT_DIR` intentionally do not appear in the same list.
147
176
 
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { register } from "tsx/esm/api";
3
+ register();
4
+ await import("../cli-send.ts");
package/broker/client.ts CHANGED
@@ -376,7 +376,9 @@ export class IntercomClient extends EventEmitter {
376
376
  };
377
377
 
378
378
  const onReaderError = (error: Error) => {
379
- const protocolError = new Error(`Intercom protocol error: ${error.message}`, { cause: error });
379
+ const protocolError = new Error(`Intercom protocol error: ${error.message}`, { cause: error }) as Error & { code?: string };
380
+ const code = (error as Error & { code?: string }).code;
381
+ if (code) protocolError.code = code;
380
382
  if (!connectionEstablished) {
381
383
  onError(protocolError);
382
384
  return;
package/broker/framing.ts CHANGED
@@ -41,7 +41,10 @@ export function createMessageReader(
41
41
  return true;
42
42
  } catch (error) {
43
43
  const message = error instanceof Error ? error.message : String(error);
44
- onError(new Error(`Failed to handle intercom message: ${message}`, { cause: error }));
44
+ const handlerError = new Error(`Failed to handle intercom message: ${message}`, { cause: error }) as Error & { code?: string };
45
+ const code = (error as { code?: unknown } | null)?.code;
46
+ if (typeof code === "string") handlerError.code = code;
47
+ onError(handlerError);
45
48
  return false;
46
49
  }
47
50
  }
package/cli-send.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { IntercomClient } from "./broker/client.ts";
3
+ import { spawnBrokerIfNeeded } from "./broker/spawn.ts";
4
+ import { loadConfig } from "./config.ts";
5
+
6
+ async function main(): Promise<void> {
7
+ const [to, ...parts] = process.argv.slice(2);
8
+ const message = parts.join(" ");
9
+ if (!to?.trim() || !message.trim()) {
10
+ throw new Error("Usage: intercom-send <session-name-or-id> <message>");
11
+ }
12
+ const config = loadConfig();
13
+ if (!config.enabled) throw new Error("Intercom disabled");
14
+ await spawnBrokerIfNeeded(config.brokerCommand, config.brokerArgs);
15
+ const client = new IntercomClient();
16
+ const now = Date.now();
17
+ try {
18
+ // Never inherit the calling Pi's session ID or take over its mailbox.
19
+ await client.connect({
20
+ name: `intercom-cli-${randomUUID()}`,
21
+ cwd: process.cwd(),
22
+ model: "cli-sender",
23
+ pid: process.pid,
24
+ startedAt: now,
25
+ lastActivity: now,
26
+ runtimeInstanceId: randomUUID(),
27
+ });
28
+ const result = await client.send(to, { text: message });
29
+ console.log(JSON.stringify({ messageId: result.id, ...result }));
30
+ if (!result.delivered) process.exitCode = 1;
31
+ } finally {
32
+ await client.disconnect();
33
+ }
34
+ }
35
+
36
+ main().catch((error: Error & { code?: string }) => {
37
+ console.log(JSON.stringify({ accepted: false, delivered: false, reason: error.message, ...(error.code ? { code: error.code } : {}) }));
38
+ process.exitCode = 1;
39
+ });
package/index.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { intercomOutputSchema, structuredIntercomResult } from "./tool-result.ts";
2
3
  import { StringEnum } from "@earendil-works/pi-ai";
3
4
  import { randomUUID } from "crypto";
4
5
  import { spawn, spawnSync } from "child_process";
@@ -147,9 +148,8 @@ function toError(error: unknown): Error {
147
148
  }
148
149
 
149
150
  function toolErrorDetails(error: unknown): { error: true; code?: string } {
150
- return error instanceof BossTeamScopeError
151
- ? { error: true, code: error.code }
152
- : { error: true };
151
+ const code = (error as { code?: unknown } | null)?.code;
152
+ return { error: true, ...(typeof code === "string" ? { code } : {}) };
153
153
  }
154
154
 
155
155
  class AskWaitElapsedError extends Error {
@@ -680,6 +680,15 @@ function getNamePollMs(): number {
680
680
  return 1000;
681
681
  }
682
682
  export default function piIntercomExtension(pi: ExtensionAPI) {
683
+ function registerIntercomTool(tool: ToolDefinition<any, any>): void {
684
+ pi.registerTool({
685
+ ...tool,
686
+ outputSchema: intercomOutputSchema,
687
+ async execute(...args) {
688
+ return structuredIntercomResult(await tool.execute(...args));
689
+ },
690
+ });
691
+ }
683
692
  let runtimeScopeId = intercomScopeIdFromEnvForRegistration();
684
693
  const initialHarnessSessionId = process.env[INTERCOM_SESSION_ID_ENV]?.trim();
685
694
  const initialGenericSessionId = process.env.AGENT_INTERCOM_SESSION_ID?.trim();
@@ -706,6 +715,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
706
715
  let reconnectPromiseGeneration: number | null = null;
707
716
  let startupConnectTimer: NodeJS.Timeout | null = null;
708
717
  let reconnectAttempt = 0;
718
+ let registrationConflict: Error | null = null;
709
719
  let shuttingDown = false;
710
720
  let disposed = true;
711
721
  let runtimeStarted = false;
@@ -835,7 +845,8 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
835
845
  }
836
846
  function currentStatus(): string {
837
847
  const activeToolName = activeTools.values().next().value;
838
- const lifecycleStatus = activeToolName ? `tool:${activeToolName}` : agentRunning ? "thinking" : "idle";
848
+ const busy = agentRunning || getLiveContext()?.isIdle() === false;
849
+ const lifecycleStatus = activeToolName ? `tool:${activeToolName}` : busy ? "thinking" : "idle";
839
850
  const queueStatus = inboundInbox?.size ? ` · inbox:${inboundInbox.size}` : "";
840
851
  const outboxStatus = client?.outboxSize ? ` · outbox:${client.outboxSize}` : "";
841
852
  return config.status ? `${lifecycleStatus}${queueStatus}${outboxStatus} · ${config.status}` : `${lifecycleStatus}${queueStatus}${outboxStatus}`;
@@ -1079,6 +1090,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1079
1090
  inboundFlushTimer.unref?.();
1080
1091
  }
1081
1092
  function flushIdleMessages(generation = runtimeGeneration): void {
1093
+ if (registrationConflict) return;
1082
1094
  if (!inboundInbox || inboundInbox.size === 0) {
1083
1095
  return;
1084
1096
  }
@@ -1086,6 +1098,10 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1086
1098
  if (!ctx) {
1087
1099
  return;
1088
1100
  }
1101
+ if (!client?.isConnected()) {
1102
+ scheduleInboundFlush(INBOUND_IDLE_RETRY_MS);
1103
+ return;
1104
+ }
1089
1105
 
1090
1106
  let isIdle: boolean;
1091
1107
  try {
@@ -1305,7 +1321,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1305
1321
  });
1306
1322
  }
1307
1323
  function scheduleReconnect(): void {
1308
- if (disposed || shuttingDown || reconnectTimer || reconnectPromise || !getLiveContext()) {
1324
+ if (disposed || shuttingDown || registrationConflict || reconnectTimer || reconnectPromise || !getLiveContext()) {
1309
1325
  return;
1310
1326
  }
1311
1327
  const scheduledGeneration = runtimeGeneration;
@@ -1327,6 +1343,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1327
1343
  if (disposed || shuttingDown) {
1328
1344
  throw new Error("Intercom shutting down");
1329
1345
  }
1346
+ if (registrationConflict) throw registrationConflict;
1330
1347
  if (bossTeamScope.present) {
1331
1348
  const selfError = currentSessionId ? bossSelfSessionError(bossTeamScope, currentSessionId) : "Boss session identity is unavailable";
1332
1349
  if (selfError) throw new BossTeamScopeError("BOSS_TEAM_METADATA_INVALID", selfError);
@@ -1359,19 +1376,24 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1359
1376
  }
1360
1377
  client = nextClient;
1361
1378
  reconnectAttempt = 0;
1379
+ scheduleInboundFlush(0);
1362
1380
  return nextClient;
1363
1381
  } catch (error) {
1364
1382
  if (client === nextClient) {
1365
1383
  client = null;
1366
1384
  }
1367
- if (reason === "background" && getLiveContext(contextAtStart, generationAtStart)) {
1368
- scheduleReconnect();
1385
+ if ((error as { code?: string })?.code === "SESSION_ID_IN_USE" && getLiveContext(contextAtStart, generationAtStart)) {
1386
+ registrationConflict = toError(error);
1387
+ clearReconnectTimer();
1388
+ pi.appendEntry("intercom_registration_conflict", { sessionId: currentSessionId, code: "SESSION_ID_IN_USE", timestamp: Date.now() });
1389
+ notifyIfLive(contextAtStart, "Intercom session is owned by another runtime. Switch to a different session, or release the duplicate owner and /reload. The existing owner was not changed.", "warning", generationAtStart);
1369
1390
  }
1370
1391
  throw toError(error);
1371
1392
  } finally {
1372
1393
  if (reconnectPromise === nextReconnectPromise) {
1373
1394
  reconnectPromise = null;
1374
1395
  reconnectPromiseGeneration = null;
1396
+ if (reason === "background" && !client) scheduleReconnect();
1375
1397
  }
1376
1398
  }
1377
1399
  })();
@@ -1490,6 +1512,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1490
1512
  runtimeStarted = true;
1491
1513
  runtimeGeneration += 1;
1492
1514
  reconnectAttempt = 0;
1515
+ registrationConflict = null;
1493
1516
  clearReconnectTimer();
1494
1517
  clearStartupConnectTimer();
1495
1518
  clearNamePollTimer();
@@ -1810,11 +1833,16 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1810
1833
  activeTools.delete(event.toolCallId);
1811
1834
  syncPresenceStatus();
1812
1835
  });
1813
- pi.on("agent_end", () => {
1814
- if (!getLiveContext()) {
1815
- return;
1816
- }
1836
+ pi.on("agent_end", (_event, ctx) => {
1837
+ if (!getLiveContext(ctx)) return;
1817
1838
  replyTracker.endTurn();
1839
+ agentRunning = !ctx.isIdle();
1840
+ activeTools.clear();
1841
+ syncPresenceStatus();
1842
+ scheduleInboundFlush(0);
1843
+ });
1844
+ pi.on("agent_settled", (_event, ctx) => {
1845
+ if (!getLiveContext(ctx)) return;
1818
1846
  agentRunning = false;
1819
1847
  activeTools.clear();
1820
1848
  syncPresenceStatus();
@@ -1895,7 +1923,7 @@ export default function piIntercomExtension(pi: ExtensionAPI) {
1895
1923
 
1896
1924
  const childOrchestratorMetadata = readChildOrchestratorMetadata();
1897
1925
  if (childOrchestratorMetadata) {
1898
- pi.registerTool({
1926
+ registerIntercomTool({
1899
1927
  name: "contact_supervisor",
1900
1928
  label: "Contact Supervisor",
1901
1929
  description: "Subagent-only tool for contacting the supervisor agent that delegated this task. Use need_decision when blocked, uncertain, needing approval, or facing a product/API/scope decision before continuing; this waits up to 30 seconds, then continues asynchronously if unanswered. Use interview_request when multiple structured questions need supervisor answers; it has the same soft wait. Use progress_update only for meaningful progress or unexpected discoveries that change the plan; this does not wait for a reply. Do not use for routine completion handoffs.",
@@ -2219,6 +2247,12 @@ Usage:
2219
2247
  }),
2220
2248
 
2221
2249
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2250
+ if (params.action === "status" && registrationConflict) {
2251
+ return {
2252
+ content: [{ type: "text", text: `**Intercom Status:**\nConnected: No\nSession ID: ${currentSessionId}\nState: SESSION_ID_IN_USE\nAnother runtime owns this session; automatic reconnect is paused. Release the duplicate owner and /reload, or switch to a different session.` }],
2253
+ details: { connected: false, sessionId: currentSessionId, code: "SESSION_ID_IN_USE", error: true },
2254
+ };
2255
+ }
2222
2256
  let connectedClient: IntercomClient;
2223
2257
  try {
2224
2258
  connectedClient = await ensureConnected("tool");
@@ -2272,7 +2306,7 @@ Usage:
2272
2306
 
2273
2307
  return {
2274
2308
  content: [{ type: "text", text: `${currentSection}\n\n${otherSection}` }],
2275
- details: {},
2309
+ details: { sessionId: mySessionId, sessions },
2276
2310
  };
2277
2311
  } catch (error) {
2278
2312
  return {
@@ -2632,7 +2666,7 @@ Usage:
2632
2666
  type: "text",
2633
2667
  text: `**Intercom Status:**\nConnected: Yes\nSession ID: ${mySessionId}\nActive sessions: ${sessions.length}\nQueued inbound messages: ${inboundInbox?.size ?? 0}\nQueued outbound messages: ${connectedClient.outboxSize}\nPending inbound asks: ${replyTracker.listPending().length}`,
2634
2668
  }],
2635
- details: {},
2669
+ details: { connected: true, sessionId: mySessionId, activeSessions: sessions.length, inboundMessages: inboundInbox?.size ?? 0, outboundMessages: connectedClient.outboxSize, pendingAsks: replyTracker.listPending().length },
2636
2670
  };
2637
2671
  } catch (error) {
2638
2672
  return {
@@ -2705,7 +2739,7 @@ Usage:
2705
2739
  legacyIntercomTool.renderCall({ ...args, action }, theme, context);
2706
2740
  const renderSplitResult = legacyIntercomTool.renderResult;
2707
2741
 
2708
- pi.registerTool({
2742
+ registerIntercomTool({
2709
2743
  name: "intercom_send",
2710
2744
  label: "Intercom Send",
2711
2745
  description: "Send a message to another local Pi session. Both the recipient and message are required.",
@@ -2721,7 +2755,7 @@ Usage:
2721
2755
  renderResult: renderSplitResult,
2722
2756
  } as any);
2723
2757
 
2724
- pi.registerTool({
2758
+ registerIntercomTool({
2725
2759
  name: "intercom_ask",
2726
2760
  label: "Intercom Ask",
2727
2761
  description: "Ask another local Pi session a blocking question, waiting briefly before continuing asynchronously. Do not use this for progress or status checkpoints.",
@@ -2737,7 +2771,7 @@ Usage:
2737
2771
  renderResult: renderSplitResult,
2738
2772
  } as any);
2739
2773
 
2740
- pi.registerTool({
2774
+ registerIntercomTool({
2741
2775
  name: "intercom_reply",
2742
2776
  label: "Intercom Reply",
2743
2777
  description: "Reply to an inbound intercom message or ask. Exact protocol threading is resolved internally.",
@@ -2754,7 +2788,7 @@ Usage:
2754
2788
  renderResult: renderSplitResult,
2755
2789
  } as any);
2756
2790
 
2757
- pi.registerTool({
2791
+ registerIntercomTool({
2758
2792
  name: "intercom_team",
2759
2793
  label: "Intercom Team",
2760
2794
  description: "Show your current manager and the live coworkers owned by that manager. No arguments are required.",
@@ -2789,8 +2823,9 @@ Usage:
2789
2823
  },
2790
2824
  } as any);
2791
2825
 
2792
- pi.registerTool({
2826
+ registerIntercomTool({
2793
2827
  name: "intercom_join",
2828
+ executionMode: "sequential",
2794
2829
  label: "Intercom Join",
2795
2830
  description: "List, join, or create a named intercom team without tmux. Omit name to list joinable teams. Set create=true to create a team and join as manager.",
2796
2831
  promptSnippet: "Join or create a named intercom team so intercom_team works without tmux.",
@@ -2838,7 +2873,7 @@ Usage:
2838
2873
  { name: "intercom_status", label: "Intercom Status", action: "status", description: "Show this session's intercom connection status.", promptSnippet: "Show intercom connection status." },
2839
2874
  ] as const) {
2840
2875
  if (definition.name === "intercom_list" && bossTeamScope.restricted) continue;
2841
- pi.registerTool({
2876
+ registerIntercomTool({
2842
2877
  name: definition.name,
2843
2878
  label: definition.label,
2844
2879
  description: definition.description,
@@ -2856,7 +2891,7 @@ Usage:
2856
2891
  }
2857
2892
 
2858
2893
  if (config.legacyTool) {
2859
- pi.registerTool(legacyIntercomTool);
2894
+ registerIntercomTool(legacyIntercomTool);
2860
2895
  }
2861
2896
 
2862
2897
  async function resolveCurrentContact(ctx: ExtensionContext, generation = runtimeGeneration): Promise<{ target: string; name?: string; id: string; duplicateName: boolean } | undefined> {
package/package.json CHANGED
@@ -1,15 +1,19 @@
1
1
  {
2
2
  "name": "@ctliz/agent-intercom-pi",
3
- "version": "0.12.0-connect.9",
3
+ "version": "0.13.0",
4
4
  "description": "Pi coding-agent intercom for local messaging with Codex, Claude Code, OpenCode, Grok Build, and AGY agents.",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
7
7
  "main": "index.ts",
8
+ "bin": {
9
+ "intercom-send": "./bin/intercom-send.mjs"
10
+ },
8
11
  "exports": {
9
12
  ".": "./index.ts"
10
13
  },
11
14
  "files": [
12
15
  "banner.png",
16
+ "bin/*.mjs",
13
17
  "*.ts",
14
18
  "!*.test.ts",
15
19
  "broker/**/*.ts",
@@ -54,9 +58,9 @@
54
58
  },
55
59
  "dependencies": {
56
60
  "@ctliz/agent-intercom-core": "0.2.0",
57
- "@earendil-works/pi-ai": "*",
58
- "@earendil-works/pi-coding-agent": "*",
59
- "@earendil-works/pi-tui": "*",
61
+ "@earendil-works/pi-ai": "^0.99.1",
62
+ "@earendil-works/pi-coding-agent": "^0.99.1",
63
+ "@earendil-works/pi-tui": "^0.99.1",
60
64
  "tsx": "^4.20.0",
61
65
  "typebox": "*"
62
66
  },
@@ -24,6 +24,19 @@ This skill covers how to handle those orchestrator-side escalations.
24
24
  - **Clarification loops**: Worker asks questions, planner answers, work continues
25
25
  - **Multi-session workflows**: Coordinate between specialized sessions (frontend/backend, research/implementation)
26
26
 
27
+ ## Codemode (Pi 0.99.1+)
28
+
29
+ Active Intercom tools can be called from codemode as `await tools.intercom_send({...})`, `await tools.intercom_team({})`, and so on. Every result is `{ ok, text, data }`: check `ok`, then read structured delivery flags, session lists, team roster, or pending asks from `data`. Returned errors retain this object; invalid arguments, blocked calls, and thrown exceptions can still reject. Use `try/catch` or `Promise.allSettled()` for independent operations.
30
+
31
+ ```javascript
32
+ const result = await tools.intercom_send({ to: "worker", message: "Tests passed." });
33
+ return { ok: result.ok, delivered: result.data.delivered };
34
+ ```
35
+
36
+ A deferred ask has `ok: true` and `data.pending: true`; it is not a failure. Do not proceed with dependent work until the actual answer arrives. Different recipients may be asked concurrently; never create a second unresolved ask to the same recipient. Delivery means durable queue acknowledgement, not task completion.
37
+
38
+ For shell notifications, use `intercom-send <session-name-or-id> <message>`. It registers an independent send-only identity and prints JSON; it does not take over the current Pi session or track replies.
39
+
27
40
  ## Core Patterns
28
41
 
29
42
  ### Pattern 1: Planner-Worker Delegation
@@ -467,7 +480,7 @@ if (result.details?.pending) {
467
480
 
468
481
  ### Session name flips or registration reports `SESSION_ID_IN_USE`
469
482
 
470
- The same Pi session is open in more than one live runtime, such as a desktop terminal and a mobile/RPC host. Intercom keeps the first runtime authoritative instead of allowing the two clients to evict each other. Close or switch away from the duplicate runtime; one transcript/session ID must have only one live owner.
483
+ The same Pi session is open in more than one live runtime, such as a desktop terminal and a mobile/RPC host. Intercom keeps the first runtime authoritative instead of allowing the two clients to evict each other. The conflicting runtime pauses automatic reconnect; `intercom_status` returns `ok: false` with `data.code: "SESSION_ID_IN_USE"`. Switch to a different session, or release the duplicate owner and `/reload`. One transcript/session ID must have only one live owner; do not auto-generate a replacement identity for a running session.
471
484
 
472
485
  ### Message not delivered
473
486
 
package/tool-result.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+
4
+ export const intercomOutputSchema = Type.Object({
5
+ ok: Type.Boolean(),
6
+ text: Type.String(),
7
+ data: Type.Record(Type.String(), Type.Unknown()),
8
+ });
9
+
10
+ type ToolResult = Awaited<ReturnType<ToolDefinition<any, any>["execute"]>>;
11
+
12
+ /** Keep model/TUI content unchanged while giving codemode callers JSON data. */
13
+ export function structuredIntercomResult(result: ToolResult): ToolResult {
14
+ const details = result.details ?? {};
15
+ const isError = result.isError === true || details.error === true || details.delivered === false;
16
+ return {
17
+ ...result,
18
+ details,
19
+ isError,
20
+ structuredContent: {
21
+ ok: !isError,
22
+ text: result.content.filter((item) => item.type === "text").map((item) => item.text).join("\n"),
23
+ data: JSON.parse(JSON.stringify(details)),
24
+ },
25
+ };
26
+ }