pi-ca-leash 0.10.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.
Files changed (138) hide show
  1. package/AGENTS.md +77 -0
  2. package/ARCHITECTURE.md +290 -0
  3. package/CHANGELOG.md +158 -0
  4. package/DEVELOPMENT.md +197 -0
  5. package/KNOWN_LIMITS.md +80 -0
  6. package/LICENSE +21 -0
  7. package/README.md +288 -0
  8. package/extensions/backend-tool-actions.test.ts +59 -0
  9. package/extensions/backend-tool-actions.ts +31 -0
  10. package/extensions/command-drivers.test.ts +37 -0
  11. package/extensions/command-drivers.ts +126 -0
  12. package/extensions/command-parity.test.ts +560 -0
  13. package/extensions/command-visibility.test.ts +21 -0
  14. package/extensions/command-visibility.ts +10 -0
  15. package/extensions/index.ts +3218 -0
  16. package/extensions/llm-tools.test.ts +537 -0
  17. package/extensions/model-catalog.test.ts +34 -0
  18. package/extensions/model-catalog.ts +173 -0
  19. package/extensions/peer-history.test.ts +141 -0
  20. package/extensions/peer-history.ts +90 -0
  21. package/extensions/peer-naming.test.ts +25 -0
  22. package/extensions/peer-naming.ts +129 -0
  23. package/extensions/peer-relay.test.ts +122 -0
  24. package/extensions/peer-relay.ts +83 -0
  25. package/extensions/peer-ux.test.ts +239 -0
  26. package/extensions/peer-ux.ts +327 -0
  27. package/extensions/persistence.test.ts +68 -0
  28. package/extensions/persistence.ts +67 -0
  29. package/extensions/prompts/extension-log-tool.md +5 -0
  30. package/extensions/prompts/peer-ask-tool.md +5 -0
  31. package/extensions/prompts/peer-bridge-system.md +4 -0
  32. package/extensions/prompts/peer-history-tool.md +3 -0
  33. package/extensions/prompts/peer-init-user-help.md +11 -0
  34. package/extensions/prompts/peer-init.md +17 -0
  35. package/extensions/prompts/peer-interrupt-tool.md +2 -0
  36. package/extensions/prompts/peer-list-tool.md +3 -0
  37. package/extensions/prompts/peer-no-babysitting.md +6 -0
  38. package/extensions/prompts/peer-send-tool.md +5 -0
  39. package/extensions/prompts/peer-start-tool.md +7 -0
  40. package/extensions/prompts/peer-stop-tool.md +3 -0
  41. package/extensions/prompts/runtime-models-tool.md +6 -0
  42. package/extensions/prompts/subagent-list-tool.md +3 -0
  43. package/extensions/prompts/subagent-run-tool.md +6 -0
  44. package/extensions/prompts/subagent-status-tool.md +2 -0
  45. package/extensions/prompts/team-list-tool.md +2 -0
  46. package/extensions/prompts/team-message-tool.md +2 -0
  47. package/extensions/prompts/team-spawn-tool.md +5 -0
  48. package/extensions/prompts/team-stop-tool.md +2 -0
  49. package/extensions/prompts/team-task-tool.md +3 -0
  50. package/extensions/prompts.ts +41 -0
  51. package/extensions/runtime-driver.test.ts +38 -0
  52. package/extensions/runtime-driver.ts +33 -0
  53. package/extensions/runtime-safety.test.ts +21 -0
  54. package/extensions/runtime-safety.ts +49 -0
  55. package/extensions/support.test.ts +144 -0
  56. package/extensions/support.ts +205 -0
  57. package/extensions/tool-inputs.test.ts +45 -0
  58. package/extensions/tool-inputs.ts +79 -0
  59. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/bridge.d.ts +48 -0
  60. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/bridge.js +406 -0
  61. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/bridge.js.map +1 -0
  62. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/cli.d.ts +2 -0
  63. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/cli.js +18 -0
  64. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/cli.js.map +1 -0
  65. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/index.d.ts +5 -0
  66. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/index.js +5 -0
  67. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/index.js.map +1 -0
  68. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/persistence.d.ts +12 -0
  69. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/persistence.js +31 -0
  70. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/persistence.js.map +1 -0
  71. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/pi-intercom-transport.d.ts +12 -0
  72. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/pi-intercom-transport.js +347 -0
  73. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/pi-intercom-transport.js.map +1 -0
  74. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/types.d.ts +103 -0
  75. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/types.js +2 -0
  76. package/node_modules/@pi-claude-code-agent/intercom-bridge/dist/types.js.map +1 -0
  77. package/node_modules/@pi-claude-code-agent/intercom-bridge/package.json +32 -0
  78. package/node_modules/@pi-claude-code-agent/runtime/dist/cli.d.ts +2 -0
  79. package/node_modules/@pi-claude-code-agent/runtime/dist/cli.js +26 -0
  80. package/node_modules/@pi-claude-code-agent/runtime/dist/cli.js.map +1 -0
  81. package/node_modules/@pi-claude-code-agent/runtime/dist/driver-config.d.ts +4 -0
  82. package/node_modules/@pi-claude-code-agent/runtime/dist/driver-config.js +12 -0
  83. package/node_modules/@pi-claude-code-agent/runtime/dist/driver-config.js.map +1 -0
  84. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/claude-sdk.d.ts +8 -0
  85. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/claude-sdk.js +320 -0
  86. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/claude-sdk.js.map +1 -0
  87. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/codex-cli.d.ts +24 -0
  88. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/codex-cli.js +266 -0
  89. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/codex-cli.js.map +1 -0
  90. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/messages.d.ts +72 -0
  91. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/messages.js +2 -0
  92. package/node_modules/@pi-claude-code-agent/runtime/dist/drivers/messages.js.map +1 -0
  93. package/node_modules/@pi-claude-code-agent/runtime/dist/index.d.ts +6 -0
  94. package/node_modules/@pi-claude-code-agent/runtime/dist/index.js +5 -0
  95. package/node_modules/@pi-claude-code-agent/runtime/dist/index.js.map +1 -0
  96. package/node_modules/@pi-claude-code-agent/runtime/dist/persistence.d.ts +16 -0
  97. package/node_modules/@pi-claude-code-agent/runtime/dist/persistence.js +94 -0
  98. package/node_modules/@pi-claude-code-agent/runtime/dist/persistence.js.map +1 -0
  99. package/node_modules/@pi-claude-code-agent/runtime/dist/runtime.d.ts +31 -0
  100. package/node_modules/@pi-claude-code-agent/runtime/dist/runtime.js +409 -0
  101. package/node_modules/@pi-claude-code-agent/runtime/dist/runtime.js.map +1 -0
  102. package/node_modules/@pi-claude-code-agent/runtime/dist/types.d.ts +185 -0
  103. package/node_modules/@pi-claude-code-agent/runtime/dist/types.js +2 -0
  104. package/node_modules/@pi-claude-code-agent/runtime/dist/types.js.map +1 -0
  105. package/node_modules/@pi-claude-code-agent/runtime/package.json +32 -0
  106. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/backend.d.ts +34 -0
  107. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/backend.js +327 -0
  108. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/backend.js.map +1 -0
  109. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/cli.d.ts +2 -0
  110. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/cli.js +17 -0
  111. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/cli.js.map +1 -0
  112. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/index.d.ts +4 -0
  113. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/index.js +4 -0
  114. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/index.js.map +1 -0
  115. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/persistence.d.ts +12 -0
  116. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/persistence.js +81 -0
  117. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/persistence.js.map +1 -0
  118. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/types.d.ts +72 -0
  119. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/types.js +2 -0
  120. package/node_modules/@pi-claude-code-agent/subagents-backend/dist/types.js.map +1 -0
  121. package/node_modules/@pi-claude-code-agent/subagents-backend/package.json +32 -0
  122. package/node_modules/@pi-claude-code-agent/teams-backend/dist/backend.d.ts +27 -0
  123. package/node_modules/@pi-claude-code-agent/teams-backend/dist/backend.js +194 -0
  124. package/node_modules/@pi-claude-code-agent/teams-backend/dist/backend.js.map +1 -0
  125. package/node_modules/@pi-claude-code-agent/teams-backend/dist/cli.d.ts +2 -0
  126. package/node_modules/@pi-claude-code-agent/teams-backend/dist/cli.js +21 -0
  127. package/node_modules/@pi-claude-code-agent/teams-backend/dist/cli.js.map +1 -0
  128. package/node_modules/@pi-claude-code-agent/teams-backend/dist/index.d.ts +4 -0
  129. package/node_modules/@pi-claude-code-agent/teams-backend/dist/index.js +4 -0
  130. package/node_modules/@pi-claude-code-agent/teams-backend/dist/index.js.map +1 -0
  131. package/node_modules/@pi-claude-code-agent/teams-backend/dist/persistence.d.ts +8 -0
  132. package/node_modules/@pi-claude-code-agent/teams-backend/dist/persistence.js +66 -0
  133. package/node_modules/@pi-claude-code-agent/teams-backend/dist/persistence.js.map +1 -0
  134. package/node_modules/@pi-claude-code-agent/teams-backend/dist/types.d.ts +41 -0
  135. package/node_modules/@pi-claude-code-agent/teams-backend/dist/types.js +2 -0
  136. package/node_modules/@pi-claude-code-agent/teams-backend/dist/types.js.map +1 -0
  137. package/node_modules/@pi-claude-code-agent/teams-backend/package.json +33 -0
  138. package/package.json +98 -0
