@fitchmultz/pi-subagents 0.0.0-stage → 0.44.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 (342) hide show
  1. package/CHANGELOG.md +1645 -0
  2. package/LICENSE +21 -0
  3. package/README.md +1529 -2
  4. package/agents/context-builder.md +97 -0
  5. package/agents/debugger.md +51 -0
  6. package/agents/delegate.md +13 -0
  7. package/agents/fixer.md +37 -0
  8. package/agents/oracle.md +71 -0
  9. package/agents/planner.md +72 -0
  10. package/agents/researcher.md +51 -0
  11. package/agents/reviewer-claude.md +69 -0
  12. package/agents/reviewer-gpt.md +68 -0
  13. package/agents/reviewer-ponytail.md +82 -0
  14. package/agents/reviewer-security.md +81 -0
  15. package/agents/reviewer.md +69 -0
  16. package/agents/scout.md +66 -0
  17. package/agents/ui-designer.md +51 -0
  18. package/agents/watcher.md +26 -0
  19. package/agents/worker.md +46 -0
  20. package/agents/writer.md +36 -0
  21. package/dist/agents/agent-management.js +923 -0
  22. package/dist/agents/agent-scope.js +7 -0
  23. package/dist/agents/agent-selection.js +20 -0
  24. package/dist/agents/agent-serializer.js +100 -0
  25. package/dist/agents/agents.js +752 -0
  26. package/dist/agents/chain-serializer.js +367 -0
  27. package/dist/agents/frontmatter.js +21 -0
  28. package/dist/agents/identity.js +29 -0
  29. package/dist/agents/skills.js +570 -0
  30. package/dist/extension/build-info.js +1 -0
  31. package/dist/extension/compact-tools.js +112 -0
  32. package/dist/extension/config.js +22 -0
  33. package/dist/extension/control-notices.js +29 -0
  34. package/dist/extension/doctor.js +143 -0
  35. package/dist/extension/fanout-child.js +326 -0
  36. package/dist/extension/index.js +577 -0
  37. package/dist/extension/schemas.js +416 -0
  38. package/dist/extension/tool-input.js +20 -0
  39. package/dist/extension/tool-result.js +14 -0
  40. package/dist/history/canonical-result.js +81 -0
  41. package/dist/history/database.js +72 -0
  42. package/dist/history/index.js +194 -0
  43. package/dist/history/ingest.js +447 -0
  44. package/dist/history/queries.js +350 -0
  45. package/dist/history/store.js +216 -0
  46. package/dist/history/types.js +4 -0
  47. package/dist/history/worker.js +461 -0
  48. package/dist/intercom/intercom-bridge.js +112 -0
  49. package/dist/intercom/live-intercom.js +94 -0
  50. package/dist/intercom/result-intercom.js +373 -0
  51. package/dist/pi-intercom/agent-dir.js +10 -0
  52. package/dist/pi-intercom/broker/broker.js +426 -0
  53. package/dist/pi-intercom/broker/client.js +492 -0
  54. package/dist/pi-intercom/broker/framing.js +65 -0
  55. package/dist/pi-intercom/broker/paths.js +42 -0
  56. package/dist/pi-intercom/broker/pid.js +66 -0
  57. package/dist/pi-intercom/broker/spawn.js +221 -0
  58. package/dist/pi-intercom/config.js +102 -0
  59. package/dist/pi-intercom/index.js +2942 -0
  60. package/dist/pi-intercom/reply-tracker.js +182 -0
  61. package/dist/pi-intercom/session-targets.js +128 -0
  62. package/dist/pi-intercom/subagent-live-events.js +132 -0
  63. package/dist/pi-intercom/topics.js +157 -0
  64. package/dist/pi-intercom/types.js +163 -0
  65. package/dist/pi-intercom/ui/compose.js +269 -0
  66. package/dist/pi-intercom/ui/inline-message.js +80 -0
  67. package/dist/pi-intercom/ui/session-list.js +124 -0
  68. package/dist/runs/background/async-control.js +95 -0
  69. package/dist/runs/background/async-execution.js +676 -0
  70. package/dist/runs/background/async-job-tracker.js +383 -0
  71. package/dist/runs/background/async-result-file.js +58 -0
  72. package/dist/runs/background/async-resume.js +305 -0
  73. package/dist/runs/background/async-status.js +442 -0
  74. package/dist/runs/background/completion-dedupe.js +69 -0
  75. package/dist/runs/background/completion-delivery.js +245 -0
  76. package/dist/runs/background/notify.js +71 -0
  77. package/dist/runs/background/parallel-groups.js +55 -0
  78. package/dist/runs/background/result-watcher.js +426 -0
  79. package/dist/runs/background/run-id-resolver.js +64 -0
  80. package/dist/runs/background/run-status.js +382 -0
  81. package/dist/runs/background/stale-run-reconciler.js +335 -0
  82. package/dist/runs/background/subagent-runner-launcher.js +55 -0
  83. package/dist/runs/background/subagent-runner.js +1872 -0
  84. package/dist/runs/background/top-level-async.js +5 -0
  85. package/dist/runs/foreground/chain-clarify.js +1152 -0
  86. package/dist/runs/foreground/clarify-invocation.js +72 -0
  87. package/dist/runs/foreground/execution-input.js +423 -0
  88. package/dist/runs/foreground/foreground-control.js +817 -0
  89. package/dist/runs/foreground/question-control.js +77 -0
  90. package/dist/runs/foreground/run-async-path.js +205 -0
  91. package/dist/runs/foreground/subagent-executor.js +442 -0
  92. package/dist/runs/foreground/subagent-params.js +165 -0
  93. package/dist/runs/foreground/wait-run.js +139 -0
  94. package/dist/runs/shared/acceptance-contract.js +253 -0
  95. package/dist/runs/shared/acceptance-evaluation.js +256 -0
  96. package/dist/runs/shared/acceptance-finalization.js +280 -0
  97. package/dist/runs/shared/acceptance-reports.js +147 -0
  98. package/dist/runs/shared/acceptance.js +4 -0
  99. package/dist/runs/shared/chain-outputs.js +110 -0
  100. package/dist/runs/shared/child-attempt.js +693 -0
  101. package/dist/runs/shared/child-execution-cwd.js +96 -0
  102. package/dist/runs/shared/child-observations.js +114 -0
  103. package/dist/runs/shared/claude-code.js +199 -0
  104. package/dist/runs/shared/completion-guard.js +31 -0
  105. package/dist/runs/shared/dynamic-fanout.js +274 -0
  106. package/dist/runs/shared/history-index.js +72 -0
  107. package/dist/runs/shared/mcp-direct-tool-allowlist.js +334 -0
  108. package/dist/runs/shared/model-fallback.js +185 -0
  109. package/dist/runs/shared/mutating-tool-guard.js +170 -0
  110. package/dist/runs/shared/native-finalization.js +101 -0
  111. package/dist/runs/shared/native-usage.js +153 -0
  112. package/dist/runs/shared/nested-events.js +700 -0
  113. package/dist/runs/shared/nested-path.js +48 -0
  114. package/dist/runs/shared/nested-render.js +108 -0
  115. package/dist/runs/shared/parallel-utils.js +61 -0
  116. package/dist/runs/shared/parent-receipts.js +68 -0
  117. package/dist/runs/shared/parent-usage.js +121 -0
  118. package/dist/runs/shared/pi-args.js +310 -0
  119. package/dist/runs/shared/pi-spawn.js +52 -0
  120. package/dist/runs/shared/run-history-queries.js +45 -0
  121. package/dist/runs/shared/run-history.js +103 -0
  122. package/dist/runs/shared/run-id-ambiguity.js +4 -0
  123. package/dist/runs/shared/run-records.js +679 -0
  124. package/dist/runs/shared/session-cwd-preload.js +33 -0
  125. package/dist/runs/shared/single-output.js +222 -0
  126. package/dist/runs/shared/streaming-text.js +15 -0
  127. package/dist/runs/shared/structured-output.js +55 -0
  128. package/dist/runs/shared/subagent-control.js +179 -0
  129. package/dist/runs/shared/subagent-prompt-runtime.js +197 -0
  130. package/dist/runs/shared/subagent-tool-loop-guard.js +66 -0
  131. package/dist/runs/shared/supervisor-questions.js +320 -0
  132. package/dist/runs/shared/workflow-graph.js +214 -0
  133. package/dist/runs/shared/workflow-policy.js +84 -0
  134. package/dist/runs/shared/worktree.js +525 -0
  135. package/dist/shared/agent-context-policy.js +126 -0
  136. package/dist/shared/artifacts.js +96 -0
  137. package/dist/shared/atomic-json.js +13 -0
  138. package/dist/shared/cooperative.js +20 -0
  139. package/dist/shared/execution-cwd.js +14 -0
  140. package/dist/shared/file-coalescer.js +21 -0
  141. package/dist/shared/fork-context.js +49 -0
  142. package/dist/shared/formatters.js +85 -0
  143. package/dist/shared/journal-reader.js +407 -0
  144. package/dist/shared/lazy-tools.js +32 -0
  145. package/dist/shared/model-info.js +68 -0
  146. package/dist/shared/native-session.js +15 -0
  147. package/dist/shared/native-tui.js +18 -0
  148. package/dist/shared/native-typebox.js +20 -0
  149. package/dist/shared/post-exit-stdio-guard.js +197 -0
  150. package/dist/shared/prompt-sections.js +8 -0
  151. package/dist/shared/session-entries.js +52 -0
  152. package/dist/shared/session-identity.js +14 -0
  153. package/dist/shared/settings.js +254 -0
  154. package/dist/shared/status-format.js +111 -0
  155. package/dist/shared/temp-root.js +114 -0
  156. package/dist/shared/types.js +207 -0
  157. package/dist/shared/ui-mode.js +3 -0
  158. package/dist/shared/utils.js +334 -0
  159. package/dist/slash/prompt-template-bridge.js +298 -0
  160. package/dist/slash/slash-bridge.js +116 -0
  161. package/dist/slash/slash-commands.js +607 -0
  162. package/dist/slash/slash-live-state.js +246 -0
  163. package/dist/tui/action-hints.js +75 -0
  164. package/dist/tui/agent-history.js +197 -0
  165. package/dist/tui/agent-view.js +1656 -0
  166. package/dist/tui/render.js +1515 -0
  167. package/docs/intercom.md +579 -0
  168. package/docs/performance.md +121 -0
  169. package/package.json +101 -4
  170. package/prompts/gather-context-and-clarify.md +14 -0
  171. package/prompts/parallel-cleanup.md +62 -0
  172. package/prompts/parallel-context-build.md +56 -0
  173. package/prompts/parallel-handoff-plan.md +62 -0
  174. package/prompts/parallel-research.md +51 -0
  175. package/prompts/parallel-review.md +59 -0
  176. package/prompts/review-loop.md +42 -0
  177. package/scripts/build.mjs +134 -0
  178. package/scripts/check-compat.mjs +48 -0
  179. package/scripts/compat-host.mjs +19 -0
  180. package/scripts/linux-smoke.sh +51 -0
  181. package/scripts/local-install-smoke.mjs +66 -0
  182. package/scripts/native-package-smoke.mjs +82 -0
  183. package/scripts/package-smoke.mjs +205 -0
  184. package/scripts/prepare.mjs +67 -0
  185. package/scripts/real-pi-smoke.mjs +415 -0
  186. package/scripts/run-tests.mjs +149 -0
  187. package/skills/pi-intercom/SKILL.md +113 -0
  188. package/skills/pi-intercom/evals/evals.json +29 -0
  189. package/skills/pi-intercom/evals/trigger-evals.json +47 -0
  190. package/skills/pi-intercom/references/peer-sessions.md +69 -0
  191. package/skills/pi-subagents/SKILL.md +76 -0
  192. package/skills/pi-subagents/evals/evals.json +29 -0
  193. package/skills/pi-subagents/evals/trigger-evals.json +42 -0
  194. package/skills/pi-subagents/references/full-orchestration-guide.md +863 -0
  195. package/src/agents/agent-management.ts +813 -0
  196. package/src/agents/agent-scope.ts +7 -0
  197. package/src/agents/agent-selection.ts +23 -0
  198. package/src/agents/agent-serializer.ts +99 -0
  199. package/src/agents/agents.ts +916 -0
  200. package/src/agents/chain-serializer.ts +329 -0
  201. package/src/agents/frontmatter.ts +21 -0
  202. package/src/agents/identity.ts +30 -0
  203. package/src/agents/skills.ts +637 -0
  204. package/src/extension/build-info.ts +2 -0
  205. package/src/extension/compact-tools.ts +114 -0
  206. package/src/extension/config.ts +23 -0
  207. package/src/extension/control-notices.ts +47 -0
  208. package/src/extension/doctor.ts +171 -0
  209. package/src/extension/fanout-child.ts +312 -0
  210. package/src/extension/index.ts +649 -0
  211. package/src/extension/schemas.ts +444 -0
  212. package/src/extension/tool-input.ts +19 -0
  213. package/src/extension/tool-result.ts +16 -0
  214. package/src/history/canonical-result.ts +62 -0
  215. package/src/history/database.ts +59 -0
  216. package/src/history/index.ts +152 -0
  217. package/src/history/ingest.ts +332 -0
  218. package/src/history/queries.ts +241 -0
  219. package/src/history/store.ts +203 -0
  220. package/src/history/types.ts +166 -0
  221. package/src/history/worker.ts +317 -0
  222. package/src/intercom/intercom-bridge.ts +123 -0
  223. package/src/intercom/live-intercom.ts +104 -0
  224. package/src/intercom/result-intercom.ts +429 -0
  225. package/src/pi-intercom/agent-dir.ts +9 -0
  226. package/src/pi-intercom/broker/broker.ts +463 -0
  227. package/src/pi-intercom/broker/client.ts +558 -0
  228. package/src/pi-intercom/broker/framing.ts +77 -0
  229. package/src/pi-intercom/broker/paths.ts +47 -0
  230. package/src/pi-intercom/broker/pid.ts +59 -0
  231. package/src/pi-intercom/broker/spawn.ts +248 -0
  232. package/src/pi-intercom/config.ts +142 -0
  233. package/src/pi-intercom/index.ts +2929 -0
  234. package/src/pi-intercom/reply-tracker.ts +211 -0
  235. package/src/pi-intercom/session-targets.ts +172 -0
  236. package/src/pi-intercom/subagent-live-events.ts +133 -0
  237. package/src/pi-intercom/topics.ts +125 -0
  238. package/src/pi-intercom/types.ts +271 -0
  239. package/src/pi-intercom/ui/compose.ts +289 -0
  240. package/src/pi-intercom/ui/inline-message.ts +92 -0
  241. package/src/pi-intercom/ui/session-list.ts +139 -0
  242. package/src/runs/background/async-control.ts +89 -0
  243. package/src/runs/background/async-execution.ts +850 -0
  244. package/src/runs/background/async-job-tracker.ts +385 -0
  245. package/src/runs/background/async-result-file.ts +62 -0
  246. package/src/runs/background/async-resume.ts +333 -0
  247. package/src/runs/background/async-status.ts +481 -0
  248. package/src/runs/background/completion-dedupe.ts +79 -0
  249. package/src/runs/background/completion-delivery.ts +195 -0
  250. package/src/runs/background/notify.ts +123 -0
  251. package/src/runs/background/parallel-groups.ts +53 -0
  252. package/src/runs/background/result-watcher.ts +423 -0
  253. package/src/runs/background/run-id-resolver.ts +69 -0
  254. package/src/runs/background/run-status.ts +384 -0
  255. package/src/runs/background/stale-run-reconciler.ts +369 -0
  256. package/src/runs/background/subagent-runner-launcher.ts +56 -0
  257. package/src/runs/background/subagent-runner.ts +2053 -0
  258. package/src/runs/background/top-level-async.ts +13 -0
  259. package/src/runs/foreground/chain-clarify.ts +1340 -0
  260. package/src/runs/foreground/clarify-invocation.ts +77 -0
  261. package/src/runs/foreground/execution-input.ts +467 -0
  262. package/src/runs/foreground/foreground-control.ts +821 -0
  263. package/src/runs/foreground/question-control.ts +70 -0
  264. package/src/runs/foreground/run-async-path.ts +243 -0
  265. package/src/runs/foreground/subagent-executor.ts +501 -0
  266. package/src/runs/foreground/subagent-params.ts +320 -0
  267. package/src/runs/foreground/wait-run.ts +121 -0
  268. package/src/runs/shared/acceptance-contract.ts +257 -0
  269. package/src/runs/shared/acceptance-evaluation.ts +275 -0
  270. package/src/runs/shared/acceptance-finalization.ts +353 -0
  271. package/src/runs/shared/acceptance-reports.ts +152 -0
  272. package/src/runs/shared/acceptance.ts +33 -0
  273. package/src/runs/shared/chain-outputs.ts +114 -0
  274. package/src/runs/shared/child-attempt.ts +623 -0
  275. package/src/runs/shared/child-execution-cwd.ts +91 -0
  276. package/src/runs/shared/child-observations.ts +99 -0
  277. package/src/runs/shared/claude-code.ts +243 -0
  278. package/src/runs/shared/completion-guard.ts +35 -0
  279. package/src/runs/shared/dynamic-fanout.ts +295 -0
  280. package/src/runs/shared/history-index.ts +69 -0
  281. package/src/runs/shared/mcp-direct-tool-allowlist.ts +376 -0
  282. package/src/runs/shared/model-fallback.ts +219 -0
  283. package/src/runs/shared/mutating-tool-guard.ts +194 -0
  284. package/src/runs/shared/native-finalization.ts +128 -0
  285. package/src/runs/shared/native-usage.ts +113 -0
  286. package/src/runs/shared/nested-events.ts +752 -0
  287. package/src/runs/shared/nested-path.ts +52 -0
  288. package/src/runs/shared/nested-render.ts +119 -0
  289. package/src/runs/shared/parallel-utils.ts +153 -0
  290. package/src/runs/shared/parent-receipts.ts +59 -0
  291. package/src/runs/shared/parent-usage.ts +121 -0
  292. package/src/runs/shared/pi-args.ts +350 -0
  293. package/src/runs/shared/pi-spawn.ts +52 -0
  294. package/src/runs/shared/run-history-queries.ts +44 -0
  295. package/src/runs/shared/run-history.ts +93 -0
  296. package/src/runs/shared/run-id-ambiguity.ts +4 -0
  297. package/src/runs/shared/run-records.ts +607 -0
  298. package/src/runs/shared/session-cwd-preload.ts +27 -0
  299. package/src/runs/shared/single-output.ts +267 -0
  300. package/src/runs/shared/streaming-text.ts +18 -0
  301. package/src/runs/shared/structured-output.ts +68 -0
  302. package/src/runs/shared/subagent-control.ts +219 -0
  303. package/src/runs/shared/subagent-prompt-runtime.ts +201 -0
  304. package/src/runs/shared/subagent-tool-loop-guard.ts +89 -0
  305. package/src/runs/shared/supervisor-questions.ts +372 -0
  306. package/src/runs/shared/workflow-graph.ts +220 -0
  307. package/src/runs/shared/workflow-policy.ts +125 -0
  308. package/src/runs/shared/worktree.ts +635 -0
  309. package/src/shared/agent-context-policy.ts +184 -0
  310. package/src/shared/artifacts.ts +95 -0
  311. package/src/shared/atomic-json.ts +16 -0
  312. package/src/shared/cooperative.ts +21 -0
  313. package/src/shared/execution-cwd.ts +14 -0
  314. package/src/shared/file-coalescer.ts +29 -0
  315. package/src/shared/fork-context.ts +68 -0
  316. package/src/shared/formatters.ts +84 -0
  317. package/src/shared/journal-reader.ts +333 -0
  318. package/src/shared/lazy-tools.ts +36 -0
  319. package/src/shared/model-info.ts +81 -0
  320. package/src/shared/native-session.ts +8 -0
  321. package/src/shared/native-tui.ts +10 -0
  322. package/src/shared/native-typebox.ts +14 -0
  323. package/src/shared/post-exit-stdio-guard.ts +204 -0
  324. package/src/shared/prompt-sections.ts +10 -0
  325. package/src/shared/session-entries.ts +49 -0
  326. package/src/shared/session-identity.ts +18 -0
  327. package/src/shared/settings.ts +384 -0
  328. package/src/shared/status-format.ts +123 -0
  329. package/src/shared/temp-root.ts +107 -0
  330. package/src/shared/types.ts +1259 -0
  331. package/src/shared/ui-mode.ts +5 -0
  332. package/src/shared/utils.ts +342 -0
  333. package/src/slash/prompt-template-bridge.ts +408 -0
  334. package/src/slash/slash-bridge.ts +178 -0
  335. package/src/slash/slash-commands.ts +617 -0
  336. package/src/slash/slash-live-state.ts +292 -0
  337. package/src/tui/action-hints.ts +69 -0
  338. package/src/tui/agent-history.ts +210 -0
  339. package/src/tui/agent-view.ts +1323 -0
  340. package/src/tui/render.ts +1550 -0
  341. package/tsconfig.build.json +12 -0
  342. package/tsconfig.json +19 -0
