handmux 0.23.0 → 0.25.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 (327) hide show
  1. package/README.md +31 -8
  2. package/README.zh-CN.md +31 -8
  3. package/bin/handmux.js +14 -688
  4. package/bin/node-version.js +20 -0
  5. package/dist/bin/handmux.js +1015 -0
  6. package/dist/connectors/bridgeClient.js +385 -0
  7. package/dist/connectors/claude/index.js +476 -0
  8. package/dist/connectors/pi/bridgeClient.js +8 -0
  9. package/dist/connectors/pi/index.js +851 -0
  10. package/dist/hooks/handmux-notify.sh +64 -0
  11. package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
  12. package/dist/hooks/handmux-write.cjs +264 -0
  13. package/dist/package.json +127 -0
  14. package/dist/public/assets/index-CTrqTqtT.css +32 -0
  15. package/dist/public/assets/index-DmjeMSQA.js +346 -0
  16. package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
  17. package/{public → dist/public}/index.html +5 -4
  18. package/dist/src/agent-runtime/adapter.js +204 -0
  19. package/dist/src/agent-runtime/bridge.js +1006 -0
  20. package/dist/src/agent-runtime/bridgeStore.js +25 -0
  21. package/dist/src/agent-runtime/bridgeTransport.js +619 -0
  22. package/dist/src/agent-runtime/bridgeTypes.js +13 -0
  23. package/dist/src/agent-runtime/builtinRuntime.js +193 -0
  24. package/dist/src/agent-runtime/conversation.js +2179 -0
  25. package/dist/src/agent-runtime/conversationActivation.js +75 -0
  26. package/dist/src/agent-runtime/conversationActivity.js +69 -0
  27. package/dist/src/agent-runtime/conversationControls.js +206 -0
  28. package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
  29. package/dist/src/agent-runtime/conversationStore.js +352 -0
  30. package/dist/src/agent-runtime/conversationTypes.js +1 -0
  31. package/dist/src/agent-runtime/conversationValidation.js +339 -0
  32. package/dist/src/agent-runtime/inbox.js +973 -0
  33. package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
  34. package/dist/src/agent-runtime/inboxStore.js +25 -0
  35. package/dist/src/agent-runtime/inboxTypes.js +1 -0
  36. package/dist/src/agent-runtime/interaction.js +829 -0
  37. package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
  38. package/dist/src/agent-runtime/interactionStore.js +18 -0
  39. package/dist/src/agent-runtime/interactionTypes.js +1 -0
  40. package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
  41. package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
  42. package/dist/src/agent-runtime/resourceTypes.js +1 -0
  43. package/dist/src/agent-runtime/resources.js +245 -0
  44. package/dist/src/agent-runtime/run.js +328 -0
  45. package/dist/src/agent-runtime/runtime.js +793 -0
  46. package/dist/src/agent-runtime/sessionControl.js +164 -0
  47. package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
  48. package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
  49. package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
  50. package/dist/src/agents/claude.js +203 -0
  51. package/dist/src/agents/claudeConversation.js +362 -0
  52. package/dist/src/agents/claudeInteraction.js +134 -0
  53. package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
  54. package/dist/src/agents/codex.js +262 -0
  55. package/dist/src/agents/codexConversation.js +1103 -0
  56. package/dist/src/agents/codexConversationActivation.js +121 -0
  57. package/dist/src/agents/codexConversationControls.js +256 -0
  58. package/dist/src/agents/codexInteraction.js +306 -0
  59. package/dist/src/agents/codexSessionControl.js +118 -0
  60. package/dist/src/agents/codexSubscriptionUsage.js +313 -0
  61. package/dist/src/agents/codexTerminalControl.js +23 -0
  62. package/dist/src/agents/conversationProjectionSafety.js +416 -0
  63. package/dist/src/agents/index.js +39 -0
  64. package/dist/src/agents/nativeInbox.js +264 -0
  65. package/dist/src/agents/nativeInboxSources.js +115 -0
  66. package/dist/src/agents/pi.js +28 -0
  67. package/dist/src/agents/piConversation.js +523 -0
  68. package/dist/src/agents/piConversationHistory.js +502 -0
  69. package/dist/src/agents/piInboxBridge.js +368 -0
  70. package/dist/src/agents/piSessionControl.js +32 -0
  71. package/dist/src/agents/processIdentity.js +94 -0
  72. package/dist/src/agents/scanUtils.js +262 -0
  73. package/dist/src/apiAccountProviders.js +194 -0
  74. package/dist/src/apiAccounts.js +592 -0
  75. package/dist/src/apiErrors.js +86 -0
  76. package/{src → dist/src}/appName.js +12 -13
  77. package/dist/src/asr/iflyConfig.js +7 -0
  78. package/dist/src/asr/iflySign.js +14 -0
  79. package/dist/src/auth.js +27 -0
  80. package/dist/src/browser/bootstrap.js +72 -0
  81. package/dist/src/browser/cookieProfiles.js +413 -0
  82. package/dist/src/browser/coordinator.js +51 -0
  83. package/dist/src/browser/hammerheadCompat.js +104 -0
  84. package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
  85. package/dist/src/browser/manager.js +560 -0
  86. package/dist/src/browser/originLabel.js +15 -0
  87. package/dist/src/browser/profilePersistence.js +235 -0
  88. package/dist/src/browser/publicProxy.js +176 -0
  89. package/dist/src/browser/routes.js +159 -0
  90. package/dist/src/browser/sessionStore.js +72 -0
  91. package/dist/src/browser/siteVersion.js +121 -0
  92. package/dist/src/browser/targetPolicy.js +163 -0
  93. package/{src → dist/src}/browser/worker.js +14 -18
  94. package/dist/src/browser/workerClient.js +286 -0
  95. package/dist/src/browser/workerServer.js +91 -0
  96. package/dist/src/captureBackground.js +95 -0
  97. package/dist/src/claudeEvents.js +525 -0
  98. package/dist/src/cli/agentIntegration.js +131 -0
  99. package/dist/src/cli/agentLauncher.js +26 -0
  100. package/dist/src/cli/claudeHooks.js +290 -0
  101. package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
  102. package/dist/src/cli/cloudflared.js +106 -0
  103. package/dist/src/cli/codexManaged.js +80 -0
  104. package/{src → dist/src}/cli/cpolarUrl.js +8 -9
  105. package/dist/src/cli/drivers.js +98 -0
  106. package/dist/src/cli/hookScaffold.js +39 -0
  107. package/dist/src/cli/i18n/en.js +392 -0
  108. package/{src → dist/src}/cli/i18n/index.js +27 -25
  109. package/dist/src/cli/i18n/zh.js +391 -0
  110. package/dist/src/cli/legacyCodexHooks.js +52 -0
  111. package/{src → dist/src}/cli/natappUrl.js +2 -3
  112. package/dist/src/cli/options.js +245 -0
  113. package/dist/src/cli/piExtension.js +155 -0
  114. package/dist/src/cli/probe.js +16 -0
  115. package/{src → dist/src}/cli/prompt.js +4 -6
  116. package/dist/src/cli/pushCmd.js +102 -0
  117. package/{src → dist/src}/cli/qr.js +19 -19
  118. package/dist/src/cli/service.js +154 -0
  119. package/dist/src/cli/setupModel.js +194 -0
  120. package/dist/src/cli/setupWizard.js +647 -0
  121. package/dist/src/cli/shortcutEditor.js +351 -0
  122. package/dist/src/cli/sshTunnel.js +18 -0
  123. package/dist/src/cli/state.js +117 -0
  124. package/dist/src/cli/statusLine.js +115 -0
  125. package/dist/src/cli/supervisor.js +278 -0
  126. package/dist/src/cli/supervisorHealth.js +21 -0
  127. package/dist/src/cli/supervisorLaunch.js +148 -0
  128. package/dist/src/cli/supervisorProcesses.js +62 -0
  129. package/dist/src/cli/supervisorState.js +62 -0
  130. package/dist/src/cli/tmuxVersion.js +53 -0
  131. package/{src → dist/src}/cli/tunlite.js +7 -10
  132. package/dist/src/cli/tunnelClients.js +72 -0
  133. package/{src → dist/src}/cli/updateCheck.js +98 -69
  134. package/{src → dist/src}/cli/urlHost.js +8 -4
  135. package/dist/src/cli/workspaceCmd.js +347 -0
  136. package/dist/src/codexAppServer.js +3436 -0
  137. package/dist/src/codexConversationProjection.js +199 -0
  138. package/dist/src/codexDiff.js +118 -0
  139. package/dist/src/codexMessageIdentity.js +18 -0
  140. package/dist/src/codexPlan.js +30 -0
  141. package/dist/src/codexQueueProtocol.js +137 -0
  142. package/dist/src/codexStreamProtocol.js +207 -0
  143. package/dist/src/codexToolProtocol.js +68 -0
  144. package/dist/src/codexTranscriptParse.js +835 -0
  145. package/dist/src/codexUsageSnapshot.js +228 -0
  146. package/dist/src/config.js +8 -0
  147. package/{src → dist/src}/docPath.js +22 -24
  148. package/dist/src/docs.js +337 -0
  149. package/dist/src/git.js +284 -0
  150. package/dist/src/healthProtocol.js +97 -0
  151. package/dist/src/httpApi.js +96 -0
  152. package/dist/src/jsonStore.js +15 -0
  153. package/{src → dist/src}/keyNames.js +3 -3
  154. package/dist/src/notifications.js +111 -0
  155. package/dist/src/orphans.js +167 -0
  156. package/dist/src/paneInput.js +42 -0
  157. package/dist/src/pendingPrompt.js +194 -0
  158. package/dist/src/previewServer.js +202 -0
  159. package/dist/src/previews.js +179 -0
  160. package/dist/src/privateStateStore.js +124 -0
  161. package/dist/src/projectTask/backup.js +224 -0
  162. package/dist/src/projectTask/lock.js +108 -0
  163. package/dist/src/projectTask/migrations.js +90 -0
  164. package/dist/src/projectTask/routes.js +155 -0
  165. package/dist/src/projectTask/runtime.js +182 -0
  166. package/dist/src/projectTask/schema.js +56 -0
  167. package/dist/src/projectTask/store.js +457 -0
  168. package/dist/src/push.js +224 -0
  169. package/dist/src/routes/agents.js +631 -0
  170. package/dist/src/routes/apiAccounts.js +82 -0
  171. package/dist/src/routes/files.js +230 -0
  172. package/dist/src/routes/git.js +89 -0
  173. package/dist/src/routes/health.js +16 -0
  174. package/dist/src/routes/notifications.js +16 -0
  175. package/dist/src/routes/previews.js +43 -0
  176. package/dist/src/routes/push.js +145 -0
  177. package/dist/src/routes/sessions.js +244 -0
  178. package/dist/src/routes/system.js +220 -0
  179. package/dist/src/routes/terminal.js +244 -0
  180. package/dist/src/routes/workspace.js +267 -0
  181. package/dist/src/server.js +315 -0
  182. package/dist/src/shortcutConfig.js +83 -0
  183. package/{src → dist/src}/staticCache.js +5 -3
  184. package/{src → dist/src}/staticCompression.js +5 -6
  185. package/dist/src/terminalStream.js +441 -0
  186. package/{src → dist/src}/tmux/commands.js +185 -172
  187. package/dist/src/tmux/format.js +47 -0
  188. package/dist/src/transcriptParse.js +261 -0
  189. package/dist/src/transcriptReader.js +209 -0
  190. package/{src → dist/src}/trimCapture.js +11 -11
  191. package/dist/src/uploadTypes.js +31 -0
  192. package/dist/src/urlPolicy.js +18 -0
  193. package/dist/src/usagePaths.js +15 -0
  194. package/dist/src/workspace/agentRunner.js +301 -0
  195. package/dist/src/workspace/atomicJson.js +31 -0
  196. package/dist/src/workspace/capture.js +139 -0
  197. package/dist/src/workspace/checkpointer.js +228 -0
  198. package/dist/src/workspace/environment.js +71 -0
  199. package/dist/src/workspace/lock.js +137 -0
  200. package/dist/src/workspace/mapping.js +128 -0
  201. package/dist/src/workspace/operations.js +271 -0
  202. package/dist/src/workspace/paths.js +15 -0
  203. package/dist/src/workspace/planner.js +292 -0
  204. package/dist/src/workspace/restore.js +363 -0
  205. package/dist/src/workspace/runtime.js +318 -0
  206. package/dist/src/workspace/schema.js +156 -0
  207. package/dist/src/workspace/store.js +464 -0
  208. package/dist/src/workspace/tmuxAdapter.js +704 -0
  209. package/dist/src/workspaceProtocol.js +158 -0
  210. package/package.json +30 -17
  211. package/hooks/handmux-notify.sh +0 -22
  212. package/hooks/handmux-write.cjs +0 -78
  213. package/public/assets/index-C3N7OkyP.css +0 -32
  214. package/public/assets/index-D1DWcC5u.js +0 -337
  215. package/src/.gitkeep +0 -0
  216. package/src/agents/claude.js +0 -159
  217. package/src/agents/codex.js +0 -168
  218. package/src/agents/index.js +0 -21
  219. package/src/agents/processIdentity.js +0 -72
  220. package/src/agents/scanUtils.js +0 -225
  221. package/src/asr/iflyConfig.js +0 -10
  222. package/src/asr/iflySign.js +0 -16
  223. package/src/auth.js +0 -30
  224. package/src/browser/bootstrap.js +0 -68
  225. package/src/browser/cookieProfiles.js +0 -403
  226. package/src/browser/coordinator.js +0 -47
  227. package/src/browser/hammerheadCompat.js +0 -86
  228. package/src/browser/hammerheadRedirectCompat.js +0 -38
  229. package/src/browser/manager.js +0 -556
  230. package/src/browser/originLabel.js +0 -17
  231. package/src/browser/profilePersistence.js +0 -241
  232. package/src/browser/publicProxy.js +0 -175
  233. package/src/browser/routes.js +0 -154
  234. package/src/browser/sessionStore.js +0 -78
  235. package/src/browser/siteVersion.js +0 -124
  236. package/src/browser/targetPolicy.js +0 -137
  237. package/src/browser/workerClient.js +0 -253
  238. package/src/browser/workerServer.js +0 -98
  239. package/src/captureBackground.js +0 -93
  240. package/src/claudeEvents.js +0 -399
  241. package/src/cli/claudeHooks.js +0 -206
  242. package/src/cli/cloudflared.js +0 -99
  243. package/src/cli/codexManaged.js +0 -77
  244. package/src/cli/drivers.js +0 -97
  245. package/src/cli/hookScaffold.js +0 -33
  246. package/src/cli/i18n/en.js +0 -385
  247. package/src/cli/i18n/zh.js +0 -384
  248. package/src/cli/legacyCodexHooks.js +0 -49
  249. package/src/cli/options.js +0 -211
  250. package/src/cli/probe.js +0 -16
  251. package/src/cli/pushCmd.js +0 -60
  252. package/src/cli/service.js +0 -126
  253. package/src/cli/setupModel.js +0 -154
  254. package/src/cli/setupWizard.js +0 -506
  255. package/src/cli/shortcutEditor.js +0 -288
  256. package/src/cli/sshTunnel.js +0 -12
  257. package/src/cli/state.js +0 -90
  258. package/src/cli/statusLine.js +0 -90
  259. package/src/cli/supervisor.js +0 -183
  260. package/src/cli/supervisorProcesses.js +0 -57
  261. package/src/cli/tmuxVersion.js +0 -49
  262. package/src/cli/tunnelClients.js +0 -62
  263. package/src/cli/workspaceCmd.js +0 -300
  264. package/src/codexAppServer.js +0 -1620
  265. package/src/codexDiff.js +0 -108
  266. package/src/codexTranscriptParse.js +0 -413
  267. package/src/codexUsageSnapshot.cjs +0 -129
  268. package/src/config.js +0 -9
  269. package/src/docs.js +0 -228
  270. package/src/git.js +0 -208
  271. package/src/httpApi.js +0 -72
  272. package/src/jsonStore.js +0 -24
  273. package/src/notifications.js +0 -68
  274. package/src/orphans.js +0 -152
  275. package/src/pendingPrompt.js +0 -163
  276. package/src/previewServer.js +0 -168
  277. package/src/previews.js +0 -116
  278. package/src/push.js +0 -183
  279. package/src/routes/codex.js +0 -473
  280. package/src/routes/files.js +0 -172
  281. package/src/routes/git.js +0 -57
  282. package/src/routes/notifications.js +0 -19
  283. package/src/routes/previews.js +0 -35
  284. package/src/routes/push.js +0 -119
  285. package/src/routes/sessions.js +0 -168
  286. package/src/routes/system.js +0 -132
  287. package/src/routes/terminal.js +0 -210
  288. package/src/routes/transcript.js +0 -171
  289. package/src/routes/workspace.js +0 -267
  290. package/src/server.js +0 -186
  291. package/src/shortcutConfig.js +0 -75
  292. package/src/terminalStream.js +0 -391
  293. package/src/tmux/format.js +0 -45
  294. package/src/transcriptParse.js +0 -180
  295. package/src/transcriptReader.js +0 -101
  296. package/src/uploadTypes.js +0 -30
  297. package/src/urlPolicy.js +0 -14
  298. package/src/usage.js +0 -260
  299. package/src/workspace/agentRunner.js +0 -188
  300. package/src/workspace/atomicJson.js +0 -32
  301. package/src/workspace/capture.js +0 -139
  302. package/src/workspace/checkpointer.js +0 -187
  303. package/src/workspace/environment.js +0 -75
  304. package/src/workspace/lock.js +0 -128
  305. package/src/workspace/mapping.js +0 -124
  306. package/src/workspace/operations.js +0 -252
  307. package/src/workspace/paths.js +0 -16
  308. package/src/workspace/planner.js +0 -247
  309. package/src/workspace/restore.js +0 -279
  310. package/src/workspace/runtime.js +0 -314
  311. package/src/workspace/schema.js +0 -136
  312. package/src/workspace/store.js +0 -382
  313. package/src/workspace/tmuxAdapter.js +0 -554
  314. /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  315. /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
  316. /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
  317. /package/{public → dist/public}/icons/badge-96.png +0 -0
  318. /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
  319. /package/{public → dist/public}/icons/icon-192.png +0 -0
  320. /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
  321. /package/{public → dist/public}/icons/icon-512.png +0 -0
  322. /package/{public → dist/public}/icons/logo.svg +0 -0
  323. /package/{public → dist/public}/manifest.webmanifest +0 -0
  324. /package/{public → dist/public}/offline.html +0 -0
  325. /package/{public → dist/public}/sw.js +0 -0
  326. /package/{src → dist/src}/browser/protocol.js +0 -0
  327. /package/{src → dist/src}/cli/cfNamed.js +0 -0