@@ -0,0 +1,83 @@
1
+ export interface PeerRelaySnapshot {
2
+ sessionId: string;
3
+ state: string;
4
+ updatedAt: string;
5
+ relayKey: string;
6
+ }
7
+
8
+ export interface PeerRelayMessageInput {
9
+ peerName: string;
10
+ state: string;
11
+ sessionId: string;
12
+ message: string;
13
+ }
14
+
15
+ const RELAYABLE_STATES = new Set(["idle", "waiting", "error"]);
16
+
17
+ export function createPeerRelaySnapshot(input: { sessionId: string; state: string; updatedAt: string; messageText?: string }): PeerRelaySnapshot {
18
+ const digest = input.messageText ? shortDigest(input.messageText) : undefined;
19
+ return {
20
+ sessionId: input.sessionId,
21
+ state: input.state,
22
+ updatedAt: input.updatedAt,
23
+ relayKey: digest ? `${input.sessionId}:${input.state}:${digest}` : `${input.sessionId}:${input.state}`,
24
+ };
25
+ }
26
+
27
+ export function shouldRelayPeerCompletion(previous: PeerRelaySnapshot | undefined, current: PeerRelaySnapshot): boolean {
28
+ if (!RELAYABLE_STATES.has(current.state)) {
29
+ return false;
30
+ }
31
+ return previous !== undefined && previous.relayKey !== current.relayKey;
32
+ }
33
+
34
+ export function shouldForceRelayPeerCompletion(previous: PeerRelaySnapshot | undefined, current: PeerRelaySnapshot): boolean {
35
+ if (!RELAYABLE_STATES.has(current.state)) {
36
+ return false;
37
+ }
38
+ return previous?.relayKey !== current.relayKey;
39
+ }
40
+
41
+ export function formatQuotedTextBlock(text: string, language = "text"): string {
42
+ const longestBacktickRun = Math.max(...(text.match(/`+/g) ?? [""]).map((match) => match.length));
43
+ const fence = "`".repeat(Math.max(3, longestBacktickRun + 1));
44
+ return [`${fence}${language}`, text, fence].join("\n");
45
+ }
46
+
47
+ export function formatPeerCompletionTurn(input: PeerRelayMessageInput): string {
48
+ const headline = input.state === "waiting"
49
+ ? `Peer ${input.peerName} needs input.`
50
+ : input.state === "error"
51
+ ? `Peer ${input.peerName} failed.`
52
+ : `Peer ${input.peerName} finished.`;
53
+
54
+ return [
55
+ `[peer_update name=${input.peerName} state=${input.state} session=${shortId(input.sessionId, 12)}]`,
56
+ "Automated peer update.",
57
+ "",
58
+ `Peer: ${input.peerName}`,
59
+ `State: ${input.state}`,
60
+ `Session: ${shortId(input.sessionId, 12)}`,
61
+ headline,
62
+ "",
63
+ "Latest peer message:",
64
+ formatQuotedTextBlock(input.message),
65
+ "",
66
+ "Use this as internal orchestration context.",
67
+ "Choose next step: continue orchestration, ask follow-up, or ignore if already handled.",
68
+ "Do not quote this wrapper verbatim to the user.",
69
+ ].join("\n");
70
+ }
71
+
72
+ function shortId(value: string, size: number): string {
73
+ return value.length > size ? value.slice(0, size) : value;
74
+ }
75
+
76
+ // DJB2 hash — deterministic, no imports, 8 hex chars.
77
+ function shortDigest(text: string): string {
78
+ let hash = 5381;
79
+ for (let i = 0; i < text.length; i++) {
80
+ hash = (((hash << 5) + hash) ^ text.charCodeAt(i)) >>> 0;
81
+ }
82
+ return hash.toString(16).padStart(8, "0");
83
+ }
@@ -0,0 +1,239 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { BridgePeer } from "@pi-claude-code-agent/intercom-bridge";
4
+ import type { RuntimeEvent } from "@pi-claude-code-agent/runtime";
5
+ import { buildPeerActivityRow, getPeerFirstHealth, isPeerVisibleInWidget } from "./peer-ux.js";
6
+
7
+ function makePeer(overrides: Partial<BridgePeer> = {}): BridgePeer {
8
+ return {
9
+ name: "reviewer",
10
+ sessionId: "session-1",
11
+ cwd: process.cwd(),
12
+ state: "idle",
13
+ createdAt: "2026-05-01T19:10:00.000Z",
14
+ updatedAt: "2026-05-01T19:14:05.000Z",
15
+ lastActivityAt: "2026-05-01T19:14:05.000Z",
16
+ ...overrides,
17
+ };
18
+ }
19
+
20
+ function makeEvent(event: Partial<RuntimeEvent> & Pick<RuntimeEvent, "type">): RuntimeEvent {
21
+ return {
22
+ id: "event-1",
23
+ sessionId: "session-1",
24
+ sequence: 1,
25
+ timestamp: "2026-05-01T19:14:05.000Z",
26
+ ...event,
27
+ } as RuntimeEvent;
28
+ }
29
+
30
+ test("buildPeerActivityRow summarizes active Bash tool use", () => {
31
+ const row = buildPeerActivityRow(makePeer({ state: "busy" }), [
32
+ makeEvent({
33
+ type: "tool",
34
+ phase: "requested",
35
+ toolName: "Bash",
36
+ input: { command: "npm test" },
37
+ }),
38
+ ]);
39
+
40
+ assert.equal(row.state, "busy");
41
+ assert.equal(row.activity, "Bash: npm test");
42
+ });
43
+
44
+ test("buildPeerActivityRow summarizes last reply for idle peer", () => {
45
+ const row = buildPeerActivityRow(makePeer(), [
46
+ makeEvent({
47
+ type: "message",
48
+ role: "assistant",
49
+ message: {
50
+ role: "assistant",
51
+ blocks: [{ type: "text", text: "peer-ok" }],
52
+ },
53
+ }),
54
+ ]);
55
+
56
+ assert.equal(row.state, "idle");
57
+ assert.equal(row.activity, "last reply: peer-ok");
58
+ });
59
+
60
+ test("buildPeerActivityRow detects waiting for input", () => {
61
+ const row = buildPeerActivityRow(makePeer(), [
62
+ makeEvent({
63
+ type: "message",
64
+ role: "assistant",
65
+ message: {
66
+ role: "assistant",
67
+ blocks: [{ type: "text", text: "Please provide the failing command." }],
68
+ },
69
+ }),
70
+ ]);
71
+
72
+ assert.equal(row.state, "waiting");
73
+ assert.equal(row.activity, "needs input");
74
+ });
75
+
76
+ test("buildPeerActivityRow does not flag completed reports as waiting", () => {
77
+ const row = buildPeerActivityRow(makePeer(), [
78
+ makeEvent({
79
+ type: "message",
80
+ role: "assistant",
81
+ message: {
82
+ role: "assistant",
83
+ blocks: [{
84
+ type: "text",
85
+ text: [
86
+ "Slice implemented.",
87
+ "",
88
+ "Changed files:",
89
+ "- extensions/peer-ux.ts",
90
+ "",
91
+ "Commands:",
92
+ "- npm test",
93
+ "",
94
+ "Blockers: none.",
95
+ "Residual risk: low.",
96
+ ].join("\n"),
97
+ }],
98
+ },
99
+ }),
100
+ ]);
101
+
102
+ assert.equal(row.state, "idle");
103
+ assert.match(row.activity, /^last reply:/);
104
+ });
105
+
106
+ test("buildPeerActivityRow does not treat report headings as unresolved asks", () => {
107
+ const row = buildPeerActivityRow(makePeer(), [
108
+ makeEvent({
109
+ type: "message",
110
+ role: "assistant",
111
+ message: {
112
+ role: "assistant",
113
+ blocks: [{ type: "text", text: "What changed:\n- tightened peer state mapping\n\nNext steps: none." }],
114
+ },
115
+ }),
116
+ ]);
117
+
118
+ assert.equal(row.state, "idle");
119
+ assert.match(row.activity, /^last reply:/);
120
+ });
121
+
122
+ test("buildPeerActivityRow includes latest token usage from result events", () => {
123
+ const row = buildPeerActivityRow(makePeer(), [
124
+ makeEvent({
125
+ type: "result",
126
+ ok: true,
127
+ summary: "done",
128
+ usage: {
129
+ inputTokens: 123_000,
130
+ outputTokens: 456,
131
+ cacheReadInputTokens: 100_000,
132
+ reasoningOutputTokens: 12,
133
+ },
134
+ }),
135
+ ]);
136
+
137
+ assert.equal(row.inputTokens, 123_000);
138
+ assert.equal(row.outputTokens, 456);
139
+ assert.equal(row.cacheReadInputTokens, 100_000);
140
+ assert.equal(row.reasoningOutputTokens, 12);
141
+ });
142
+
143
+ test("buildPeerActivityRow includes latest context usage from result events", () => {
144
+ const row = buildPeerActivityRow(makePeer(), [
145
+ makeEvent({
146
+ type: "result",
147
+ ok: true,
148
+ summary: "done",
149
+ usage: {
150
+ inputTokens: 20_000,
151
+ outputTokens: 100,
152
+ contextTokens: 50_000,
153
+ contextWindow: 200_000,
154
+ contextPercentage: 25,
155
+ },
156
+ }),
157
+ ]);
158
+
159
+ assert.equal(row.contextTokens, 50_000);
160
+ assert.equal(row.contextWindow, 200_000);
161
+ assert.equal(row.contextPercentage, 25);
162
+ });
163
+
164
+ test("buildPeerActivityRow derives context usage from raw Claude SDK modelUsage fallback", () => {
165
+ const row = buildPeerActivityRow(makePeer(), [
166
+ makeEvent({
167
+ type: "result",
168
+ ok: true,
169
+ summary: "done",
170
+ usage: {
171
+ inputTokens: 6,
172
+ outputTokens: 23,
173
+ cacheCreationInputTokens: 29_989,
174
+ cacheReadInputTokens: 0,
175
+ },
176
+ raw: {
177
+ type: "result",
178
+ modelUsage: {
179
+ "claude-haiku-4-5-20251001": {
180
+ inputTokens: 359,
181
+ outputTokens: 13,
182
+ cacheReadInputTokens: 0,
183
+ cacheCreationInputTokens: 0,
184
+ contextWindow: 200_000,
185
+ maxOutputTokens: 32_000,
186
+ },
187
+ "claude-opus-4-7[1m]": {
188
+ inputTokens: 6,
189
+ outputTokens: 23,
190
+ cacheReadInputTokens: 0,
191
+ cacheCreationInputTokens: 29_989,
192
+ contextWindow: 1_000_000,
193
+ maxOutputTokens: 64_000,
194
+ },
195
+ },
196
+ },
197
+ }),
198
+ ]);
199
+
200
+ assert.equal(row.contextTokens, 29_995);
201
+ assert.equal(row.contextWindow, 1_000_000);
202
+ assert.equal(row.contextPercentage, 3);
203
+ });
204
+
205
+ test("buildPeerActivityRow includes driver and model from peer", () => {
206
+ const row = buildPeerActivityRow(makePeer({ driver: "codex-cli", model: "claude-sonnet-4-6" }), []);
207
+ assert.equal(row.driver, "codex-cli");
208
+ assert.equal(row.model, "claude-sonnet-4-6");
209
+ });
210
+
211
+ test("buildPeerActivityRow has undefined driver and model when peer has none", () => {
212
+ const row = buildPeerActivityRow(makePeer(), []);
213
+ assert.equal(row.driver, undefined);
214
+ assert.equal(row.model, undefined);
215
+ });
216
+
217
+ test("stopped peers are hidden from the compact widget", () => {
218
+ assert.equal(isPeerVisibleInWidget(buildPeerActivityRow(makePeer({ state: "idle" }), [])), true);
219
+ assert.equal(isPeerVisibleInWidget(buildPeerActivityRow(makePeer({ state: "stopped" }), [])), false);
220
+ });
221
+
222
+ test("peer-first health favors warning over activity", () => {
223
+ const busy = buildPeerActivityRow(makePeer({ state: "busy" }), []);
224
+ const waiting = buildPeerActivityRow(makePeer(), [
225
+ makeEvent({
226
+ type: "message",
227
+ role: "assistant",
228
+ message: {
229
+ role: "assistant",
230
+ blocks: [{ type: "text", text: "What file should I inspect next?" }],
231
+ },
232
+ }),
233
+ ]);
234
+
235
+ assert.equal(getPeerFirstHealth([busy], false), "active");
236
+ assert.equal(getPeerFirstHealth([waiting], false), "warning");
237
+ assert.equal(getPeerFirstHealth([], true), "warning");
238
+ assert.equal(getPeerFirstHealth([], false), "idle");
239
+ });
@@ -0,0 +1,327 @@
1
+ import { basename } from "node:path";
2
+ import type { BridgePeer } from "@pi-claude-code-agent/intercom-bridge";
3
+ import type { RuntimeEvent, ToolEvent } from "@pi-claude-code-agent/runtime";
4
+
5
+ export interface PeerActivityRow {
6
+ name: string;
7
+ state: string;
8
+ activity: string;
9
+ lastUpdateAt: string;
10
+ sessionId: string;
11
+ driver?: string;
12
+ model?: string;
13
+ contextTokens?: number;
14
+ contextWindow?: number;
15
+ contextPercentage?: number;
16
+ inputTokens?: number;
17
+ outputTokens?: number;
18
+ cacheReadInputTokens?: number;
19
+ reasoningOutputTokens?: number;
20
+ }
21
+
22
+ export function buildPeerActivityRow(peer: BridgePeer, events: RuntimeEvent[]): PeerActivityRow {
23
+ const summary = summarizePeerActivity(peer, events);
24
+ const usage = findLastResultUsage(events);
25
+ return {
26
+ name: peer.name,
27
+ state: summary.state,
28
+ activity: summary.activity,
29
+ lastUpdateAt: peer.updatedAt,
30
+ sessionId: peer.sessionId,
31
+ driver: peer.driver,
32
+ model: peer.model,
33
+ contextTokens: usage?.contextTokens,
34
+ contextWindow: usage?.contextWindow,
35
+ contextPercentage: usage?.contextPercentage,
36
+ inputTokens: usage?.inputTokens,
37
+ outputTokens: usage?.outputTokens,
38
+ cacheReadInputTokens: usage?.cacheReadInputTokens,
39
+ reasoningOutputTokens: usage?.reasoningOutputTokens,
40
+ };
41
+ }
42
+
43
+ export function summarizePeerActivity(peer: BridgePeer, events: RuntimeEvent[]): { state: string; activity: string } {
44
+ const lastAssistantText = findLastAssistantText(events);
45
+ const waitingForInput = Boolean(lastAssistantText && looksLikeWaitingForInput(lastAssistantText));
46
+
47
+ switch (peer.state) {
48
+ case "errored":
49
+ return {
50
+ state: "error",
51
+ activity: summarizeError(events),
52
+ };
53
+ case "disconnected":
54
+ return {
55
+ state: "offline",
56
+ activity: `last seen ${formatIsoTime(peer.updatedAt, false)}`,
57
+ };
58
+ case "stopped":
59
+ return {
60
+ state: "stopped",
61
+ activity: `last seen ${formatIsoTime(peer.updatedAt, false)}`,
62
+ };
63
+ case "interrupted":
64
+ return {
65
+ state: waitingForInput ? "waiting" : "stopped",
66
+ activity: waitingForInput ? "needs input" : "interrupted",
67
+ };
68
+ default:
69
+ break;
70
+ }
71
+
72
+ const activeTool = findActiveTool(events);
73
+ if (["starting", "busy"].includes(peer.state)) {
74
+ if (activeTool) {
75
+ return {
76
+ state: "busy",
77
+ activity: summarizeTool(activeTool),
78
+ };
79
+ }
80
+ return {
81
+ state: "busy",
82
+ activity: peer.state === "starting" ? "starting" : "drafting reply",
83
+ };
84
+ }
85
+
86
+ if (waitingForInput) {
87
+ return {
88
+ state: "waiting",
89
+ activity: "needs input",
90
+ };
91
+ }
92
+
93
+ if (lastAssistantText) {
94
+ return {
95
+ state: "idle",
96
+ activity: `last reply: ${summarizeReply(lastAssistantText)}`,
97
+ };
98
+ }
99
+
100
+ const lastResult = findLastResultSummary(events);
101
+ if (lastResult) {
102
+ return {
103
+ state: "idle",
104
+ activity: `last reply: ${summarizeReply(lastResult)}`,
105
+ };
106
+ }
107
+
108
+ return {
109
+ state: "idle",
110
+ activity: "idle",
111
+ };
112
+ }
113
+
114
+ export function getPeerFirstHealth(rows: PeerActivityRow[], transportDegraded: boolean): "idle" | "active" | "warning" {
115
+ if (transportDegraded || rows.some((row) => ["error", "offline", "waiting"].includes(row.state))) {
116
+ return "warning";
117
+ }
118
+ if (rows.some((row) => row.state === "busy")) {
119
+ return "active";
120
+ }
121
+ return "idle";
122
+ }
123
+
124
+ export function isPeerVisibleInWidget(row: PeerActivityRow): boolean {
125
+ return row.state !== "stopped";
126
+ }
127
+
128
+ function findActiveTool(events: RuntimeEvent[]): ToolEvent | undefined {
129
+ return [...events]
130
+ .reverse()
131
+ .find((event): event is ToolEvent => event.type === "tool" && event.phase === "requested");
132
+ }
133
+
134
+ function summarizeTool(event: ToolEvent): string {
135
+ const toolName = event.toolName || "Tool";
136
+ const input = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : undefined;
137
+ const lowerToolName = toolName.toLowerCase();
138
+
139
+ if (lowerToolName === "bash") {
140
+ const command = firstString(input?.command, input?.cmd, input?.script);
141
+ return command ? `Bash: ${truncateInline(command, 36)}` : "Bash";
142
+ }
143
+
144
+ if (["read", "view", "open"].includes(lowerToolName)) {
145
+ const path = firstString(input?.path, input?.filePath, input?.file, input?.target_file);
146
+ return path ? `Read: ${compactPath(path)}` : "Read";
147
+ }
148
+
149
+ if (["edit", "multiedit", "apply_patch"].includes(lowerToolName)) {
150
+ const path = firstString(input?.path, input?.filePath, input?.file, input?.target_file);
151
+ return path ? `Edit: ${compactPath(path)}` : "Edit";
152
+ }
153
+
154
+ if (["write", "create"].includes(lowerToolName)) {
155
+ const path = firstString(input?.path, input?.filePath, input?.file, input?.target_file);
156
+ return path ? `Write: ${compactPath(path)}` : "Write";
157
+ }
158
+
159
+ const path = firstString(input?.path, input?.filePath, input?.file, input?.target_file);
160
+ if (path) {
161
+ return `${capitalize(toolName)}: ${compactPath(path)}`;
162
+ }
163
+
164
+ return capitalize(toolName);
165
+ }
166
+
167
+ function findLastAssistantText(events: RuntimeEvent[]): string | undefined {
168
+ for (const event of [...events].reverse()) {
169
+ if (event.type !== "message" || event.role !== "assistant") {
170
+ continue;
171
+ }
172
+ const texts = event.message.blocks
173
+ .filter((block) => block.type !== "thinking")
174
+ .map((block) => block.text?.trim())
175
+ .filter((value): value is string => Boolean(value));
176
+ if (texts.length > 0) {
177
+ return texts.join(" ");
178
+ }
179
+ }
180
+ return undefined;
181
+ }
182
+
183
+ function findLastResultSummary(events: RuntimeEvent[]): string | undefined {
184
+ return [...events]
185
+ .reverse()
186
+ .find((event): event is Extract<RuntimeEvent, { type: "result" }> => event.type === "result")
187
+ ?.summary;
188
+ }
189
+
190
+ function findLastResultUsage(events: RuntimeEvent[]) {
191
+ const event = [...events]
192
+ .reverse()
193
+ .find((entry): entry is Extract<RuntimeEvent, { type: "result" }> => entry.type === "result" && (Boolean(entry.usage) || Boolean(entry.raw)));
194
+ if (!event) {
195
+ return undefined;
196
+ }
197
+ if (event.usage?.contextPercentage != null) {
198
+ return event.usage;
199
+ }
200
+ return {
201
+ ...event.usage,
202
+ ...deriveContextUsageFromRawResult(event.raw, event.usage),
203
+ };
204
+ }
205
+
206
+ function deriveContextUsageFromRawResult(raw: unknown, usage: Extract<RuntimeEvent, { type: "result" }>["usage"] = {}) {
207
+ if (!raw || typeof raw !== "object") {
208
+ return undefined;
209
+ }
210
+ const modelUsage = (raw as Record<string, unknown>).modelUsage;
211
+ if (!modelUsage || typeof modelUsage !== "object") {
212
+ return undefined;
213
+ }
214
+
215
+ const entries = Object.values(modelUsage as Record<string, unknown>)
216
+ .map((entry) => {
217
+ if (!entry || typeof entry !== "object") {
218
+ return undefined;
219
+ }
220
+ const item = entry as Record<string, unknown>;
221
+ const input = numberValue(item.inputTokens);
222
+ const cacheCreation = numberValue(item.cacheCreationInputTokens);
223
+ const cacheRead = numberValue(item.cacheReadInputTokens);
224
+ const contextTokens = [input, cacheCreation, cacheRead]
225
+ .filter((value): value is number => value != null)
226
+ .reduce((sum, value) => sum + value, 0);
227
+ const contextWindow = numberValue(item.contextWindow);
228
+ const maxOutputTokens = numberValue(item.maxOutputTokens);
229
+ return { contextTokens, contextWindow, maxOutputTokens };
230
+ })
231
+ .filter((entry): entry is { contextTokens: number; contextWindow?: number; maxOutputTokens?: number } => Boolean(entry));
232
+
233
+ const best = entries.sort((a, b) => b.contextTokens - a.contextTokens)[0];
234
+ if (!best?.contextWindow) {
235
+ return undefined;
236
+ }
237
+
238
+ const fallbackContextTokens = [usage.inputTokens, usage.cacheCreationInputTokens, usage.cacheReadInputTokens]
239
+ .filter((value): value is number => value != null)
240
+ .reduce((sum, value) => sum + value, 0);
241
+ const contextTokens = best.contextTokens > 0 ? best.contextTokens : fallbackContextTokens;
242
+ return {
243
+ contextTokens: contextTokens > 0 ? contextTokens : undefined,
244
+ contextWindow: best.contextWindow,
245
+ contextPercentage: contextTokens > 0 ? Math.round((contextTokens / best.contextWindow) * 1000) / 10 : undefined,
246
+ maxOutputTokens: best.maxOutputTokens,
247
+ };
248
+ }
249
+
250
+ function numberValue(value: unknown): number | undefined {
251
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
252
+ }
253
+
254
+ function summarizeError(events: RuntimeEvent[]): string {
255
+ const error = [...events]
256
+ .reverse()
257
+ .find((event): event is Extract<RuntimeEvent, { type: "error" }> => event.type === "error");
258
+ return error?.message ? `error: ${truncateInline(error.message, 40)}` : "error";
259
+ }
260
+
261
+ function looksLikeWaitingForInput(text: string): boolean {
262
+ const normalized = text.toLowerCase().replace(/\s+/g, " ").trim();
263
+ if (/\b(waiting for|need|needs)\s+(your\s+)?input\b/.test(normalized)) {
264
+ return true;
265
+ }
266
+ if (/\b(blocked on|blocked until|cannot continue without|can't continue without|need you to|i need you to|i need the|missing required)\b/.test(normalized)) {
267
+ return true;
268
+ }
269
+
270
+ const finalSentence = extractFinalSentence(text).toLowerCase();
271
+ if (!finalSentence) {
272
+ return false;
273
+ }
274
+
275
+ return [
276
+ /^please (provide|share|confirm|clarify|choose|send|attach|tell me|let me know)\b/,
277
+ /^(can|could|would) you\b/,
278
+ /^(which|what|where|when|who|how) (file|command|option|path|branch|directory|repo|repository|target|environment|env|approach|version|model|task|slice)\b/,
279
+ /^(do you want|should i|may i)\b/,
280
+ ].some((pattern) => pattern.test(finalSentence)) || finalSentence.endsWith("?");
281
+ }
282
+
283
+ function extractFinalSentence(text: string): string {
284
+ const lines = text
285
+ .split(/\r?\n/)
286
+ .map((line) => line.trim().replace(/^[-*]\s+/, "").replace(/^#{1,6}\s+/, ""))
287
+ .filter(Boolean);
288
+ const lastLine = lines.at(-1) ?? "";
289
+ const sentences = lastLine.match(/[^.!?]+[.!?]?/g)?.map((item) => item.trim()).filter(Boolean) ?? [];
290
+ return sentences.at(-1) ?? lastLine;
291
+ }
292
+
293
+ function summarizeReply(text: string): string {
294
+ return truncateInline(text.replace(/\s+/g, " ").trim(), 42);
295
+ }
296
+
297
+ function compactPath(path: string): string {
298
+ const normalized = path.replace(/\\/g, "/");
299
+ const parts = normalized.split("/").filter(Boolean);
300
+ if (parts.length === 0) {
301
+ return basename(path);
302
+ }
303
+ return parts.length <= 3 ? parts.join("/") : parts.slice(-3).join("/");
304
+ }
305
+
306
+ function firstString(...values: unknown[]): string | undefined {
307
+ return values.find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim();
308
+ }
309
+
310
+ function truncateInline(value: string, max: number): string {
311
+ return value.length > max ? `${value.slice(0, max - 1)}…` : value;
312
+ }
313
+
314
+ function capitalize(value: string): string {
315
+ return value.length > 0 ? `${value[0]!.toUpperCase()}${value.slice(1)}` : value;
316
+ }
317
+
318
+ function formatIsoTime(value: string, includeSeconds: boolean): string {
319
+ const date = new Date(value);
320
+ const hours = String(date.getHours()).padStart(2, "0");
321
+ const minutes = String(date.getMinutes()).padStart(2, "0");
322
+ if (!includeSeconds) {
323
+ return `${hours}:${minutes}`;
324
+ }
325
+ const seconds = String(date.getSeconds()).padStart(2, "0");
326
+ return `${hours}:${minutes}:${seconds}`;
327
+ }