@@ -0,0 +1,211 @@
1
+ import type { Message, SessionInfo } from "./types.ts";
2
+ import { formatSessionTarget, resolveSessionTarget, shortSessionId } from "./session-targets.ts";
3
+
4
+ export interface IntercomContext {
5
+ from: SessionInfo;
6
+ message: Message;
7
+ receivedAt: number;
8
+ }
9
+
10
+ function resolveBySenderTarget(contexts: IntercomContext[], to: string): IntercomContext[] {
11
+ const sessions = contexts.map((context) => context.from);
12
+ const resolution = resolveSessionTarget(sessions, to);
13
+ if (resolution.status === "none" || resolution.status === "prefix_too_short") {
14
+ return [];
15
+ }
16
+ const matchingIds = new Set(resolution.matches.map((session) => session.id));
17
+ return contexts.filter((context) => matchingIds.has(context.from.id));
18
+ }
19
+
20
+ function tooShortSenderTargetMessage(contexts: IntercomContext[], to: string): string | null {
21
+ const resolution = resolveSessionTarget(contexts.map((context) => context.from), to);
22
+ if (resolution.status !== "prefix_too_short") {
23
+ return null;
24
+ }
25
+ const matchingIds = new Set(resolution.matches.map((session) => session.id));
26
+ const matches = contexts.filter((context) => matchingIds.has(context.from.id));
27
+ return `Pending ask target "${to}" is too short. Use one of: ${pendingSenderOptions(matches, contexts)}.`;
28
+ }
29
+
30
+ function pendingSenderOptions(contexts: IntercomContext[], allContexts: IntercomContext[] = contexts): string {
31
+ const allSenders = allContexts.map((context) => context.from);
32
+ return contexts
33
+ .map((context) => `${context.from.name || shortSessionId(context.from.id)}: to: ${JSON.stringify(formatSessionTarget(context.from, allSenders))} or replyTo: ${JSON.stringify(context.message.id)}`)
34
+ .join(", ");
35
+ }
36
+
37
+ export function isDurableSupervisorQuestion(message: Message): boolean {
38
+ return message.content.text.includes(`Question ID: ${message.id}`);
39
+ }
40
+
41
+ export class ReplyTracker {
42
+ private readonly pendingAsks = new Map<string, IntercomContext>();
43
+ private readonly pendingTurnContexts: IntercomContext[] = [];
44
+ private currentTurnContext: IntercomContext | null = null;
45
+ private activeAgentContext: IntercomContext | null = null;
46
+ private readonly askTimeoutMs: number;
47
+
48
+ constructor(askTimeoutMs = 10 * 60 * 1000) {
49
+ this.askTimeoutMs = askTimeoutMs;
50
+ }
51
+
52
+ recordIncomingMessage(from: SessionInfo, message: Message, receivedAt = Date.now()): IntercomContext {
53
+ const context = { from, message, receivedAt };
54
+ if (message.expectsReply) {
55
+ this.pruneExpired(receivedAt);
56
+ this.pendingAsks.set(message.id, context);
57
+ }
58
+ return context;
59
+ }
60
+
61
+ queueTurnContext(context: IntercomContext): void {
62
+ if (!context.message.expectsReply) return;
63
+ if (this.hasTurnContext(context.message.id)) return;
64
+ this.pendingTurnContexts.push(context);
65
+ }
66
+
67
+ beginTurn(now = Date.now()): void {
68
+ this.pruneExpired(now);
69
+ this.currentTurnContext = this.pendingTurnContexts.shift() ?? null;
70
+ if (this.currentTurnContext) {
71
+ this.activeAgentContext = this.currentTurnContext;
72
+ }
73
+ }
74
+
75
+ currentTurn(): IntercomContext | null {
76
+ return this.currentTurnContext ?? this.activeAgentContext;
77
+ }
78
+
79
+ endTurn(): void {
80
+ this.currentTurnContext = null;
81
+ }
82
+
83
+ endAgent(): void {
84
+ this.currentTurnContext = null;
85
+ this.activeAgentContext = null;
86
+ }
87
+
88
+ reset(): void {
89
+ this.pendingAsks.clear();
90
+ this.pendingTurnContexts.length = 0;
91
+ this.currentTurnContext = null;
92
+ this.activeAgentContext = null;
93
+ }
94
+
95
+ expireSender(sessionId: string): number {
96
+ const expires = (context: IntercomContext) => context.from.id === sessionId && !isDurableSupervisorQuestion(context.message);
97
+ let expired = 0;
98
+ for (const [messageId, context] of this.pendingAsks) {
99
+ if (expires(context)) {
100
+ this.pendingAsks.delete(messageId);
101
+ expired += 1;
102
+ }
103
+ }
104
+ const beforeQueued = this.pendingTurnContexts.length;
105
+ for (let index = this.pendingTurnContexts.length - 1; index >= 0; index -= 1) {
106
+ if (expires(this.pendingTurnContexts[index]!)) {
107
+ this.pendingTurnContexts.splice(index, 1);
108
+ }
109
+ }
110
+ expired += beforeQueued - this.pendingTurnContexts.length;
111
+ if (this.currentTurnContext && expires(this.currentTurnContext)) {
112
+ this.currentTurnContext = null;
113
+ expired += 1;
114
+ }
115
+ if (this.activeAgentContext && expires(this.activeAgentContext)) {
116
+ this.activeAgentContext = null;
117
+ expired += 1;
118
+ }
119
+ return expired;
120
+ }
121
+
122
+ resolveReplyTarget(options: { to?: string; replyTo?: string }, now = Date.now()): IntercomContext {
123
+ this.pruneExpired(now);
124
+
125
+ const pending = Array.from(this.pendingAsks.values());
126
+ const contexts = this.currentTurnContext
127
+ ? [this.currentTurnContext, ...pending.filter((context) => context.message.id !== this.currentTurnContext?.message.id)]
128
+ : pending;
129
+
130
+ if (options.replyTo) {
131
+ const target = contexts.find((context) => context.message.id === options.replyTo);
132
+ if (!target) {
133
+ throw new Error(`No pending ask with replyTo "${options.replyTo}"`);
134
+ }
135
+ if (options.to) {
136
+ const tooShortMessage = tooShortSenderTargetMessage(contexts, options.to);
137
+ if (tooShortMessage) {
138
+ throw new Error(tooShortMessage);
139
+ }
140
+ const senderMatches = resolveBySenderTarget(contexts, options.to);
141
+ if (!senderMatches.some((context) => context.message.id === target.message.id)) {
142
+ throw new Error(`Pending ask "${options.replyTo}" is not from "${options.to}"`);
143
+ }
144
+ }
145
+ return target;
146
+ }
147
+
148
+ if (options.to) {
149
+ const tooShortMessage = tooShortSenderTargetMessage(contexts, options.to);
150
+ if (tooShortMessage) {
151
+ throw new Error(tooShortMessage);
152
+ }
153
+ const matches = resolveBySenderTarget(contexts, options.to);
154
+ if (matches.length === 1) {
155
+ return matches[0]!;
156
+ }
157
+ if (matches.length > 1) {
158
+ throw new Error(`Multiple pending asks from \"${options.to}\" — use one of: ${pendingSenderOptions(matches, contexts)}.`);
159
+ }
160
+ throw new Error(`No pending ask from \"${options.to}\"`);
161
+ }
162
+
163
+ if (this.currentTurnContext) {
164
+ return this.currentTurnContext;
165
+ }
166
+
167
+ if (pending.length === 1) {
168
+ return pending[0]!;
169
+ }
170
+
171
+ if (pending.length === 0) {
172
+ throw new Error("No active intercom context to reply to");
173
+ }
174
+
175
+ throw new Error("Multiple pending asks — specify `to`");
176
+ }
177
+
178
+ markReplied(replyTo: string): void {
179
+ this.removeContext(replyTo);
180
+ }
181
+
182
+ get hasReplyContext(): boolean {
183
+ return this.pendingAsks.size > 0 || this.pendingTurnContexts.length > 0 || this.currentTurnContext !== null || this.activeAgentContext !== null;
184
+ }
185
+
186
+ listPending(now = Date.now()): IntercomContext[] {
187
+ this.pruneExpired(now);
188
+ return Array.from(this.pendingAsks.values()).sort((a, b) => a.receivedAt - b.receivedAt);
189
+ }
190
+
191
+ private hasTurnContext(messageId: string): boolean {
192
+ return this.pendingTurnContexts.some((context) => context.message.id === messageId)
193
+ || this.currentTurnContext?.message.id === messageId
194
+ || this.activeAgentContext?.message.id === messageId;
195
+ }
196
+
197
+ private removeContext(messageId: string): void {
198
+ this.pendingAsks.delete(messageId);
199
+ for (let index = this.pendingTurnContexts.length - 1; index >= 0; index -= 1) {
200
+ if (this.pendingTurnContexts[index]?.message.id === messageId) this.pendingTurnContexts.splice(index, 1);
201
+ }
202
+ if (this.currentTurnContext?.message.id === messageId) this.currentTurnContext = null;
203
+ if (this.activeAgentContext?.message.id === messageId) this.activeAgentContext = null;
204
+ }
205
+
206
+ private pruneExpired(now: number): void {
207
+ for (const [messageId, context] of this.pendingAsks) {
208
+ if (!isDurableSupervisorQuestion(context.message) && now - context.receivedAt > this.askTimeoutMs) this.removeContext(messageId);
209
+ }
210
+ }
211
+ }
@@ -0,0 +1,172 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import path from "node:path";
4
+
5
+ export interface TargetIdentity {
6
+ id: string;
7
+ name?: string;
8
+ }
9
+
10
+ export interface ProjectSessionIdentity extends TargetIdentity {
11
+ cwd: string;
12
+ projectId?: string;
13
+ }
14
+
15
+ export const MIN_SESSION_TARGET_PREFIX_LENGTH = 8;
16
+
17
+ export interface TargetResolution<T extends TargetIdentity> {
18
+ status: "none" | "found" | "ambiguous" | "prefix_too_short";
19
+ target?: T;
20
+ matches: T[];
21
+ minLength?: number;
22
+ }
23
+
24
+ export function shortSessionId(sessionId: string): string {
25
+ return sessionId.slice(0, MIN_SESSION_TARGET_PREFIX_LENGTH);
26
+ }
27
+
28
+ function normalizedPath(value: string): string {
29
+ return path.resolve(value);
30
+ }
31
+
32
+ function resolveGitCommonDirectory(cwd: string): Promise<string | undefined> {
33
+ const env = { ...process.env };
34
+ delete env.GIT_DIR;
35
+ delete env.GIT_COMMON_DIR;
36
+ delete env.GIT_WORK_TREE;
37
+ return new Promise((resolve) => {
38
+ execFile("git", ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], {
39
+ cwd: path.dirname(process.execPath),
40
+ encoding: "utf8",
41
+ env,
42
+ maxBuffer: 4096,
43
+ timeout: 500,
44
+ }, (error, stdout) => {
45
+ const gitDirectory = error ? "" : stdout.trim();
46
+ resolve(gitDirectory && !/[\0\r\n]/.test(gitDirectory)
47
+ ? normalizedPath(path.resolve(cwd, gitDirectory))
48
+ : undefined);
49
+ });
50
+ });
51
+ }
52
+
53
+ export async function resolveSessionProjectId(cwd: string): Promise<string> {
54
+ const gitDirectory = await resolveGitCommonDirectory(cwd);
55
+ const identity = gitDirectory ? `git:${gitDirectory}` : `cwd:${normalizedPath(cwd)}`;
56
+ return createHash("sha256").update(identity).digest("hex");
57
+ }
58
+
59
+ function normalizedNames(sessions: TargetIdentity[]): Set<string> {
60
+ return new Set(sessions
61
+ .map((session) => session.name?.trim().toLowerCase())
62
+ .filter((name): name is string => Boolean(name)));
63
+ }
64
+
65
+ export function formatSessionTarget(session: TargetIdentity, allSessions: TargetIdentity[] = [session]): string {
66
+ const ids = allSessions.map((candidate) => candidate.id.toLowerCase());
67
+ const names = normalizedNames(allSessions);
68
+ const id = session.id.toLowerCase();
69
+
70
+ for (let length = MIN_SESSION_TARGET_PREFIX_LENGTH; length < session.id.length; length += 1) {
71
+ const prefix = id.slice(0, length);
72
+ const uniqueIdPrefix = ids.filter((candidateId) => candidateId.startsWith(prefix)).length === 1;
73
+ if (uniqueIdPrefix && !names.has(prefix)) {
74
+ return session.id.slice(0, length);
75
+ }
76
+ }
77
+
78
+ return session.id;
79
+ }
80
+
81
+ export function formatTargetOptions(sessions: TargetIdentity[], allSessions: TargetIdentity[] = sessions): string {
82
+ return sessions
83
+ .map((session) => `${session.name || shortSessionId(session.id)} → ${formatSessionTarget(session, allSessions)}`)
84
+ .join(", ");
85
+ }
86
+
87
+ export function targetDisplayName(session: TargetIdentity, allSessions: TargetIdentity[] = [session]): string {
88
+ if (!session.name?.trim()) {
89
+ return session.id;
90
+ }
91
+
92
+ const lowerName = session.name.trim().toLowerCase();
93
+ const duplicateName = allSessions.some((candidate) =>
94
+ candidate.id !== session.id && candidate.name?.trim().toLowerCase() === lowerName
95
+ );
96
+ const nameConflictsWithOtherIdPrefix = allSessions.some((candidate) =>
97
+ candidate.id !== session.id && candidate.id.toLowerCase().startsWith(lowerName)
98
+ );
99
+
100
+ return duplicateName || nameConflictsWithOtherIdPrefix
101
+ ? `${session.name} (${formatSessionTarget(session, allSessions)})`
102
+ : session.name;
103
+ }
104
+
105
+ // The hint is deliberately a constant: it is appended to the system prompt,
106
+ // which is the very start of the provider prompt-cache prefix. Any per-turn
107
+ // variation there (live peer counts, checkout counts) invalidates the entire
108
+ // cached context for every turn in which fleet membership changed, which is
109
+ // expensive on large sessions. Live details belong behind intercom list.
110
+ export const PEER_AWARENESS_HINT = `Other Pi sessions may be connected to this project. Use load_intercom({}) if needed, then intercom({ action: "list" }) before changing shared state or coordinating known overlapping work. Routine standalone read-only tasks do not need a peer check. Coordinate only when work overlaps; use subagent controls for managed child runs.`;
111
+
112
+ export function filterProjectSessions<T extends ProjectSessionIdentity>(sessions: T[], currentSessionId: string): T[] {
113
+ const current = sessions.find((session) => session.id === currentSessionId);
114
+ if (!current) return [];
115
+
116
+ return sessions.filter((session) =>
117
+ session.id === currentSessionId
118
+ || session.cwd === current.cwd
119
+ || Boolean(current.projectId && session.projectId && session.projectId === current.projectId)
120
+ );
121
+ }
122
+
123
+ export function formatPeerAwarenessHint<T extends ProjectSessionIdentity>(sessions: T[], currentSessionId: string): string | undefined {
124
+ return filterProjectSessions(sessions, currentSessionId).some((session) => session.id !== currentSessionId)
125
+ ? PEER_AWARENESS_HINT
126
+ : undefined;
127
+ }
128
+
129
+ export function resolveSessionTarget<T extends TargetIdentity>(sessions: T[], rawTarget: string): TargetResolution<T> {
130
+ const target = rawTarget.trim();
131
+ const lowerTarget = target.toLowerCase();
132
+
133
+ const exactIdMatches = sessions.filter((session) => session.id.toLowerCase() === lowerTarget);
134
+ if (exactIdMatches.length === 1) {
135
+ return { status: "found", target: exactIdMatches[0], matches: exactIdMatches };
136
+ }
137
+ if (exactIdMatches.length > 1) {
138
+ return { status: "ambiguous", matches: exactIdMatches };
139
+ }
140
+
141
+ const nameMatches = sessions.filter((session) => session.name?.trim().toLowerCase() === lowerTarget);
142
+ const allPrefixMatches = sessions.filter((session) => session.id.toLowerCase().startsWith(lowerTarget));
143
+ const prefixMatches = target.length >= MIN_SESSION_TARGET_PREFIX_LENGTH ? allPrefixMatches : [];
144
+ if (target.length > 0 && target.length < MIN_SESSION_TARGET_PREFIX_LENGTH && allPrefixMatches.length > 0) {
145
+ if (nameMatches.length > 0) {
146
+ const matchesById = new Map<string, T>();
147
+ for (const session of [...nameMatches, ...allPrefixMatches]) {
148
+ matchesById.set(session.id, session);
149
+ }
150
+ const matches = Array.from(matchesById.values());
151
+ if (matches.length === 1) {
152
+ return { status: "found", target: matches[0], matches };
153
+ }
154
+ return { status: "ambiguous", matches };
155
+ }
156
+ return { status: "prefix_too_short", matches: allPrefixMatches, minLength: MIN_SESSION_TARGET_PREFIX_LENGTH };
157
+ }
158
+
159
+ const matchesById = new Map<string, T>();
160
+ for (const session of [...nameMatches, ...prefixMatches]) {
161
+ matchesById.set(session.id, session);
162
+ }
163
+ const matches = Array.from(matchesById.values());
164
+
165
+ if (matches.length === 1) {
166
+ return { status: "found", target: matches[0], matches };
167
+ }
168
+ if (matches.length > 1) {
169
+ return { status: "ambiguous", matches };
170
+ }
171
+ return { status: "none", matches: [] };
172
+ }
@@ -0,0 +1,133 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import type { IntercomClient } from "./broker/client.ts";
3
+ import type { SubagentIntercomConnection } from "../shared/types.ts";
4
+ import { formatSessionTarget, resolveSessionTarget as resolveSessionTargetValue } from "./session-targets.ts";
5
+ import { isAttachment, isHumanMessageOrigin, type Attachment, type HumanMessageOrigin } from "./types.ts";
6
+
7
+ const SUBAGENT_LIVE_INTERCOM_EVENT = "subagent:live-intercom";
8
+ const SUBAGENT_LIVE_INTERCOM_DELIVERY_EVENT = "subagent:live-intercom-delivery";
9
+ const SUBAGENT_INTERCOM_HEALTH_REQUEST_EVENT = "subagent:intercom-health-request";
10
+ const SUBAGENT_INTERCOM_HEALTH_RESPONSE_EVENT = "subagent:intercom-health-response";
11
+
12
+ type PiEvents = ExtensionAPI["events"];
13
+
14
+ type LiveEventDeps = {
15
+ events: PiEvents;
16
+ ensureConnected: () => Promise<IntercomClient>;
17
+ getConnection: () => { client: IntercomClient | null; connecting: boolean; started: boolean };
18
+ resolveSessionTarget: (client: IntercomClient, target: string) => Promise<string | null | undefined>;
19
+ currentSessionTargetMatches: (requestedTarget: string, resolvedTarget?: string, client?: IntercomClient) => boolean;
20
+ getLivenessCheck: () => () => boolean;
21
+ };
22
+
23
+ function getErrorMessage(error: unknown): string {
24
+ return error instanceof Error ? error.message : String(error);
25
+ }
26
+
27
+ function emitLiveDelivery(events: PiEvents, requestId: string | undefined, delivered: boolean, reason?: string, receipt?: { id: string; accepted: boolean; queued?: boolean }): void {
28
+ if (!requestId) return;
29
+ events.emit(SUBAGENT_LIVE_INTERCOM_DELIVERY_EVENT, {
30
+ requestId,
31
+ delivered,
32
+ ...(receipt ? { messageId: receipt.id, accepted: receipt.accepted, queued: receipt.queued } : {}),
33
+ ...(reason ? { reason } : {}),
34
+ });
35
+ }
36
+
37
+ function parseLiveMessagePayload(payload: unknown): { requestId: string; to: string; message: string; delivery: "queue" | "steer"; messageId?: string; human?: HumanMessageOrigin; attachments?: Attachment[] } | undefined {
38
+ if (!payload || typeof payload !== "object") return undefined;
39
+ const parsed = payload as { requestId?: unknown; to?: unknown; message?: unknown; delivery?: unknown; messageId?: unknown; human?: unknown; attachments?: unknown };
40
+ if (typeof parsed.requestId !== "string" || typeof parsed.to !== "string" || typeof parsed.message !== "string") return undefined;
41
+ return {
42
+ requestId: parsed.requestId,
43
+ to: parsed.to,
44
+ message: parsed.message,
45
+ delivery: parsed.delivery === "queue" ? "queue" : "steer",
46
+ ...(typeof parsed.messageId === "string" ? { messageId: parsed.messageId } : {}),
47
+ ...(isHumanMessageOrigin(parsed.human) ? { human: parsed.human } : {}),
48
+ ...(Array.isArray(parsed.attachments) && parsed.attachments.every(isAttachment) ? { attachments: parsed.attachments } : {}),
49
+ };
50
+ }
51
+
52
+ async function relayLiveSubagentMessage(payload: unknown, deps: LiveEventDeps): Promise<void> {
53
+ const parsed = parseLiveMessagePayload(payload);
54
+ if (!parsed) return;
55
+ const isLive = deps.getLivenessCheck();
56
+ if (!isLive()) return;
57
+ let activeClient: IntercomClient;
58
+ let target: string;
59
+ try {
60
+ activeClient = await deps.ensureConnected();
61
+ target = await deps.resolveSessionTarget(activeClient, parsed.to) ?? parsed.to;
62
+ } catch (error) {
63
+ if (isLive()) emitLiveDelivery(deps.events, parsed.requestId, false, getErrorMessage(error));
64
+ return;
65
+ }
66
+ if (!isLive()) return;
67
+ if (deps.currentSessionTargetMatches(parsed.to, target, activeClient)) {
68
+ emitLiveDelivery(deps.events, parsed.requestId, false, "Cannot message the current session");
69
+ return;
70
+ }
71
+ try {
72
+ const result = await activeClient.send(target, { text: parsed.message, delivery: parsed.delivery, messageId: parsed.messageId, human: parsed.human, attachments: parsed.attachments });
73
+ if (isLive()) emitLiveDelivery(deps.events, parsed.requestId, result.delivered, result.reason, result);
74
+ } catch (error) {
75
+ if (isLive()) emitLiveDelivery(deps.events, parsed.requestId, false, getErrorMessage(error));
76
+ }
77
+ }
78
+
79
+ function parseHealthPayload(payload: unknown): { requestId: string; targets: string[] } | undefined {
80
+ if (!payload || typeof payload !== "object") return undefined;
81
+ const parsed = payload as { requestId?: unknown; targets?: unknown };
82
+ if (typeof parsed.requestId !== "string" || !Array.isArray(parsed.targets)) return undefined;
83
+ const targets = parsed.targets.filter((target): target is string => typeof target === "string" && target.trim().length > 0);
84
+ return { requestId: parsed.requestId, targets };
85
+ }
86
+
87
+ async function answerLiveIntercomHealth(payload: unknown, deps: LiveEventDeps): Promise<void> {
88
+ const parsed = parseHealthPayload(payload);
89
+ if (!parsed) return;
90
+ const isLive = deps.getLivenessCheck();
91
+ const connectionSnapshot = (): SubagentIntercomConnection => {
92
+ const { client, connecting, started } = deps.getConnection();
93
+ return { status: !started ? "unknown" : connecting ? "connecting" : client?.isConnected() ? "unknown" : "disconnected", ...(client?.sessionId ? { sessionId: client.sessionId } : {}) };
94
+ };
95
+ const respond = (health: unknown[], connection: SubagentIntercomConnection) => {
96
+ if (isLive()) deps.events.emit(SUBAGENT_INTERCOM_HEALTH_RESPONSE_EVENT, { requestId: parsed.requestId, health, connection });
97
+ };
98
+ if (!isLive()) return;
99
+ try {
100
+ // An empty target list is a read-only check of this bridge, not a reconnect request.
101
+ const activeClient = parsed.targets.length ? await deps.ensureConnected() : deps.getConnection().client;
102
+ if (!activeClient?.isConnected()) { respond([], connectionSnapshot()); return; }
103
+ const sessions = await activeClient.listSessions();
104
+ const health = parsed.targets.map((target) => {
105
+ const resolution = resolveSessionTargetValue(sessions, target);
106
+ if (resolution.status !== "found") return { target, status: resolution.status };
107
+ const session = resolution.target!;
108
+ return {
109
+ target,
110
+ status: "registered" as const,
111
+ resolvedTarget: formatSessionTarget(session, sessions),
112
+ sessionId: session.id,
113
+ ...(session.name ? { sessionName: session.name } : {}),
114
+ ...(session.status ? { sessionStatus: session.status } : {}),
115
+ ...(session.acceptsAsks !== undefined ? { acceptsAsks: session.acceptsAsks } : {}),
116
+ ...(session.pendingAsks !== undefined ? { pendingAsks: session.pendingAsks } : {}),
117
+ ...(session.lastSeen !== undefined ? { lastSeen: session.lastSeen } : {}),
118
+ ...(session.lastIntercomActivity !== undefined ? { lastIntercomActivity: session.lastIntercomActivity } : {}),
119
+ };
120
+ });
121
+ const registered = activeClient.isConnected() && sessions.some((session) => session.id === activeClient.sessionId);
122
+ respond(health, registered ? { status: "connected", sessionId: activeClient.sessionId! } : { ...connectionSnapshot(), reason: "Current broker registration was not confirmed." });
123
+ } catch (error) {
124
+ respond(parsed.targets.map((target) => ({ target, status: "missing" })), { ...connectionSnapshot(), reason: getErrorMessage(error) });
125
+ }
126
+ }
127
+
128
+ export function registerSubagentLiveEventHandlers(deps: LiveEventDeps): Array<() => void> {
129
+ return [
130
+ deps.events.on(SUBAGENT_LIVE_INTERCOM_EVENT, (payload) => relayLiveSubagentMessage(payload, deps)),
131
+ deps.events.on(SUBAGENT_INTERCOM_HEALTH_REQUEST_EVENT, (payload) => answerLiveIntercomHealth(payload, deps)),
132
+ ];
133
+ }
@@ -0,0 +1,125 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Container, ScrollView, Text, matchesKey } from "@earendil-works/pi-tui";
3
+ import { actionHints } from "../tui/action-hints.ts";
4
+ import { isTopicSubscription, isTopicUpdate, type Message, type SessionInfo, type TopicSubscription, type TopicUpdate } from "./types.ts";
5
+
6
+ interface TopicRecord { from: Pick<SessionInfo, "id" | "name">; update: TopicUpdate; connected: boolean; notifiedRevision?: number }
7
+ const TOPIC_ENTRY = "intercom-topic";
8
+
9
+ /** Latest inspectable state plus native audit entries; routine updates never enter model context. */
10
+ export class IntercomTopics {
11
+ private pi: ExtensionAPI;
12
+ private getContext: () => ExtensionContext | null;
13
+ private records = new Map<string, TopicRecord>();
14
+ private disconnectedOwners = new Set<string>();
15
+ private render?: () => void;
16
+ private hydrate = new Set<string>();
17
+ readonly subscriptions = new Map<string, TopicSubscription>();
18
+ readonly published = new Map<string, TopicUpdate>();
19
+ constructor(pi: ExtensionAPI, getContext: () => ExtensionContext | null) { this.pi = pi; this.getContext = getContext; }
20
+
21
+ private save(data: object): void {
22
+ const ctx = this.getContext();
23
+ if (ctx) this.pi.appendEntry(TOPIC_ENTRY, { sessionId: ctx.sessionManager.getSessionId(), ...data });
24
+ }
25
+ start(ctx: ExtensionContext): void {
26
+ this.render = undefined;
27
+ this.records.clear(); this.disconnectedOwners.clear(); this.hydrate.clear(); this.subscriptions.clear(); this.published.clear();
28
+ for (const entry of ctx.sessionManager.getEntries()) {
29
+ if (entry.type !== "custom" || entry.customType !== TOPIC_ENTRY) continue;
30
+ const data = entry.data as { sessionId?: string; subscriptions?: TopicSubscription[]; published?: TopicUpdate; record?: TopicRecord };
31
+ if (data?.sessionId !== ctx.sessionManager.getSessionId()) continue;
32
+ if (Array.isArray(data.subscriptions) && data.subscriptions.every(isTopicSubscription)) {
33
+ this.subscriptions.clear(); for (const subscription of data.subscriptions) this.subscriptions.set(subscription.topic, subscription);
34
+ }
35
+ if (isTopicUpdate(data.published)) this.published.set(data.published.topic, data.published);
36
+ if (data.record && isTopicUpdate(data.record.update)) this.records.set(`${data.record.from.id}:${data.record.update.topic}`, { ...data.record, connected: false });
37
+ }
38
+ for (const topic of this.subscriptions.keys()) this.hydrate.add(topic);
39
+ }
40
+ subscribe(topic: string, awaitRelease?: boolean): void {
41
+ this.subscriptions.set(topic, { topic, ...(awaitRelease ? { awaitRelease } : {}) });
42
+ this.hydrate.add(topic);
43
+ this.save({ subscriptions: [...this.subscriptions.values()] });
44
+ }
45
+ unsubscribe(topic: string): void { this.subscriptions.delete(topic); this.save({ subscriptions: [...this.subscriptions.values()] }); }
46
+ publish(update: TopicUpdate, from: Pick<SessionInfo, "id" | "name">): void { this.published.set(update.topic, update); this.save({ published: update }); this.record(from, update); }
47
+ presence(): Required<Pick<SessionInfo, "subscriptions" | "topics">> { return { subscriptions: [...this.subscriptions.values()], topics: [...this.published.values()] }; }
48
+ private record(from: Pick<SessionInfo, "id" | "name">, update: TopicUpdate, connected = true): boolean {
49
+ const key = `${from.id}:${update.topic}`, previous = this.records.get(key);
50
+ if (previous && previous.update.revision >= update.revision) return false;
51
+ const record = { from: { id: from.id, name: from.name }, update, connected: connected && !this.disconnectedOwners.has(from.id), notifiedRevision: previous?.notifiedRevision };
52
+ this.records.set(key, record); this.save({ record }); this.render?.();
53
+ return true;
54
+ }
55
+ /** True means handled as quiet state (or unsubscribed/obsolete), not a conversation message. */
56
+ receive(from: SessionInfo, message: Message): boolean {
57
+ const update = message.topic;
58
+ if (!update) return false;
59
+ const subscription = this.subscriptions.get(update.topic);
60
+ if (!subscription) return true;
61
+ const key = `${from.id}:${update.topic}`;
62
+ if ((this.records.get(key)?.update.revision ?? 0) > update.revision) return true;
63
+ this.record(from, update);
64
+ if (update.event === "update" || update.event === "release" && !subscription.awaitRelease) return true;
65
+ const record = this.records.get(key)!;
66
+ if ((record.notifiedRevision ?? 0) >= update.revision) return true;
67
+ record.notifiedRevision = update.revision;
68
+ this.save({ record });
69
+ return false;
70
+ }
71
+ refresh(sessions: SessionInfo[]): void {
72
+ for (const session of sessions) this.disconnectedOwners.delete(session.id);
73
+ for (const record of this.records.values()) record.connected = sessions.some((session) => session.id === record.from.id);
74
+ for (const session of sessions) for (const update of session.topics ?? []) {
75
+ if (this.subscriptions.has(update.topic)) {
76
+ this.record(session, update);
77
+ const record = this.records.get(`${session.id}:${update.topic}`)!;
78
+ if (this.hydrate.has(update.topic) && (record.notifiedRevision ?? 0) < update.revision) {
79
+ record.notifiedRevision = update.revision; this.save({ record });
80
+ }
81
+ }
82
+ }
83
+ this.hydrate.clear();
84
+ }
85
+ disconnected(id?: string): void {
86
+ if (id) this.disconnectedOwners.add(id);
87
+ for (const record of this.records.values()) if (!id || record.from.id === id) { record.connected = false; this.disconnectedOwners.add(record.from.id); }
88
+ this.render?.();
89
+ }
90
+ inspect(topic?: string): string {
91
+ const subscriptions = [...this.subscriptions.values()].filter((item) => !topic || item.topic === topic);
92
+ const records = [...this.records.values()].filter((record) => !topic || record.update.topic === topic).sort((a, b) => b.update.updatedAt - a.update.updatedAt);
93
+ return ["Intercom topics · latest self-contained state (not a work queue or exclusive lock)",
94
+ `Subscriptions: ${subscriptions.map((item) => `${item.topic}${item.awaitRelease ? " (awaiting release)" : ""}`).join(", ") || "none"}`,
95
+ ...records.map(({ from, update, connected }) => `\n${update.topic} · ${from.name ?? from.id} · ${connected ? "connected" : "disconnected / unavailable"}\n${update.resource ? `Resource: ${update.resource} · declared ${update.ownership ?? "state unknown"}${!connected && update.ownership !== "released" ? "; disconnect is not release" : ""}\n` : ""}${new Date(update.updatedAt).toISOString()} · ${update.event}\n${update.text}`),
96
+ ...(!records.length ? ["No current records. Subscribe to an exact topic or publish a self-contained update."] : []),
97
+ ].join("\n");
98
+ }
99
+ async open(ctx: ExtensionContext, notice?: string): Promise<void> {
100
+ await ctx.ui.custom((tui, _theme, _keys, done) => {
101
+ const text = new Text("", 0, 0);
102
+ const scroll = new ScrollView(text, { follow: "none", scrollbar: "hidden" });
103
+ const render = () => tui.requestRender();
104
+ this.render = render;
105
+ let closed = false;
106
+ const act = (delta?: number) => { if (closed) return; if (delta === undefined) done(undefined); else scroll.scrollBy(delta); tui.requestRender(); };
107
+ const view = new Container();
108
+ view.addChild({
109
+ invalidate() { scroll.invalidate(); },
110
+ render: (width) => { text.setText([notice, this.inspect()].filter(Boolean).join("\n\n")); const lines = scroll.render(width); const height = Math.max(1, tui.terminal.rows - 2); scroll.updateLayout(lines.length, height, render); return lines.slice(scroll.scrollTop, scroll.scrollTop + height); },
111
+ });
112
+ view.addChild(actionHints([
113
+ { text: "↑", run: () => act(-1) }, "/", { text: "↓", run: () => act(1) }, " ", { text: "PgUp", run: () => act(-scroll.viewportHeight) }, "/",
114
+ { text: "PgDn Read", run: () => act(scroll.viewportHeight) }, " · ", { text: "Esc Back", run: () => act() },
115
+ ], undefined, "..."));
116
+ return {
117
+ invalidate() { view.invalidate(); },
118
+ dispose: () => { closed = true; if (this.render === render) this.render = undefined; },
119
+ render: (width) => view.render(width),
120
+ handleInput(data) { act(matchesKey(data, "escape") ? undefined : matchesKey(data, "pageUp") ? -scroll.viewportHeight : matchesKey(data, "pageDown") ? scroll.viewportHeight : matchesKey(data, "up") ? -1 : matchesKey(data, "down") ? 1 : 0); },
121
+ handleMouse(event) { if (event.type === "wheel") { act(event.wheelDelta ?? 0); return { handled: true }; } return view.handleMouse(event); },
122
+ };
123
+ }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left" } });
124
+ }
125
+ }