@@ -0,0 +1,851 @@
1
+ import crypto from 'node:crypto';
2
+ import { existsSync } from 'node:fs';
3
+ import { promises as fsp } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { PiBridgeClient } from './bridgeClient.js';
7
+ import { normalizePiCommandTool, projectPiLiveItems, } from '../../src/agents/piConversationHistory.js';
8
+ // Increment when a running Pi Extension must be reloaded to satisfy the Connector contract.
9
+ // The Server treats an absent version as the legacy v1 implementation and surfaces one actionable
10
+ // reload notice instead of silently continuing with stale message/inbox behavior.
11
+ const PI_CONNECTOR_IMPLEMENTATION_VERSION = 6;
12
+ const PI_THINKING_LEVELS = [
13
+ 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max',
14
+ ];
15
+ const GLOBAL_ATTACHMENT = Symbol.for('handmux.pi-extension.attachment-id');
16
+ const GLOBAL_GENERATIONS = Symbol.for('handmux.pi-extension.generations');
17
+ const GLOBAL_HANDOFFS = Symbol.for('handmux.pi-extension.handoffs');
18
+ const processGlobal = globalThis;
19
+ const attachmentId = processGlobal[GLOBAL_ATTACHMENT] ??= crypto.randomUUID();
20
+ const generations = processGlobal[GLOBAL_GENERATIONS] ??= new Map();
21
+ const handoffs = processGlobal[GLOBAL_HANDOFFS] ??= new Map();
22
+ function record(value) {
23
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
24
+ ? value : null;
25
+ }
26
+ function modelReference(model) {
27
+ return `${model.provider}/${model.id}`;
28
+ }
29
+ function supportedThinkingLevels(model) {
30
+ if (!model.reasoning)
31
+ return ['off'];
32
+ return PI_THINKING_LEVELS.filter((level) => {
33
+ const mapped = model.thinkingLevelMap?.[level];
34
+ if (mapped === null)
35
+ return false;
36
+ return level !== 'xhigh' && level !== 'max' || mapped !== undefined;
37
+ });
38
+ }
39
+ function usableModels(context) {
40
+ if (!context.modelRegistry || !context.scopedModels) {
41
+ throw new Error('Pi model registry is unavailable');
42
+ }
43
+ const candidates = context.scopedModels.length
44
+ ? context.scopedModels.map((entry) => entry.model)
45
+ : context.modelRegistry.getAvailable();
46
+ const seen = new Set();
47
+ return candidates.filter((model) => {
48
+ const key = modelReference(model);
49
+ if (seen.has(key))
50
+ return false;
51
+ seen.add(key);
52
+ return true;
53
+ });
54
+ }
55
+ function modelControlSnapshot(pi, context) {
56
+ if (!pi.getThinkingLevel)
57
+ throw new Error('Pi thinking control is unavailable');
58
+ return {
59
+ models: usableModels(context).map((model) => ({
60
+ id: modelReference(model),
61
+ label: model.name || model.id,
62
+ efforts: supportedThinkingLevels(model).map((id) => ({ id, label: id })),
63
+ })),
64
+ selected: {
65
+ model: context.model ? modelReference(context.model) : null,
66
+ effort: pi.getThinkingLevel(),
67
+ },
68
+ };
69
+ }
70
+ function boundedText(value, max = 1024 * 1024) {
71
+ return typeof value === 'string' && value.length > 0 && value.length <= max;
72
+ }
73
+ function leaf(context) {
74
+ const value = context.sessionManager.getLeafId();
75
+ return typeof value === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(value) ? value : 'root';
76
+ }
77
+ function eventId(...parts) {
78
+ return `pi:${crypto.createHash('sha256').update(JSON.stringify(parts)).digest('hex')}`;
79
+ }
80
+ function inboxText(value) {
81
+ if (typeof value !== 'string')
82
+ return undefined;
83
+ const normalized = value.trim();
84
+ if (!normalized)
85
+ return undefined;
86
+ return Array.from(normalized).slice(0, 4096).join('');
87
+ }
88
+ function rawMessageText(message) {
89
+ if (typeof message.content === 'string')
90
+ return message.content || undefined;
91
+ if (!Array.isArray(message.content))
92
+ return undefined;
93
+ const text = message.content.flatMap((candidate) => {
94
+ const block = record(candidate);
95
+ return block?.type === 'text' && typeof block.text === 'string' ? [block.text] : [];
96
+ }).join('\n\n');
97
+ return text || undefined;
98
+ }
99
+ function messageText(message) {
100
+ return inboxText(rawMessageText(message));
101
+ }
102
+ function lastUserText(context) {
103
+ const branch = context.sessionManager.getBranch();
104
+ for (let index = branch.length - 1; index >= 0; index -= 1) {
105
+ const entry = record(branch[index]);
106
+ const message = record(entry?.message);
107
+ if (message?.role === 'user')
108
+ return messageText(message);
109
+ }
110
+ return undefined;
111
+ }
112
+ function setInbox(active, current) {
113
+ active.inboxCurrent = current;
114
+ active.client.setSnapshot('inbox', {
115
+ availability: 'ready',
116
+ ...(current === undefined ? {} : { current }),
117
+ });
118
+ }
119
+ function pendingUserItem(active, request) {
120
+ return {
121
+ id: provisionalUserId(request.id),
122
+ sessionId: active.sessionId,
123
+ status: 'complete',
124
+ kind: 'message',
125
+ role: 'user',
126
+ content: [{ type: 'text', text: request.text }],
127
+ correlationId: request.id,
128
+ ...(request.nativeTimestamp === undefined ? {} : { sourceCreatedAt: request.nativeTimestamp }),
129
+ extensions: {
130
+ 'pi.live': true,
131
+ 'pi.pendingClientRequestId': request.id,
132
+ ...(request.nativeText === undefined || request.nativeText === request.text
133
+ ? {} : { 'pi.pendingNativeText': request.nativeText }),
134
+ },
135
+ };
136
+ }
137
+ function pendingUserItems(active) {
138
+ // Steer text is a live-only optimistic occurrence. Once native Pi starts it, canonical history
139
+ // becomes the sole durable source; never copy guide text into the Connector snapshot.
140
+ return active.requests.filter((request) => request.nativeStarted && request.origin !== 'steer')
141
+ .map((request) => pendingUserItem(active, request));
142
+ }
143
+ function activeToolSnapshots(active) {
144
+ return [...active.tools.values()].map((tool) => ({
145
+ provisionalId: tool.provisionalId,
146
+ draft: {
147
+ kind: 'tool_call',
148
+ callId: `pi:${tool.id}`,
149
+ name: tool.name,
150
+ input: tool.input,
151
+ },
152
+ ...(tool.settlement === undefined ? {} : { settlement: tool.settlement }),
153
+ ...(tool.committedLeafId === undefined ? {} : { committedLeafId: tool.committedLeafId }),
154
+ }));
155
+ }
156
+ function unsettledTools(active) {
157
+ return [...active.tools.values()].flatMap((tool) => (tool.settlement && !tool.committedLeafId ? [{ tool, settlement: tool.settlement }] : []));
158
+ }
159
+ function commitSettledTools(active, candidates, expectedLeaf) {
160
+ for (const { tool, settlement } of candidates) {
161
+ if (active.tools.get(tool.id) !== tool || tool.settlement !== settlement)
162
+ continue;
163
+ tool.committedLeafId = expectedLeaf;
164
+ }
165
+ }
166
+ function removeCommittedTools(active) {
167
+ let changed = false;
168
+ for (const [id, tool] of active.tools) {
169
+ if (!tool.committedLeafId)
170
+ continue;
171
+ active.tools.delete(id);
172
+ changed = true;
173
+ }
174
+ return changed;
175
+ }
176
+ function provisionalUserId(clientRequestId) {
177
+ return `pi-user:${crypto.createHash('sha256').update(clientRequestId).digest('hex')}`;
178
+ }
179
+ function removeRequest(active, request) {
180
+ active.requests = active.requests.filter((candidate) => candidate !== request);
181
+ if (active.currentRequest === request)
182
+ active.currentRequest = undefined;
183
+ }
184
+ function removeRequests(active, requests) {
185
+ active.requests = active.requests.filter((request) => !requests.has(request));
186
+ if (active.currentRequest && requests.has(active.currentRequest))
187
+ active.currentRequest = undefined;
188
+ }
189
+ function cancelNativeDispatch(active) {
190
+ active.nativeDispatch?.cancel();
191
+ }
192
+ function enqueueDrain(paneId, client) {
193
+ const previous = handoffs.get(paneId) ?? Promise.resolve();
194
+ const pending = previous.then(async () => {
195
+ await client.waitForDurableDrain();
196
+ client.closeAndRemoveIfDrained();
197
+ });
198
+ handoffs.set(paneId, pending);
199
+ void pending.finally(() => {
200
+ if (handoffs.get(paneId) === pending)
201
+ handoffs.delete(paneId);
202
+ }).catch(() => { });
203
+ return pending;
204
+ }
205
+ function waitForNativeConfirmation(active, request, context) {
206
+ return new Promise((resolve) => {
207
+ let settled = false;
208
+ let timer;
209
+ const finish = (confirmed) => {
210
+ if (settled)
211
+ return;
212
+ settled = true;
213
+ if (timer)
214
+ clearTimeout(timer);
215
+ context.signal.removeEventListener('abort', onAbort);
216
+ if (active.nativeDispatch === dispatch)
217
+ active.nativeDispatch = undefined;
218
+ resolve(confirmed);
219
+ };
220
+ const onAbort = () => finish(false);
221
+ const dispatch = {
222
+ request,
223
+ confirm: () => finish(true),
224
+ cancel: () => finish(false),
225
+ };
226
+ active.nativeDispatch = dispatch;
227
+ const remaining = context.deadlineAt - Date.now();
228
+ if (context.signal.aborted || remaining <= 0) {
229
+ finish(false);
230
+ return;
231
+ }
232
+ context.signal.addEventListener('abort', onAbort, { once: true });
233
+ timer = setTimeout(() => finish(false), remaining);
234
+ timer.unref?.();
235
+ });
236
+ }
237
+ async function waitForPendingQueue(active, context, stop) {
238
+ while (!context.signal.aborted && !stop.aborted && Date.now() < context.deadlineAt) {
239
+ try {
240
+ if (active.context.hasPendingMessages())
241
+ return true;
242
+ }
243
+ catch { /* transient native context failure; retry until the Bridge deadline */ }
244
+ const remaining = context.deadlineAt - Date.now();
245
+ if (remaining <= 0)
246
+ break;
247
+ await new Promise((resolve) => {
248
+ let timer;
249
+ const finish = () => {
250
+ if (timer)
251
+ clearTimeout(timer);
252
+ context.signal.removeEventListener('abort', finish);
253
+ stop.removeEventListener('abort', finish);
254
+ resolve();
255
+ };
256
+ context.signal.addEventListener('abort', finish, { once: true });
257
+ stop.addEventListener('abort', finish, { once: true });
258
+ timer = setTimeout(finish, Math.min(25, remaining));
259
+ timer.unref?.();
260
+ });
261
+ }
262
+ return false;
263
+ }
264
+ function runtimeDirectory() {
265
+ return process.env.HANDMUX_AGENT_RUNTIME_DIR
266
+ ? path.resolve(process.env.HANDMUX_AGENT_RUNTIME_DIR)
267
+ : path.join(os.homedir(), '.handmux', 'agent-runtime');
268
+ }
269
+ function safePane(paneId) {
270
+ return paneId.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 128);
271
+ }
272
+ function textDelta(event) {
273
+ const update = record(event.assistantMessageEvent) ?? record(event.update) ?? event;
274
+ if (update.type === 'thinking_delta' || update.type === 'reasoning_delta')
275
+ return null;
276
+ if (update.type !== undefined && update.type !== 'text_delta' && update.type !== 'text')
277
+ return null;
278
+ const value = update.delta ?? update.text;
279
+ return typeof value === 'string' && value ? value : null;
280
+ }
281
+ function toolIdentity(event) {
282
+ const id = event.toolCallId ?? event.toolCallID ?? event.id;
283
+ const name = event.toolName ?? event.name;
284
+ if (!boundedText(id, 256) || !boundedText(name, 256))
285
+ return null;
286
+ return { id, ...normalizePiCommandTool(name, event.args ?? event.arguments ?? {}) };
287
+ }
288
+ function updateSnapshots(active) {
289
+ const sessionFile = active.context.sessionManager.getSessionFile();
290
+ const fallbackItems = !sessionFile || !existsSync(sessionFile)
291
+ ? projectPiLiveItems(active.context.sessionManager.getBranch(), active.sessionId)
292
+ : [];
293
+ const pendingItems = [...fallbackItems, ...pendingUserItems(active)];
294
+ active.client.setSnapshot('conversation', {
295
+ implementationVersion: PI_CONNECTOR_IMPLEMENTATION_VERSION,
296
+ sessionId: active.sessionId,
297
+ leafId: active.leafId,
298
+ viewId: active.viewId,
299
+ ...(sessionFile ? { sessionFile } : {}),
300
+ // An explicit empty list is a tombstone for previously published pending items. Omitting it
301
+ // means "unchanged" to the Adapter so reconnect snapshots can be applied incrementally.
302
+ pendingItems,
303
+ // Running and just-settled tools stay in this Pi-private snapshot through their JSONL handoff.
304
+ // The Adapter restores them through the public provisional lifecycle; a committed marker keeps
305
+ // fresh observers from recreating a tool that is already present in native history.
306
+ activeTools: activeToolSnapshots(active),
307
+ });
308
+ }
309
+ function liveEntryId(provisionalId) {
310
+ return `live_${crypto.createHash('sha256').update(provisionalId).digest('hex')}`;
311
+ }
312
+ function settledAssistantItem(active, provisionalId, message) {
313
+ const item = projectPiLiveItems([{
314
+ type: 'message', id: liveEntryId(provisionalId), parentId: null,
315
+ message,
316
+ }], active.sessionId).find((item) => item.kind === 'message' && item.role === 'assistant');
317
+ return item ? {
318
+ ...item,
319
+ extensions: { ...item.extensions, 'pi.live': true },
320
+ } : undefined;
321
+ }
322
+ async function waitUntilLeafIsDurable(active, beforePublish) {
323
+ const file = active.context.sessionManager.getSessionFile();
324
+ const expectedLeaf = leaf(active.context);
325
+ const expectedView = active.viewId;
326
+ active.leafId = expectedLeaf;
327
+ if (!file || expectedLeaf === 'root') {
328
+ active.client.publishEphemeral('conversation', { type: 'stream.gap' });
329
+ updateSnapshots(active);
330
+ return;
331
+ }
332
+ for (let attempt = 0; attempt < 20; attempt += 1) {
333
+ try {
334
+ const lines = (await fsp.readFile(file, 'utf8')).trim().split('\n');
335
+ const readable = lines.some((line) => {
336
+ try {
337
+ return record(JSON.parse(line))?.id === expectedLeaf;
338
+ }
339
+ catch {
340
+ return false;
341
+ }
342
+ });
343
+ if (readable) {
344
+ if (active.viewId !== expectedView || leaf(active.context) !== expectedLeaf) {
345
+ updateSnapshots(active);
346
+ return;
347
+ }
348
+ // The callback may mutate recoverable ownership, so it must run only after both identity
349
+ // guards pass. It receives the captured leaf instead of reading mutable session state.
350
+ beforePublish?.(expectedLeaf);
351
+ active.client.publishEphemeral('conversation', {
352
+ type: 'history.changed', leafId: expectedLeaf, viewId: expectedView,
353
+ });
354
+ updateSnapshots(active);
355
+ return;
356
+ }
357
+ }
358
+ catch { /* JSONL may not have been flushed yet */ }
359
+ await new Promise((resolve) => setTimeout(resolve, Math.min(200, 20 * (attempt + 1))));
360
+ }
361
+ if (active.viewId !== expectedView || leaf(active.context) !== expectedLeaf)
362
+ return;
363
+ active.client.publishEphemeral('conversation', { type: 'stream.gap' });
364
+ updateSnapshots(active);
365
+ }
366
+ export default function handmuxPiExtension(pi) {
367
+ let active;
368
+ const clients = new Set();
369
+ const attach = (context, replacement) => {
370
+ const previous = active;
371
+ const paneId = process.env.TMUX_PANE;
372
+ const sessionId = context.sessionManager.getSessionId();
373
+ if (!paneId || !boundedText(sessionId, 1024))
374
+ return;
375
+ const inheritedGeneration = generations.get(paneId);
376
+ // Pi `/reload` evaluates this module again inside the same process. The new instance has no local
377
+ // `active`, while the process-global generation still identifies the loaded predecessor. Replace
378
+ // that generation even when Pi reports the next session_start as ordinary startup.
379
+ const replacing = replacement || (previous !== undefined && previous.sessionId !== sessionId)
380
+ || (previous === undefined && inheritedGeneration !== undefined);
381
+ const root = runtimeDirectory();
382
+ const generationId = replacing
383
+ ? crypto.randomUUID()
384
+ : inheritedGeneration ?? attachmentId;
385
+ generations.set(paneId, generationId);
386
+ const client = new PiBridgeClient({
387
+ socketPath: path.join(root, 'bridge', 'agent.sock'),
388
+ credentialFile: path.join(root, 'bridge-credential.json'),
389
+ stateFile: path.join(root, 'connectors', `pi-${safePane(paneId)}-${generationId}.json`),
390
+ candidate: {
391
+ paneId, attachmentId, sessionId,
392
+ implementationVersion: PI_CONNECTOR_IMPLEMENTATION_VERSION,
393
+ process: { pid: process.pid },
394
+ },
395
+ ...(replacing ? { generation: { id: generationId, replace: true } } : {}),
396
+ });
397
+ clients.add(client);
398
+ active = {
399
+ client, context, sessionId, leafId: leaf(context),
400
+ viewId: `pi-view:${crypto.randomUUID()}`, turn: 0,
401
+ assistantId: undefined, tools: new Map(), currentRequest: undefined,
402
+ requests: [], lastAssistant: undefined, inboxCurrent: undefined, nativeDispatch: undefined,
403
+ };
404
+ const session = active;
405
+ setInbox(session);
406
+ updateSnapshots(session);
407
+ client.handle('conversation', 'activity', () => {
408
+ const idle = session.context.isIdle();
409
+ return {
410
+ activity: idle ? 'idle' : 'working',
411
+ activeTurn: idle
412
+ ? { state: 'none' }
413
+ : { state: 'active', nativeTurnId: `pi-turn:${session.turn}` },
414
+ ...(idle ? { completionToken: `pi-completed:${session.turn}` } : {}),
415
+ };
416
+ });
417
+ client.handle('conversation', 'send', async (payload, requestContext) => {
418
+ const request = record(payload);
419
+ if (!request || !boundedText(request.text, 256 * 1024)
420
+ || Buffer.byteLength(request.text) > 256 * 1024
421
+ || typeof request.clientRequestId !== 'string'
422
+ || request.clientRequestId.length === 0 || request.clientRequestId.length > 256) {
423
+ return { status: 'rejected', reason: 'invalid_request' };
424
+ }
425
+ const delivery = request.delivery;
426
+ if (delivery !== 'prompt' && delivery !== 'steer' && delivery !== 'followUp') {
427
+ return { status: 'rejected', reason: 'unsupported_delivery' };
428
+ }
429
+ const plan = record(request.plan);
430
+ if (request.origin !== undefined && request.origin !== 'steer') {
431
+ return { status: 'rejected', reason: 'invalid_request' };
432
+ }
433
+ if (plan?.kind === 'steer-active-turn'
434
+ && (delivery !== 'steer' || plan.nativeTurnId !== `pi-turn:${session.turn}`
435
+ || session.context.isIdle())) {
436
+ return { status: 'rejected', reason: 'agent_busy' };
437
+ }
438
+ if (plan?.kind === 'start-turn-fallback' && (delivery !== 'prompt' || !session.context.isIdle())) {
439
+ return { status: 'rejected', reason: 'agent_busy' };
440
+ }
441
+ if (delivery === 'prompt' && !session.context.isIdle()) {
442
+ return { status: 'rejected', reason: 'agent_busy' };
443
+ }
444
+ const wasIdle = session.context.isIdle();
445
+ if (session.nativeDispatch) {
446
+ return { status: 'rejected', reason: 'agent_busy' };
447
+ }
448
+ if (requestContext.signal.aborted || requestContext.deadlineAt <= Date.now()) {
449
+ return { status: 'unknown', reason: 'native_delivery_unconfirmed' };
450
+ }
451
+ let hadPendingMessages = false;
452
+ let hadTrackedPendingMessages = false;
453
+ if (!wasIdle) {
454
+ try {
455
+ hadPendingMessages = session.context.hasPendingMessages();
456
+ }
457
+ catch {
458
+ return { status: 'unknown', reason: 'native_delivery_unconfirmed' };
459
+ }
460
+ hadTrackedPendingMessages = hadPendingMessages
461
+ && session.requests.some((candidate) => !candidate.nativeStarted);
462
+ }
463
+ const userRequest = {
464
+ id: request.clientRequestId,
465
+ text: request.text,
466
+ delivery,
467
+ origin: request.origin === 'steer' || delivery === 'steer'
468
+ || plan?.kind === 'steer-active-turn' || plan?.kind === 'start-turn-fallback'
469
+ ? 'steer' : 'ordinary',
470
+ };
471
+ const previousInbox = session.inboxCurrent;
472
+ session.requests.push(userRequest);
473
+ updateSnapshots(session);
474
+ const inboxRequest = wasIdle ? userRequest : session.currentRequest;
475
+ const workingMessage = inboxRequest?.origin === 'steer'
476
+ ? undefined : inboxText(inboxRequest?.text);
477
+ if (inboxRequest) {
478
+ setInbox(session, {
479
+ state: 'working', correlationId: inboxRequest.id,
480
+ ...(workingMessage === undefined ? {} : { message: workingMessage }),
481
+ });
482
+ }
483
+ const nativeConfirmation = wasIdle || !hadPendingMessages
484
+ ? waitForNativeConfirmation(session, userRequest, requestContext)
485
+ : undefined;
486
+ const cancelUnconfirmed = () => {
487
+ removeRequest(session, userRequest);
488
+ updateSnapshots(session);
489
+ setInbox(session, previousInbox);
490
+ return { status: 'unknown', reason: 'native_delivery_unconfirmed' };
491
+ };
492
+ try {
493
+ if (delivery === 'prompt')
494
+ pi.sendUserMessage(request.text);
495
+ else
496
+ pi.sendUserMessage(request.text, { deliverAs: delivery });
497
+ if (wasIdle && nativeConfirmation) {
498
+ return await nativeConfirmation ? { status: 'accepted' } : cancelUnconfirmed();
499
+ }
500
+ if (!hadPendingMessages && nativeConfirmation) {
501
+ const stopQueueWait = new AbortController();
502
+ const proof = await Promise.race([
503
+ nativeConfirmation.then((confirmed) => confirmed ? 'started' : 'unknown'),
504
+ waitForPendingQueue(session, requestContext, stopQueueWait.signal)
505
+ .then((queued) => queued ? 'queued' : 'unknown'),
506
+ ]);
507
+ stopQueueWait.abort();
508
+ if (proof === 'started')
509
+ return { status: 'accepted' };
510
+ if (proof === 'queued') {
511
+ cancelNativeDispatch(session);
512
+ return { status: 'queued' };
513
+ }
514
+ }
515
+ // Pi exposes only a boolean native queue. Once this Connector already owns an item in that
516
+ // queue, a later synchronous send that does not throw is the only per-item acknowledgement
517
+ // available; the pre-existing tracked request prevents an unrelated external queue from
518
+ // being mistaken for proof of this delivery.
519
+ if (hadTrackedPendingMessages)
520
+ return { status: 'queued' };
521
+ return cancelUnconfirmed();
522
+ }
523
+ catch {
524
+ cancelNativeDispatch(session);
525
+ removeRequest(session, userRequest);
526
+ updateSnapshots(session);
527
+ setInbox(session, previousInbox);
528
+ return { status: 'rejected', reason: 'provider_rejected' };
529
+ }
530
+ });
531
+ client.handle('conversation', 'interrupt', () => {
532
+ try {
533
+ session.context.abort();
534
+ return { status: 'accepted' };
535
+ }
536
+ catch {
537
+ return { status: 'rejected', reason: 'temporarily_unavailable' };
538
+ }
539
+ });
540
+ client.handle('session-control', 'read', async (payload) => {
541
+ const request = record(payload);
542
+ if (request?.refresh === true) {
543
+ if (!session.context.modelRegistry)
544
+ throw new Error('Pi model registry is unavailable');
545
+ await session.context.modelRegistry.refresh({ force: true });
546
+ }
547
+ return modelControlSnapshot(pi, session.context);
548
+ });
549
+ client.handle('session-control', 'update', async (payload) => {
550
+ const patch = record(payload);
551
+ if (!patch || !Object.keys(patch).length
552
+ || Object.keys(patch).some((key) => key !== 'model' && key !== 'effort')) {
553
+ throw new Error('Invalid Session Control update');
554
+ }
555
+ if (patch.model !== undefined) {
556
+ if (typeof patch.model !== 'string')
557
+ throw new Error('Invalid model');
558
+ const model = usableModels(session.context)
559
+ .find((candidate) => modelReference(candidate) === patch.model);
560
+ if (!model)
561
+ throw new Error('Model is unavailable');
562
+ if (!pi.setModel)
563
+ throw new Error('Pi model control is unavailable');
564
+ if (!await pi.setModel(model))
565
+ throw new Error('Model credentials are unavailable');
566
+ }
567
+ if (patch.effort !== undefined) {
568
+ if (typeof patch.effort !== 'string'
569
+ || !PI_THINKING_LEVELS.includes(patch.effort)) {
570
+ throw new Error('Invalid thinking level');
571
+ }
572
+ if (!pi.setThinkingLevel)
573
+ throw new Error('Pi thinking control is unavailable');
574
+ pi.setThinkingLevel(patch.effort);
575
+ }
576
+ return modelControlSnapshot(pi, session.context);
577
+ });
578
+ let predecessor = handoffs.get(paneId);
579
+ if (previous) {
580
+ previous.nativeDispatch?.cancel();
581
+ predecessor = enqueueDrain(paneId, previous.client);
582
+ void predecessor.finally(() => clients.delete(previous.client)).catch(() => { });
583
+ }
584
+ if (!predecessor) {
585
+ client.start();
586
+ return;
587
+ }
588
+ // A replacement must not revoke the old run before its locally durable terminal facts have entered
589
+ // the Server-owned spool. The global handoff survives Pi reloading the Extension instance between
590
+ // session_shutdown and the replacement session_start.
591
+ void predecessor.then(() => client.start()).catch(() => { });
592
+ };
593
+ pi.on('session_start', (event, context) => {
594
+ attach(context, event.reason === 'new' || event.reason === 'resume'
595
+ || event.reason === 'fork' || event.reason === 'reload');
596
+ });
597
+ pi.on('session_shutdown', () => {
598
+ const outgoing = active;
599
+ active = undefined;
600
+ if (!outgoing)
601
+ return;
602
+ outgoing.nativeDispatch?.cancel();
603
+ const paneId = process.env.TMUX_PANE;
604
+ if (!paneId) {
605
+ outgoing.client.close();
606
+ clients.delete(outgoing.client);
607
+ return;
608
+ }
609
+ const drained = enqueueDrain(paneId, outgoing.client);
610
+ void drained.finally(() => clients.delete(outgoing.client)).catch(() => { });
611
+ });
612
+ pi.on('before_agent_start', (event, context) => {
613
+ if (!active)
614
+ return;
615
+ active.context = context;
616
+ const dispatch = active.nativeDispatch;
617
+ if (!dispatch || typeof event.prompt !== 'string' || !event.prompt)
618
+ return;
619
+ dispatch.request.nativeText = event.prompt;
620
+ active.currentRequest = dispatch.request;
621
+ updateSnapshots(active);
622
+ dispatch.confirm();
623
+ });
624
+ pi.on('agent_start', (_event, context) => {
625
+ if (!active)
626
+ return;
627
+ active.context = context;
628
+ if (removeCommittedTools(active))
629
+ updateSnapshots(active);
630
+ active.turn += 1;
631
+ active.lastAssistant = undefined;
632
+ const existing = active.inboxCurrent?.state === 'working' ? active.inboxCurrent : undefined;
633
+ const currentText = active.currentRequest?.origin === 'steer'
634
+ ? undefined : existing?.message ?? lastUserText(context);
635
+ setInbox(active, {
636
+ state: 'working',
637
+ ...(currentText === undefined ? {} : { message: currentText }),
638
+ ...(existing?.correlationId === undefined ? {} : { correlationId: existing.correlationId }),
639
+ });
640
+ });
641
+ pi.on('agent_end', (event, context) => {
642
+ if (!active)
643
+ return;
644
+ active.context = context;
645
+ const messages = Array.isArray(event.messages) ? event.messages : [];
646
+ const assistant = messages.map(record).filter((message) => message?.role === 'assistant').at(-1);
647
+ if (!assistant)
648
+ return;
649
+ active.lastAssistant = assistant;
650
+ });
651
+ pi.on('agent_settled', (_event, context) => {
652
+ if (!active)
653
+ return;
654
+ const session = active;
655
+ session.context = context;
656
+ const assistant = session.lastAssistant;
657
+ const settledCurrent = session.currentRequest;
658
+ const unsettledDispatch = session.nativeDispatch?.request;
659
+ const settledRequests = new Set(session.requests.filter((request) => request !== unsettledDispatch));
660
+ const settledTools = unsettledTools(session);
661
+ if (session.currentRequest === settledCurrent)
662
+ session.currentRequest = undefined;
663
+ if (assistant?.stopReason === 'aborted') {
664
+ session.client.publishEphemeral('inbox', { kind: 'clear' });
665
+ setInbox(session);
666
+ void waitUntilLeafIsDurable(session, (expectedLeaf) => {
667
+ removeRequests(session, settledRequests);
668
+ commitSettledTools(session, settledTools, expectedLeaf);
669
+ updateSnapshots(session);
670
+ });
671
+ return;
672
+ }
673
+ const outcome = assistant?.stopReason === 'error' ? 'error' : 'done';
674
+ const correlationId = settledCurrent?.id;
675
+ const id = eventId(session.sessionId, session.turn, leaf(context), 'agent_settled', outcome);
676
+ const completedText = outcome === 'done' && assistant ? messageText(assistant) : undefined;
677
+ const current = {
678
+ state: outcome,
679
+ eventId: id,
680
+ ...(correlationId === undefined ? {} : { correlationId }),
681
+ ...(completedText === undefined ? {} : { message: completedText }),
682
+ ...(outcome === 'error' ? { reason: 'provider_error' } : {}),
683
+ ...(outcome === 'error' ? { message: 'Pi generation failed' } : {}),
684
+ };
685
+ const durable = session.client.publishDurable('inbox', id, { kind: 'set', ...current });
686
+ session.inboxCurrent = current;
687
+ session.client.setSnapshot('inbox', durable
688
+ ? { availability: 'ready', current }
689
+ : {
690
+ availability: 'degraded',
691
+ message: 'Terminal event could not be saved for offline delivery',
692
+ current,
693
+ });
694
+ void waitUntilLeafIsDurable(session, (expectedLeaf) => {
695
+ removeRequests(session, settledRequests);
696
+ commitSettledTools(session, settledTools, expectedLeaf);
697
+ updateSnapshots(session);
698
+ });
699
+ });
700
+ pi.on('session_tree', (_event, context) => {
701
+ if (!active)
702
+ return;
703
+ active.context = context;
704
+ // A tree switch creates a new Conversation view. All provisional tool ownership belongs to the
705
+ // old view and is invalidated by its stream gap; carrying any of it into the replacement snapshot
706
+ // would resurrect stale running or uncommitted tools on a fresh observer.
707
+ active.tools.clear();
708
+ active.leafId = leaf(context);
709
+ active.viewId = `pi-view:${crypto.randomUUID()}`;
710
+ void waitUntilLeafIsDurable(active);
711
+ });
712
+ pi.on('message_start', (event, context) => {
713
+ if (!active)
714
+ return;
715
+ const message = record(event.message);
716
+ if (!message)
717
+ return;
718
+ active.context = context;
719
+ if (message.role === 'user') {
720
+ const rawText = rawMessageText(message);
721
+ const dispatchRequest = active.nativeDispatch?.request;
722
+ const matchedDispatch = rawText !== undefined && dispatchRequest
723
+ && (dispatchRequest.nativeText ?? dispatchRequest.text) === rawText
724
+ ? dispatchRequest : undefined;
725
+ const request = matchedDispatch ?? (rawText === undefined ? undefined : active.requests.find((candidate) => (candidate.nativeTimestamp === undefined && (candidate.nativeText ?? candidate.text) === rawText)));
726
+ const text = request?.origin === 'steer' ? undefined : inboxText(request?.text ?? rawText);
727
+ if (request && typeof message.timestamp === 'number' && Number.isFinite(message.timestamp)) {
728
+ request.nativeTimestamp = message.timestamp;
729
+ }
730
+ if (request)
731
+ request.nativeStarted = true;
732
+ active.currentRequest = request;
733
+ if (request && active.nativeDispatch?.request === request)
734
+ active.nativeDispatch.confirm();
735
+ if (request && !request.liveSettled) {
736
+ request.liveSettled = true;
737
+ const item = pendingUserItem(active, request);
738
+ const provisionalId = provisionalUserId(request.id);
739
+ active.client.publishEphemeral('conversation', {
740
+ type: 'item.opened', provisionalId,
741
+ draft: {
742
+ kind: item.kind, role: item.role, content: item.content,
743
+ correlationId: item.correlationId,
744
+ ...(item.sourceCreatedAt === undefined ? {} : { sourceCreatedAt: item.sourceCreatedAt }),
745
+ extensions: item.extensions,
746
+ },
747
+ });
748
+ active.client.publishEphemeral('conversation', {
749
+ type: 'item.settled', provisionalId,
750
+ durableItemId: item.id, item,
751
+ });
752
+ }
753
+ updateSnapshots(active);
754
+ setInbox(active, {
755
+ state: 'working',
756
+ ...(text === undefined ? {} : { message: text }),
757
+ ...(request === undefined ? {} : { correlationId: request.id }),
758
+ });
759
+ return;
760
+ }
761
+ if (message.role !== 'assistant')
762
+ return;
763
+ active.assistantId = `assistant:${active.turn}:${crypto.randomUUID()}`;
764
+ active.client.publishEphemeral('conversation', {
765
+ type: 'item.opened', provisionalId: active.assistantId,
766
+ draft: { kind: 'message', role: 'assistant', content: [{ type: 'text', text: '' }] },
767
+ });
768
+ });
769
+ pi.on('message_update', (event, context) => {
770
+ if (!active?.assistantId)
771
+ return;
772
+ active.context = context;
773
+ const text = textDelta(event);
774
+ if (!text)
775
+ return;
776
+ active.client.publishEphemeral('conversation', {
777
+ type: 'item.delta', provisionalId: active.assistantId,
778
+ delta: { op: 'text.append', target: 'message.content', text },
779
+ });
780
+ });
781
+ pi.on('message_end', (event, context) => {
782
+ if (!active?.assistantId)
783
+ return;
784
+ const message = record(event.message);
785
+ if (message?.role !== 'assistant')
786
+ return;
787
+ active.context = context;
788
+ const provisionalId = active.assistantId;
789
+ active.assistantId = undefined;
790
+ const item = settledAssistantItem(active, provisionalId, message);
791
+ if (item) {
792
+ active.client.publishEphemeral('conversation', {
793
+ type: 'item.settled', provisionalId, durableItemId: item.id, item,
794
+ });
795
+ return;
796
+ }
797
+ // A tool-only assistant message opened an empty text draft. Its tool calls have their own live
798
+ // rows, so remove only that genuinely invisible placeholder.
799
+ active.client.publishEphemeral('conversation', {
800
+ type: 'item.cancelled', provisionalId, reason: 'superseded',
801
+ });
802
+ });
803
+ pi.on('tool_execution_start', (event, context) => {
804
+ if (!active)
805
+ return;
806
+ active.context = context;
807
+ const tool = toolIdentity(event);
808
+ if (!tool)
809
+ return;
810
+ const provisionalId = `tool:${crypto.randomUUID()}`;
811
+ active.tools.set(tool.id, { ...tool, provisionalId });
812
+ // Persist the recoverable owner before publishing the lossy live event. The Adapter suppresses
813
+ // the matching item.opened when both reach an uninterrupted subscription.
814
+ updateSnapshots(active);
815
+ active.client.publishEphemeral('conversation', {
816
+ type: 'item.opened', provisionalId,
817
+ draft: { kind: 'tool_call', callId: `pi:${tool.id}`, name: tool.name, input: tool.input },
818
+ });
819
+ });
820
+ pi.on('tool_execution_end', (event, context) => {
821
+ if (!active)
822
+ return;
823
+ active.context = context;
824
+ const tool = toolIdentity(event);
825
+ if (!tool)
826
+ return;
827
+ const running = active.tools.get(tool.id);
828
+ if (!running)
829
+ return;
830
+ const provisionalId = running.provisionalId;
831
+ const durableItemId = `pi:${liveEntryId(provisionalId)}:tool`;
832
+ const item = {
833
+ id: durableItemId,
834
+ sessionId: active.sessionId,
835
+ status: 'complete',
836
+ kind: 'tool_call',
837
+ callId: `pi:${running.id}`,
838
+ name: running.name,
839
+ input: running.input,
840
+ extensions: { 'pi.live': true },
841
+ };
842
+ running.settlement = { durableItemId, item };
843
+ // A completed snapshot makes settlement recoverable if the ephemeral event is lost. Once native
844
+ // history is durable it becomes a committed tombstone, then the next native turn removes it.
845
+ updateSnapshots(active);
846
+ active.client.publishEphemeral('conversation', {
847
+ type: 'item.settled', provisionalId,
848
+ durableItemId, item,
849
+ });
850
+ });
851
+ }