@pi-in-go/pigpen-ahp 0.1.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 (304) hide show
  1. package/CREDITS.md +27 -0
  2. package/LICENSE +23 -0
  3. package/README.md +148 -0
  4. package/extensions/ahp/access_options_test.go +116 -0
  5. package/extensions/ahp/extension.go +54 -0
  6. package/extensions/ahp/go.mod +12 -0
  7. package/extensions/ahp/go.sum +2 -0
  8. package/extensions/ahp/go.work +8 -0
  9. package/extensions/ahp/internal/channels/chat.go +110 -0
  10. package/extensions/ahp/internal/channels/root.go +32 -0
  11. package/extensions/ahp/internal/channels/session.go +128 -0
  12. package/extensions/ahp/internal/channels/session_test.go +108 -0
  13. package/extensions/ahp/internal/compose/compose.go +153 -0
  14. package/extensions/ahp/internal/compose/compose_test.go +120 -0
  15. package/extensions/ahp/internal/gaps/gaps_test.go +88 -0
  16. package/extensions/ahp/internal/host/handshake_test.go +206 -0
  17. package/extensions/ahp/internal/host/host.go +1305 -0
  18. package/extensions/ahp/internal/host/reconnect_test.go +197 -0
  19. package/extensions/ahp/internal/host/replaywindow_test.go +31 -0
  20. package/extensions/ahp/internal/host/schema_test.go +79 -0
  21. package/extensions/ahp/internal/host/store.go +291 -0
  22. package/extensions/ahp/internal/host/store_update_test.go +85 -0
  23. package/extensions/ahp/internal/host/subscriptions_test.go +160 -0
  24. package/extensions/ahp/internal/host/surface_test.go +95 -0
  25. package/extensions/ahp/internal/host/workarounds.go +458 -0
  26. package/extensions/ahp/internal/host/workarounds_test.go +96 -0
  27. package/extensions/ahp/internal/live/live.go +328 -0
  28. package/extensions/ahp/internal/live/live_test.go +91 -0
  29. package/extensions/ahp/internal/live/store.go +113 -0
  30. package/extensions/ahp/internal/mapper/activity.go +199 -0
  31. package/extensions/ahp/internal/mapper/activity_test.go +188 -0
  32. package/extensions/ahp/internal/mapper/bench_test.go +38 -0
  33. package/extensions/ahp/internal/mapper/event_mapper_test.go +380 -0
  34. package/extensions/ahp/internal/mapper/fixtures_test.go +387 -0
  35. package/extensions/ahp/internal/mapper/helpers_test.go +251 -0
  36. package/extensions/ahp/internal/mapper/js.go +131 -0
  37. package/extensions/ahp/internal/mapper/mapper.go +600 -0
  38. package/extensions/ahp/internal/mapper/message_input_test.go +196 -0
  39. package/extensions/ahp/internal/mapper/messageinput.go +272 -0
  40. package/extensions/ahp/internal/mapper/schema_helpers_test.go +5 -0
  41. package/extensions/ahp/internal/mapper/testdata/LICENSE-pi-ahp +21 -0
  42. package/extensions/ahp/internal/mapper/testdata/fixtures/abort.json +711 -0
  43. package/extensions/ahp/internal/mapper/testdata/fixtures/bash-long-output.json +1312 -0
  44. package/extensions/ahp/internal/mapper/testdata/fixtures/compaction.json +6285 -0
  45. package/extensions/ahp/internal/mapper/testdata/fixtures/parallel-tools.json +2622 -0
  46. package/extensions/ahp/internal/mapper/testdata/fixtures/plain-text.json +1856 -0
  47. package/extensions/ahp/internal/mapper/testdata/fixtures/single-tool.json +1128 -0
  48. package/extensions/ahp/internal/mapper/testdata/fixtures/steering.json +3926 -0
  49. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-bash.json +2372 -0
  50. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-edit.json +3983 -0
  51. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-error.json +2850 -0
  52. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-find.json +1162 -0
  53. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-grep.json +2783 -0
  54. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-loop.json +1693 -0
  55. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-ls.json +2427 -0
  56. package/extensions/ahp/internal/mapper/testdata/fixtures/tool-write.json +1271 -0
  57. package/extensions/ahp/internal/mapper/title.go +34 -0
  58. package/extensions/ahp/internal/mapper/usermsg.go +87 -0
  59. package/extensions/ahp/internal/pi/activeturn_test.go +296 -0
  60. package/extensions/ahp/internal/pi/backend.go +64 -0
  61. package/extensions/ahp/internal/pi/catalogue.go +370 -0
  62. package/extensions/ahp/internal/pi/catalogue_test.go +271 -0
  63. package/extensions/ahp/internal/pi/chatdriver.go +533 -0
  64. package/extensions/ahp/internal/pi/chatdriver_test.go +416 -0
  65. package/extensions/ahp/internal/pi/clientactions.go +212 -0
  66. package/extensions/ahp/internal/pi/clientactions_test.go +273 -0
  67. package/extensions/ahp/internal/pi/completions.go +230 -0
  68. package/extensions/ahp/internal/pi/completions_test.go +272 -0
  69. package/extensions/ahp/internal/pi/config_test.go +269 -0
  70. package/extensions/ahp/internal/pi/deletesession.go +69 -0
  71. package/extensions/ahp/internal/pi/deletesession_test.go +153 -0
  72. package/extensions/ahp/internal/pi/disposal_test.go +362 -0
  73. package/extensions/ahp/internal/pi/fixture_test.go +308 -0
  74. package/extensions/ahp/internal/pi/foreign_test.go +122 -0
  75. package/extensions/ahp/internal/pi/harness_test.go +83 -0
  76. package/extensions/ahp/internal/pi/history.go +269 -0
  77. package/extensions/ahp/internal/pi/hydrated_test.go +171 -0
  78. package/extensions/ahp/internal/pi/hydratedlifecycle_test.go +196 -0
  79. package/extensions/ahp/internal/pi/hydration_test.go +209 -0
  80. package/extensions/ahp/internal/pi/hydrator.go +212 -0
  81. package/extensions/ahp/internal/pi/imageinput.go +224 -0
  82. package/extensions/ahp/internal/pi/imageinput_test.go +57 -0
  83. package/extensions/ahp/internal/pi/lifecycle_test.go +381 -0
  84. package/extensions/ahp/internal/pi/models.go +212 -0
  85. package/extensions/ahp/internal/pi/models_test.go +128 -0
  86. package/extensions/ahp/internal/pi/paging.go +95 -0
  87. package/extensions/ahp/internal/pi/pagingtruncate_test.go +323 -0
  88. package/extensions/ahp/internal/pi/projecttrust.go +222 -0
  89. package/extensions/ahp/internal/pi/registry.go +924 -0
  90. package/extensions/ahp/internal/pi/restart_test.go +185 -0
  91. package/extensions/ahp/internal/pi/services.go +70 -0
  92. package/extensions/ahp/internal/pi/sessionconfig.go +64 -0
  93. package/extensions/ahp/internal/pi/sessionfiles_test.go +74 -0
  94. package/extensions/ahp/internal/pi/sessionstore.go +83 -0
  95. package/extensions/ahp/internal/pi/summary_test.go +426 -0
  96. package/extensions/ahp/internal/pi/workarounds_wire_test.go +208 -0
  97. package/extensions/ahp/internal/pisession/fromentries_test.go +32 -0
  98. package/extensions/ahp/internal/pisession/json.go +31 -0
  99. package/extensions/ahp/internal/pisession/pisession.go +580 -0
  100. package/extensions/ahp/internal/settings/access.go +63 -0
  101. package/extensions/ahp/internal/settings/access_test.go +52 -0
  102. package/extensions/ahp/internal/settings/settings.go +117 -0
  103. package/extensions/ahp/internal/settings/settings_test.go +104 -0
  104. package/extensions/ahp/internal/svc/etag_other.go +15 -0
  105. package/extensions/ahp/internal/svc/etag_unix.go +34 -0
  106. package/extensions/ahp/internal/svc/glob.go +197 -0
  107. package/extensions/ahp/internal/svc/mime.go +37 -0
  108. package/extensions/ahp/internal/svc/paths.go +108 -0
  109. package/extensions/ahp/internal/svc/pty.go +64 -0
  110. package/extensions/ahp/internal/svc/pty_darwin.go +42 -0
  111. package/extensions/ahp/internal/svc/pty_linux.go +36 -0
  112. package/extensions/ahp/internal/svc/pty_other.go +9 -0
  113. package/extensions/ahp/internal/svc/pty_test.go +114 -0
  114. package/extensions/ahp/internal/svc/pty_unix.go +147 -0
  115. package/extensions/ahp/internal/svc/resource.go +495 -0
  116. package/extensions/ahp/internal/svc/resource_test.go +501 -0
  117. package/extensions/ahp/internal/svc/stat_bsd.go +9 -0
  118. package/extensions/ahp/internal/svc/stat_linux.go +9 -0
  119. package/extensions/ahp/internal/svc/terminal.go +454 -0
  120. package/extensions/ahp/internal/svc/terminal_test.go +512 -0
  121. package/extensions/ahp/internal/svc/watch.go +590 -0
  122. package/extensions/ahp/internal/svc/watch_test.go +627 -0
  123. package/extensions/ahp/internal/svc/watchevents_test.go +452 -0
  124. package/extensions/ahp/internal/svc/watchpolicy.go +81 -0
  125. package/extensions/ahp/internal/svc/watchpolicy_test.go +151 -0
  126. package/extensions/ahp/internal/svc/watchracy_test.go +61 -0
  127. package/extensions/ahp/internal/testkit/schema/LICENSE-agent-host-protocol +21 -0
  128. package/extensions/ahp/internal/testkit/schema/actions.schema.json +9208 -0
  129. package/extensions/ahp/internal/testkit/schema/commands.schema.json +10862 -0
  130. package/extensions/ahp/internal/testkit/schema/errors.schema.json +10928 -0
  131. package/extensions/ahp/internal/testkit/schema/notifications.schema.json +6667 -0
  132. package/extensions/ahp/internal/testkit/schema/state.schema.json +6392 -0
  133. package/extensions/ahp/internal/testkit/schema.go +360 -0
  134. package/extensions/ahp/internal/testkit/testkit.go +367 -0
  135. package/extensions/ahp/internal/twin/twin.go +27 -0
  136. package/extensions/ahp/internal/wire/dispatchable.go +106 -0
  137. package/extensions/ahp/internal/wire/dispatchable_list.go +15 -0
  138. package/extensions/ahp/internal/wire/helpers_test.go +5 -0
  139. package/extensions/ahp/internal/wire/wire.go +218 -0
  140. package/extensions/ahp/internal/wire/wire_test.go +123 -0
  141. package/extensions/ahp/internal/ws/helpers_test.go +7 -0
  142. package/extensions/ahp/internal/ws/vscode_test.go +198 -0
  143. package/extensions/ahp/internal/ws/ws.go +674 -0
  144. package/extensions/ahp/internal/ws/ws_test.go +324 -0
  145. package/extensions/ahp/realpig_test.go +589 -0
  146. package/extensions/ahp/runtime.go +311 -0
  147. package/extensions/ahp/runtime_test.go +110 -0
  148. package/extensions/ahp/testdata/upstream-tests.json +3833 -0
  149. package/extensions/ahp/third_party/agent-host-protocol-go/LICENSE +21 -0
  150. package/extensions/ahp/third_party/agent-host-protocol-go/NOTICE-PIGPEN.md +18 -0
  151. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/client.go +1011 -0
  152. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/error.go +111 -0
  153. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/multi_host_state_mirror.go +239 -0
  154. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/reducers.go +1939 -0
  155. package/extensions/ahp/third_party/agent-host-protocol-go/ahp/transport.go +176 -0
  156. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/actions.generated.go +2447 -0
  157. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/commands.generated.go +1546 -0
  158. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/common.go +205 -0
  159. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/errors.generated.go +65 -0
  160. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/messages.generated.go +138 -0
  161. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/notifications.generated.go +264 -0
  162. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/state.generated.go +6171 -0
  163. package/extensions/ahp/third_party/agent-host-protocol-go/ahptypes/version.generated.go +31 -0
  164. package/extensions/ahp/third_party/agent-host-protocol-go/go.mod +3 -0
  165. package/extensions/ahp/twins_test.go +91 -0
  166. package/package.json +40 -0
  167. package/proof/PORT.md +121 -0
  168. package/proof/mutations.json +152 -0
  169. package/proof/oracle/LICENSE +21 -0
  170. package/proof/oracle/README.md +120 -0
  171. package/proof/oracle/UPSTREAM.md +7 -0
  172. package/proof/oracle/package.json +68 -0
  173. package/proof/oracle/src/bin/cli.ts +66 -0
  174. package/proof/oracle/src/bin/tunnel.ts +128 -0
  175. package/proof/oracle/src/channels/chat.ts +134 -0
  176. package/proof/oracle/src/channels/root.ts +39 -0
  177. package/proof/oracle/src/channels/session.ts +118 -0
  178. package/proof/oracle/src/channels/terminal.ts +11 -0
  179. package/proof/oracle/src/core/channels.ts +108 -0
  180. package/proof/oracle/src/core/client-workarounds.ts +358 -0
  181. package/proof/oracle/src/core/connection.ts +41 -0
  182. package/proof/oracle/src/core/host.ts +882 -0
  183. package/proof/oracle/src/core/sequencer.ts +75 -0
  184. package/proof/oracle/src/core/state-store.ts +162 -0
  185. package/proof/oracle/src/core/uri.ts +25 -0
  186. package/proof/oracle/src/host/direct-settings.ts +110 -0
  187. package/proof/oracle/src/host/pi-host.ts +220 -0
  188. package/proof/oracle/src/host/serve.ts +71 -0
  189. package/proof/oracle/src/host/terminal-service.ts +346 -0
  190. package/proof/oracle/src/pi/activity.ts +171 -0
  191. package/proof/oracle/src/pi/changeset-service.ts +646 -0
  192. package/proof/oracle/src/pi/changeset-uri.ts +64 -0
  193. package/proof/oracle/src/pi/chat-driver.ts +529 -0
  194. package/proof/oracle/src/pi/completions.ts +160 -0
  195. package/proof/oracle/src/pi/delete-session.ts +53 -0
  196. package/proof/oracle/src/pi/event-mapper.ts +648 -0
  197. package/proof/oracle/src/pi/git-changes.ts +605 -0
  198. package/proof/oracle/src/pi/history.ts +305 -0
  199. package/proof/oracle/src/pi/image-input.ts +56 -0
  200. package/proof/oracle/src/pi/image-mime.ts +8 -0
  201. package/proof/oracle/src/pi/in-process-backend.ts +155 -0
  202. package/proof/oracle/src/pi/message-input.ts +212 -0
  203. package/proof/oracle/src/pi/models.ts +124 -0
  204. package/proof/oracle/src/pi/project-trust.ts +66 -0
  205. package/proof/oracle/src/pi/provider.ts +2 -0
  206. package/proof/oracle/src/pi/resource-paths.ts +80 -0
  207. package/proof/oracle/src/pi/resource-service.ts +356 -0
  208. package/proof/oracle/src/pi/resource-watch-policy.ts +35 -0
  209. package/proof/oracle/src/pi/resource-watch.ts +357 -0
  210. package/proof/oracle/src/pi/session-catalogue.ts +318 -0
  211. package/proof/oracle/src/pi/session-config.ts +84 -0
  212. package/proof/oracle/src/pi/session-history.ts +75 -0
  213. package/proof/oracle/src/pi/session-hydrator.ts +219 -0
  214. package/proof/oracle/src/pi/session-registry.ts +815 -0
  215. package/proof/oracle/src/pi/session-storage.ts +29 -0
  216. package/proof/oracle/src/pi/session-title.ts +18 -0
  217. package/proof/oracle/src/pi/turn-paging.ts +94 -0
  218. package/proof/oracle/src/pi/user-message.ts +66 -0
  219. package/proof/oracle/src/protocol/errors.ts +44 -0
  220. package/proof/oracle/src/protocol/jsonrpc.ts +89 -0
  221. package/proof/oracle/src/protocol/version.ts +39 -0
  222. package/proof/oracle/src/transport/websocket.ts +136 -0
  223. package/proof/oracle/src/tunnel/devtunnel.ts +293 -0
  224. package/proof/oracle/src/tunnel/discovery.ts +37 -0
  225. package/proof/oracle/test/active-turn-reconnect.test.ts +274 -0
  226. package/proof/oracle/test/activity.test.ts +171 -0
  227. package/proof/oracle/test/changeset-lifecycle.test.serial.ts +303 -0
  228. package/proof/oracle/test/changeset-uri.test.ts +37 -0
  229. package/proof/oracle/test/changeset.test.serial.ts +487 -0
  230. package/proof/oracle/test/chat-driver.test.ts +691 -0
  231. package/proof/oracle/test/client-actions.test.ts +409 -0
  232. package/proof/oracle/test/client-workarounds.test.ts +320 -0
  233. package/proof/oracle/test/completions.test.ts +323 -0
  234. package/proof/oracle/test/delete-session.test.ts +142 -0
  235. package/proof/oracle/test/direct-settings.test.ts +81 -0
  236. package/proof/oracle/test/event-mapper.test.ts +619 -0
  237. package/proof/oracle/test/fetch-turns.test.ts +207 -0
  238. package/proof/oracle/test/fixtures/abort.json +711 -0
  239. package/proof/oracle/test/fixtures/bash-long-output.json +1312 -0
  240. package/proof/oracle/test/fixtures/compaction.json +6285 -0
  241. package/proof/oracle/test/fixtures/parallel-tools.json +2622 -0
  242. package/proof/oracle/test/fixtures/plain-text.json +1856 -0
  243. package/proof/oracle/test/fixtures/single-tool.json +1128 -0
  244. package/proof/oracle/test/fixtures/steering.json +3926 -0
  245. package/proof/oracle/test/fixtures/tool-bash.json +2372 -0
  246. package/proof/oracle/test/fixtures/tool-edit.json +3983 -0
  247. package/proof/oracle/test/fixtures/tool-error.json +2850 -0
  248. package/proof/oracle/test/fixtures/tool-find.json +1162 -0
  249. package/proof/oracle/test/fixtures/tool-grep.json +2783 -0
  250. package/proof/oracle/test/fixtures/tool-loop.json +1693 -0
  251. package/proof/oracle/test/fixtures/tool-ls.json +2427 -0
  252. package/proof/oracle/test/fixtures/tool-write.json +1271 -0
  253. package/proof/oracle/test/handshake.test.ts +275 -0
  254. package/proof/oracle/test/harness.ts +153 -0
  255. package/proof/oracle/test/hydrated-session-lifecycle.test.ts +211 -0
  256. package/proof/oracle/test/image-input.test.ts +28 -0
  257. package/proof/oracle/test/image-session.test.ts +69 -0
  258. package/proof/oracle/test/live-turn.test.ts +304 -0
  259. package/proof/oracle/test/mapper-fixtures.test.ts +293 -0
  260. package/proof/oracle/test/message-input.test.ts +181 -0
  261. package/proof/oracle/test/model-discovery.test.ts +170 -0
  262. package/proof/oracle/test/models.test.ts +109 -0
  263. package/proof/oracle/test/pi-host.test.ts +91 -0
  264. package/proof/oracle/test/pi-replay.test.ts +141 -0
  265. package/proof/oracle/test/project-trust.test.ts +125 -0
  266. package/proof/oracle/test/protocol-surface.test.ts +121 -0
  267. package/proof/oracle/test/pty.test.ts +100 -0
  268. package/proof/oracle/test/reconnect.test.ts +389 -0
  269. package/proof/oracle/test/resource-watch-policy.test.ts +78 -0
  270. package/proof/oracle/test/resource-watch.test.serial.ts +536 -0
  271. package/proof/oracle/test/resource.test.ts +499 -0
  272. package/proof/oracle/test/schema.test.ts +88 -0
  273. package/proof/oracle/test/session-catalogue.test.ts +314 -0
  274. package/proof/oracle/test/session-config.test.ts +224 -0
  275. package/proof/oracle/test/session-disposal.test.ts +364 -0
  276. package/proof/oracle/test/session-hydration.test.ts +194 -0
  277. package/proof/oracle/test/session-lifecycle.test.ts +416 -0
  278. package/proof/oracle/test/session-storage.test.serial.ts +32 -0
  279. package/proof/oracle/test/session-summary.test.ts +418 -0
  280. package/proof/oracle/test/subscriptions.test.ts +198 -0
  281. package/proof/oracle/test/support/assertions.ts +32 -0
  282. package/proof/oracle/test/support/async.ts +26 -0
  283. package/proof/oracle/test/support/hydrated-session.ts +205 -0
  284. package/proof/oracle/test/support/images.ts +8 -0
  285. package/proof/oracle/test/support/recorded-fixtures.ts +60 -0
  286. package/proof/oracle/test/support/recorded-scenarios.ts +145 -0
  287. package/proof/oracle/test/support/replay.ts +194 -0
  288. package/proof/oracle/test/support/schema.ts +221 -0
  289. package/proof/oracle/test/support/session-files.ts +12 -0
  290. package/proof/oracle/test/support/session-storage.ts +22 -0
  291. package/proof/oracle/test/support/upstream.ts +25 -0
  292. package/proof/oracle/test/support/watch-events.ts +108 -0
  293. package/proof/oracle/test/terminal-service.test.ts +495 -0
  294. package/proof/oracle/test/truncate.test.ts +230 -0
  295. package/proof/oracle/test/tunnel.test.ts +258 -0
  296. package/proof/oracle/test/upstream-workarounds.test.ts +37 -0
  297. package/proof/oracle/test/uri.test.ts +24 -0
  298. package/proof/oracle/test/watch-events.test.ts +118 -0
  299. package/proof/oracle/tsconfig.json +32 -0
  300. package/proof/proof-piglet/piglet.yaml +18 -0
  301. package/proof/tools/fakellm/go.mod +3 -0
  302. package/proof/tools/fakellm/main.go +109 -0
  303. package/proof/tools/gen-dispatchable.py +30 -0
  304. package/provenance.json +28 -0
@@ -0,0 +1,646 @@
1
+ /** AHP changeset lifecycle, refresh, hydration, and immutable Git content. */
2
+
3
+ import { isUtf8 } from "node:buffer";
4
+ import { basename, isAbsolute, join, relative, sep } from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { isDeepStrictEqual } from "node:util";
7
+ import {
8
+ ActionType,
9
+ type ChangesetState,
10
+ ChangesetStatus,
11
+ ContentEncoding,
12
+ type ResourceReadParams,
13
+ type ResourceReadResult,
14
+ type SessionState,
15
+ type URI,
16
+ } from "@microsoft/agent-host-protocol";
17
+ import { type ChokidarOptions, type FSWatcher, watch } from "chokidar";
18
+ import mime from "mime";
19
+ import { sessionIdFromUri } from "../core/channels.ts";
20
+ import type { AhpHost, ChannelHydrator } from "../core/host.ts";
21
+ import { ProtocolError } from "../protocol/errors.ts";
22
+ import { type PiChangesetKind, parsePiChangesetUri, piChangesetCatalogue, piChangesetUri } from "./changeset-uri.ts";
23
+ import { GitChanges, type GitChangesBackend, type GitWorkspace, parseGitBlobUri } from "./git-changes.ts";
24
+
25
+ export interface ChangesetSession {
26
+ readonly uri: URI;
27
+ readonly sessionId: string;
28
+ readonly workingDirectory: string;
29
+ }
30
+
31
+ export type ChangesetWatchFactory = (paths: string | string[], options: ChokidarOptions) => FSWatcher;
32
+
33
+ export interface ChangesetServiceOptions {
34
+ readonly getSession: (sessionId: string) => ChangesetSession | undefined;
35
+ readonly getSessionByChat: (chat: URI) => ChangesetSession | undefined;
36
+ readonly hydrateSession: (sessionId: string) => Promise<ChangesetSession | undefined>;
37
+ readonly onSessionAvailable?: (listener: (session: ChangesetSession) => void) => () => void;
38
+ readonly onSessionDeletionCommitted?: (listener: (sessionId: string) => Promise<void>) => () => void;
39
+ readonly authorizeResource?: (uri: URI) => Promise<void>;
40
+ readonly git?: GitChangesBackend;
41
+ readonly watchFactory?: ChangesetWatchFactory;
42
+ readonly debounceMs?: number;
43
+ readonly stateGraceMs?: number;
44
+ readonly log?: (message: string) => void;
45
+ }
46
+
47
+ interface SessionEntry {
48
+ session: ChangesetSession;
49
+ workspace: GitWorkspace | undefined;
50
+ probe: Promise<void> | undefined;
51
+ probeAbort: AbortController | undefined;
52
+ refresh: Promise<void> | undefined;
53
+ refreshRequested: boolean;
54
+ suspended: boolean;
55
+ abort: AbortController | undefined;
56
+ watcher: FSWatcher | undefined;
57
+ watchStarting: Promise<void> | undefined;
58
+ watchAbort: AbortController | undefined;
59
+ watcherNeedsRestart: boolean;
60
+ refreshTimer: NodeJS.Timeout | undefined;
61
+ }
62
+
63
+ const DEFAULT_DEBOUNCE_MS = 250;
64
+ const DEFAULT_STATE_GRACE_MS = 30_000;
65
+
66
+ function sameCatalogue(left: SessionState["changesets"], right: SessionState["changesets"]): boolean {
67
+ return isDeepStrictEqual(left, right);
68
+ }
69
+
70
+ function waitUntilReady(watcher: FSWatcher): Promise<void> {
71
+ return new Promise((resolveReady, rejectReady) => {
72
+ const cleanup = (): void => {
73
+ watcher.removeListener("ready", onReady);
74
+ watcher.removeListener("error", onError);
75
+ };
76
+ const onReady = (): void => {
77
+ cleanup();
78
+ resolveReady();
79
+ };
80
+ const onError = (error: unknown): void => {
81
+ cleanup();
82
+ rejectReady(error);
83
+ };
84
+ watcher.once("ready", onReady);
85
+ watcher.once("error", onError);
86
+ });
87
+ }
88
+
89
+ function isInside(root: string, candidate: string): boolean {
90
+ const path = relative(root, candidate);
91
+ return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
92
+ }
93
+
94
+ function changesIgnoreRules(workspace: GitWorkspace, path: string): boolean {
95
+ return basename(path) === ".gitignore" || path === join(workspace.commonGitDirectory, "info", "exclude");
96
+ }
97
+
98
+ function isText(buffer: Buffer): boolean {
99
+ return isUtf8(buffer) && !buffer.includes(0);
100
+ }
101
+
102
+ function sameWatchTarget(left: GitWorkspace | undefined, right: GitWorkspace): boolean {
103
+ return (
104
+ left?.cwd === right.cwd &&
105
+ left.gitDirectory === right.gitDirectory &&
106
+ left.commonGitDirectory === right.commonGitDirectory
107
+ );
108
+ }
109
+
110
+ /** Publishes the two Git-backed static changesets and owns their live resources. */
111
+ export class ChangesetService implements ChannelHydrator {
112
+ readonly #host: AhpHost;
113
+ readonly #options: ChangesetServiceOptions;
114
+ readonly #git: GitChangesBackend;
115
+ readonly #watch: ChangesetWatchFactory;
116
+ readonly #entries = new Map<string, SessionEntry>();
117
+ readonly #stateEvictions = new Map<URI, NodeJS.Timeout>();
118
+ readonly #unhookSubscribers: () => void;
119
+ readonly #unhookActions: () => void;
120
+ readonly #unhookValidator: () => void;
121
+ readonly #unhookSessionAvailable: () => void;
122
+ readonly #unhookSessionDeletionCommitted: () => void;
123
+ #disposed = false;
124
+ #disposal: Promise<void> | undefined;
125
+
126
+ constructor(host: AhpHost, options: ChangesetServiceOptions) {
127
+ this.#host = host;
128
+ this.#options = options;
129
+ this.#git = options.git ?? new GitChanges();
130
+ this.#watch = options.watchFactory ?? watch;
131
+ this.#unhookSubscribers = host.onSubscriberCountChanged((channel, count) =>
132
+ this.#subscriberChanged(channel, count),
133
+ );
134
+ this.#unhookActions = host.onActionCommitted((channel, action) => {
135
+ if (
136
+ action.type !== ActionType.ChatTurnComplete &&
137
+ action.type !== ActionType.ChatTurnCancelled &&
138
+ action.type !== ActionType.ChatError
139
+ ) {
140
+ return;
141
+ }
142
+ const session = this.#options.getSessionByChat(channel);
143
+ if (!session) return;
144
+ const entry = this.#entries.get(session.sessionId);
145
+ if (!entry) return;
146
+ if (this.#hasInterest(entry)) this.#requestRefresh(entry);
147
+ else void this.#probe(entry);
148
+ });
149
+ this.#unhookValidator = host.addClientActionValidator((channel) =>
150
+ parsePiChangesetUri(channel) ? "This host exposes read-only changesets" : undefined,
151
+ );
152
+ this.#unhookSessionAvailable =
153
+ options.onSessionAvailable?.((session) => {
154
+ void this.attach(session).catch((error) =>
155
+ this.#options.log?.(`cannot attach changesets for ${session.uri}: ${String(error)}`),
156
+ );
157
+ }) ?? (() => {});
158
+ this.#unhookSessionDeletionCommitted =
159
+ options.onSessionDeletionCommitted?.((sessionId) => this.removeSession(sessionId)) ?? (() => {});
160
+ }
161
+
162
+ get activeWatcherCount(): number {
163
+ let count = 0;
164
+ for (const entry of this.#entries.values()) {
165
+ if (entry.watcher) count += 1;
166
+ }
167
+ return count;
168
+ }
169
+
170
+ async attach(session: ChangesetSession): Promise<void> {
171
+ if (this.#disposed) return;
172
+ const entry = this.#entryFor(session);
173
+ if (!entry.workspace) await this.#probe(entry);
174
+ }
175
+
176
+ #entryFor(session: ChangesetSession): SessionEntry {
177
+ const existing = this.#entries.get(session.sessionId);
178
+ if (existing) {
179
+ existing.session = session;
180
+ return existing;
181
+ }
182
+ const entry: SessionEntry = {
183
+ session,
184
+ workspace: undefined,
185
+ probe: undefined,
186
+ probeAbort: undefined,
187
+ refresh: undefined,
188
+ refreshRequested: false,
189
+ suspended: false,
190
+ abort: undefined,
191
+ watcher: undefined,
192
+ watchStarting: undefined,
193
+ watchAbort: undefined,
194
+ watcherNeedsRestart: false,
195
+ refreshTimer: undefined,
196
+ };
197
+ this.#entries.set(session.sessionId, entry);
198
+ return entry;
199
+ }
200
+
201
+ async hydrate(channel: URI): Promise<boolean> {
202
+ const parsed = parsePiChangesetUri(channel);
203
+ if (!parsed || this.#disposed) return false;
204
+ let session = this.#options.getSession(parsed.sessionId);
205
+ if (!session) session = await this.#options.hydrateSession(parsed.sessionId);
206
+ if (!session) return false;
207
+ const entry = this.#entryFor(session);
208
+ await this.#probe(entry);
209
+ const state = this.#host.store.get(session.uri) as SessionState | undefined;
210
+ if (!state?.changesets?.some((candidate) => candidate.uriTemplate === channel)) return false;
211
+ this.#cancelStateEviction(channel);
212
+ if (!this.#host.store.has(channel)) {
213
+ this.#host.store.create(
214
+ channel,
215
+ { status: ChangesetStatus.Computing, files: [] } satisfies ChangesetState,
216
+ "changeset",
217
+ );
218
+ }
219
+ return true;
220
+ }
221
+
222
+ async suspendSession(sessionId: string): Promise<void> {
223
+ const entry = this.#entries.get(sessionId);
224
+ if (!entry) return;
225
+ entry.suspended = true;
226
+ entry.refreshRequested = false;
227
+ entry.probeAbort?.abort();
228
+ entry.abort?.abort();
229
+ entry.watchAbort?.abort();
230
+ if (entry.refreshTimer) {
231
+ clearTimeout(entry.refreshTimer);
232
+ entry.refreshTimer = undefined;
233
+ }
234
+ await this.#closeWatcher(entry);
235
+ await Promise.all([entry.probe?.catch(() => undefined), entry.refresh?.catch(() => undefined)]);
236
+ }
237
+
238
+ resumeSession(sessionId: string): void {
239
+ const entry = this.#entries.get(sessionId);
240
+ if (!entry || this.#disposed) return;
241
+ entry.suspended = false;
242
+ if (!entry.workspace) void this.#probe(entry);
243
+ if (this.#hasInterest(entry)) this.#requestRefresh(entry);
244
+ }
245
+
246
+ async removeSession(sessionId: string): Promise<void> {
247
+ const entry = this.#entries.get(sessionId);
248
+ if (entry) await this.suspendSession(sessionId);
249
+ for (const kind of ["latest-commit", "uncommitted"] as const) {
250
+ this.#disposeChannel(piChangesetUri(sessionId, kind));
251
+ }
252
+ this.#entries.delete(sessionId);
253
+ }
254
+
255
+ dispose(): Promise<void> {
256
+ if (this.#disposal) return this.#disposal;
257
+ this.#disposed = true;
258
+ this.#unhookSubscribers();
259
+ this.#unhookActions();
260
+ this.#unhookValidator();
261
+ this.#unhookSessionAvailable();
262
+ this.#unhookSessionDeletionCommitted();
263
+ for (const timer of this.#stateEvictions.values()) clearTimeout(timer);
264
+ this.#stateEvictions.clear();
265
+ this.#disposal = Promise.allSettled([...this.#entries].map(([sessionId]) => this.removeSession(sessionId))).then(
266
+ () => {},
267
+ );
268
+ return this.#disposal;
269
+ }
270
+
271
+ async readResource(params: ResourceReadParams): Promise<ResourceReadResult | undefined> {
272
+ if (!params.uri.startsWith("git-blob:")) return undefined;
273
+ const blob = parseGitBlobUri(params.uri);
274
+ if (!blob) throw ProtocolError.invalidParams(`Malformed git blob URI: ${params.uri}`);
275
+ let session = this.#options.getSession(blob.sessionId);
276
+ if (!session) session = await this.#options.hydrateSession(blob.sessionId);
277
+ if (!session) throw ProtocolError.notFound(params.uri);
278
+ await this.attach(session);
279
+ const workspace = this.#entries.get(blob.sessionId)?.workspace;
280
+ if (!workspace) throw ProtocolError.notFound(params.uri);
281
+ const blobPath = this.#git.pathForBlob(workspace, blob);
282
+ if (!blobPath) throw ProtocolError.notFound(params.uri);
283
+ await this.#options.authorizeResource?.(pathToFileURL(blobPath).toString());
284
+ let buffer: Buffer;
285
+ try {
286
+ buffer = await this.#git.readBlob(workspace, blob);
287
+ } catch {
288
+ throw ProtocolError.notFound(params.uri);
289
+ }
290
+ const text = isText(buffer);
291
+ const encoding =
292
+ params.encoding === ContentEncoding.Base64 || !text ? ContentEncoding.Base64 : ContentEncoding.Utf8;
293
+ const contentType = mime.getType(blob.path) ?? (text ? "text/plain" : undefined);
294
+ return {
295
+ data: buffer.toString(encoding === ContentEncoding.Utf8 ? "utf8" : "base64"),
296
+ encoding,
297
+ ...(contentType ? { contentType } : {}),
298
+ };
299
+ }
300
+
301
+ #subscriberChanged(channel: URI, count: number): void {
302
+ const parsed = parsePiChangesetUri(channel);
303
+ if (!parsed) {
304
+ const sessionId = sessionIdFromUri(channel);
305
+ const entry = sessionId ? this.#entries.get(sessionId) : undefined;
306
+ if (entry && count > 0) void this.#probe(entry);
307
+ return;
308
+ }
309
+ const entry = this.#entries.get(parsed.sessionId);
310
+ if (!entry) return;
311
+ if (count > 0) {
312
+ this.#cancelStateEviction(channel);
313
+ this.#requestRefresh(entry);
314
+ return;
315
+ }
316
+ this.#startStateEviction(channel);
317
+ if (!this.#hasInterest(entry)) {
318
+ entry.refreshRequested = false;
319
+ entry.abort?.abort();
320
+ void this.#closeWatcher(entry);
321
+ }
322
+ }
323
+
324
+ #startStateEviction(channel: URI): void {
325
+ if (this.#stateEvictions.has(channel)) return;
326
+ const timer = setTimeout(() => {
327
+ this.#stateEvictions.delete(channel);
328
+ if (this.#host.subscriberCount(channel) === 0) this.#host.deleteChannel(channel);
329
+ }, this.#options.stateGraceMs ?? DEFAULT_STATE_GRACE_MS);
330
+ timer.unref?.();
331
+ this.#stateEvictions.set(channel, timer);
332
+ }
333
+
334
+ #cancelStateEviction(channel: URI): void {
335
+ const timer = this.#stateEvictions.get(channel);
336
+ if (!timer) return;
337
+ clearTimeout(timer);
338
+ this.#stateEvictions.delete(channel);
339
+ }
340
+
341
+ #hasInterest(entry: SessionEntry): boolean {
342
+ return (
343
+ this.#host.subscriberCount(piChangesetUri(entry.session.sessionId, "latest-commit")) > 0 ||
344
+ this.#host.subscriberCount(piChangesetUri(entry.session.sessionId, "uncommitted")) > 0
345
+ );
346
+ }
347
+
348
+ #probe(entry: SessionEntry): Promise<void> {
349
+ if (entry.probe) return entry.probe;
350
+ const abort = new AbortController();
351
+ entry.probeAbort = abort;
352
+ const operation = this.#doProbe(entry, abort.signal).finally(() => {
353
+ if (entry.probe === operation) entry.probe = undefined;
354
+ if (entry.probeAbort === abort) entry.probeAbort = undefined;
355
+ });
356
+ entry.probe = operation;
357
+ return operation;
358
+ }
359
+
360
+ async #doProbe(entry: SessionEntry, signal: AbortSignal): Promise<void> {
361
+ try {
362
+ const workspace = await this.#inspectWorkspace(entry, signal);
363
+ if (this.#entries.get(entry.session.sessionId) !== entry || entry.suspended || this.#disposed) return;
364
+ entry.workspace = workspace;
365
+ this.#applyCatalogue(entry);
366
+ } catch (error) {
367
+ if (signal.aborted || this.#entries.get(entry.session.sessionId) !== entry || entry.suspended || this.#disposed) {
368
+ return;
369
+ }
370
+ entry.workspace = undefined;
371
+ this.#applyCatalogue(entry);
372
+ this.#options.log?.(`cannot inspect Git changes for ${entry.session.uri}: ${String(error)}`);
373
+ }
374
+ }
375
+
376
+ #applyCatalogue(entry: SessionEntry): void {
377
+ const next = entry.workspace
378
+ ? piChangesetCatalogue(entry.session.sessionId, entry.workspace.head !== undefined)
379
+ : undefined;
380
+ const available = new Set(next?.map((changeset) => changeset.uriTemplate));
381
+ for (const kind of ["latest-commit", "uncommitted"] as const) {
382
+ const channel = piChangesetUri(entry.session.sessionId, kind);
383
+ if (!available.has(channel)) this.#disposeChannel(channel);
384
+ }
385
+ const state = this.#host.store.get(entry.session.uri) as SessionState | undefined;
386
+ if (state && !sameCatalogue(state.changesets, next)) {
387
+ this.#host.dispatchServerAction(entry.session.uri, {
388
+ type: ActionType.SessionChangesetsChanged,
389
+ changesets: next,
390
+ });
391
+ }
392
+ }
393
+
394
+ #requestRefresh(entry: SessionEntry): void {
395
+ if (this.#disposed || entry.suspended || !this.#hasInterest(entry)) return;
396
+ entry.refreshRequested = true;
397
+ if (entry.refreshTimer) {
398
+ clearTimeout(entry.refreshTimer);
399
+ entry.refreshTimer = undefined;
400
+ }
401
+ if (entry.refresh) return;
402
+ const operation = this.#refreshLoop(entry).finally(() => {
403
+ if (entry.refresh !== operation) return;
404
+ entry.refresh = undefined;
405
+ // An event can arrive after the loop's final condition check but before
406
+ // this cleanup. Consume that wake-up instead of leaving it stranded.
407
+ if (entry.refreshRequested) this.#requestRefresh(entry);
408
+ });
409
+ entry.refresh = operation;
410
+ void operation.catch((error) =>
411
+ this.#options.log?.(`changeset refresh failed for ${entry.session.uri}: ${String(error)}`),
412
+ );
413
+ }
414
+
415
+ async #refreshLoop(entry: SessionEntry): Promise<void> {
416
+ while (entry.refreshRequested && !entry.suspended && !this.#disposed) {
417
+ entry.refreshRequested = false;
418
+ await this.#refreshOnce(entry);
419
+ }
420
+ }
421
+
422
+ async #refreshOnce(entry: SessionEntry): Promise<void> {
423
+ await entry.probe?.catch(() => undefined);
424
+ if (entry.suspended || this.#disposed) return;
425
+ const abort = new AbortController();
426
+ entry.abort = abort;
427
+ try {
428
+ let workspace: GitWorkspace | undefined;
429
+ try {
430
+ workspace = await this.#inspectWorkspace(entry, abort.signal);
431
+ } catch (error) {
432
+ if (abort.signal.aborted) return;
433
+ this.#publishError(entry, error);
434
+ return;
435
+ }
436
+ if (!this.#canPublish(entry, abort)) return;
437
+ entry.workspace = workspace;
438
+ this.#applyCatalogue(entry);
439
+ if (!workspace) {
440
+ await this.#closeWatcher(entry);
441
+ return;
442
+ }
443
+ if (entry.watcherNeedsRestart) await this.#closeWatcher(entry);
444
+ // Establish observation before scanning. Any event during the scan sets
445
+ // `refreshRequested`, so the loop immediately computes a second snapshot.
446
+ await this.#ensureWatcher(entry);
447
+ if (!this.#canPublish(entry, abort)) return;
448
+
449
+ const kinds = (["latest-commit", "uncommitted"] as const).filter((kind) => {
450
+ const channel = piChangesetUri(entry.session.sessionId, kind);
451
+ return this.#host.store.has(channel) && this.#host.subscriberCount(channel) > 0;
452
+ });
453
+ for (const kind of kinds) {
454
+ const channel = piChangesetUri(entry.session.sessionId, kind);
455
+ const state = this.#host.store.get(channel) as ChangesetState | undefined;
456
+ if (state?.status !== ChangesetStatus.Computing) {
457
+ this.#host.dispatchServerAction(channel, {
458
+ type: ActionType.ChangesetStatusChanged,
459
+ status: ChangesetStatus.Computing,
460
+ });
461
+ }
462
+ }
463
+ await Promise.all(kinds.map((kind) => this.#compute(entry, workspace, kind, abort)));
464
+ } finally {
465
+ if (entry.abort === abort) entry.abort = undefined;
466
+ }
467
+ }
468
+
469
+ async #compute(
470
+ entry: SessionEntry,
471
+ workspace: GitWorkspace,
472
+ kind: PiChangesetKind,
473
+ abort: AbortController,
474
+ ): Promise<void> {
475
+ const channel = piChangesetUri(entry.session.sessionId, kind);
476
+ try {
477
+ const files = await this.#git.compute(workspace, entry.session.sessionId, kind, abort.signal);
478
+ if (!this.#canPublish(entry, abort) || !this.#host.store.has(channel)) return;
479
+ this.#host.dispatchServerAction(channel, {
480
+ type: ActionType.ChangesetContentChanged,
481
+ files,
482
+ });
483
+ this.#host.dispatchServerAction(channel, {
484
+ type: ActionType.ChangesetStatusChanged,
485
+ status: ChangesetStatus.Ready,
486
+ });
487
+ } catch (error) {
488
+ if (abort.signal.aborted || !this.#canPublish(entry, abort) || !this.#host.store.has(channel)) return;
489
+ this.#host.dispatchServerAction(channel, {
490
+ type: ActionType.ChangesetStatusChanged,
491
+ status: ChangesetStatus.Error,
492
+ error: { errorType: "gitChangesFailed", message: error instanceof Error ? error.message : String(error) },
493
+ });
494
+ }
495
+ }
496
+
497
+ #publishError(entry: SessionEntry, error: unknown): void {
498
+ for (const kind of ["latest-commit", "uncommitted"] as const) {
499
+ const channel = piChangesetUri(entry.session.sessionId, kind);
500
+ if (this.#host.store.has(channel) && this.#host.subscriberCount(channel) > 0) {
501
+ this.#host.dispatchServerAction(channel, {
502
+ type: ActionType.ChangesetStatusChanged,
503
+ status: ChangesetStatus.Error,
504
+ error: { errorType: "gitChangesFailed", message: error instanceof Error ? error.message : String(error) },
505
+ });
506
+ }
507
+ }
508
+ }
509
+
510
+ async #inspectWorkspace(entry: SessionEntry, signal?: AbortSignal): Promise<GitWorkspace | undefined> {
511
+ await this.#options.authorizeResource?.(pathToFileURL(entry.session.workingDirectory).toString());
512
+ return this.#git.inspect(entry.session.workingDirectory, signal);
513
+ }
514
+
515
+ #canPublish(entry: SessionEntry, abort: AbortController): boolean {
516
+ return (
517
+ !this.#disposed &&
518
+ !entry.suspended &&
519
+ !abort.signal.aborted &&
520
+ this.#entries.get(entry.session.sessionId) === entry &&
521
+ this.#options.getSession(entry.session.sessionId) !== undefined
522
+ );
523
+ }
524
+
525
+ #scheduleRefresh(entry: SessionEntry): void {
526
+ if (this.#disposed || entry.suspended || !this.#hasInterest(entry)) return;
527
+ entry.refreshRequested = true;
528
+ if (entry.refresh || entry.refreshTimer) return;
529
+ const timer = setTimeout(() => {
530
+ entry.refreshTimer = undefined;
531
+ this.#requestRefresh(entry);
532
+ }, this.#options.debounceMs ?? DEFAULT_DEBOUNCE_MS);
533
+ timer.unref?.();
534
+ entry.refreshTimer = timer;
535
+ }
536
+
537
+ async #ensureWatcher(entry: SessionEntry): Promise<void> {
538
+ if (entry.watchStarting) return entry.watchStarting;
539
+ if (this.#disposed || entry.suspended || entry.watcher || !entry.workspace || !this.#hasInterest(entry)) {
540
+ return;
541
+ }
542
+ const workspace = entry.workspace;
543
+ const abort = new AbortController();
544
+ entry.watchAbort = abort;
545
+ const operation = this.#startWatcher(entry, workspace, abort.signal)
546
+ .catch((error) => {
547
+ if (!abort.signal.aborted) {
548
+ this.#options.log?.(`cannot watch changes for ${entry.session.uri}: ${String(error)}`);
549
+ }
550
+ })
551
+ .finally(() => {
552
+ if (entry.watchStarting === operation) entry.watchStarting = undefined;
553
+ if (entry.watchAbort === abort) entry.watchAbort = undefined;
554
+ });
555
+ entry.watchStarting = operation;
556
+ await operation;
557
+ }
558
+
559
+ async #startWatcher(entry: SessionEntry, workspace: GitWorkspace, signal: AbortSignal): Promise<void> {
560
+ // If Git cannot describe ignored trees, do not fall back to recursively
561
+ // watching them: large dependency trees can exhaust native descriptors.
562
+ const ignored = await this.#git.ignoredDirectories(workspace, signal);
563
+ if (
564
+ signal.aborted ||
565
+ this.#disposed ||
566
+ entry.suspended ||
567
+ !sameWatchTarget(entry.workspace, workspace) ||
568
+ !this.#hasInterest(entry)
569
+ ) {
570
+ return;
571
+ }
572
+ const metadataTargets = [
573
+ join(workspace.gitDirectory, "HEAD"),
574
+ join(workspace.gitDirectory, "index"),
575
+ join(workspace.commonGitDirectory, "packed-refs"),
576
+ join(workspace.commonGitDirectory, "refs"),
577
+ join(workspace.commonGitDirectory, "info", "exclude"),
578
+ ];
579
+ const metadataRoots = new Set([workspace.gitDirectory, workspace.commonGitDirectory]);
580
+ const watcher = this.#watch([workspace.cwd, ...metadataTargets], {
581
+ ignoreInitial: true,
582
+ followSymlinks: false,
583
+ ignored: (path: string) => {
584
+ const metadataRoot = [...metadataRoots].find((root) => isInside(root, path));
585
+ if (metadataRoot) {
586
+ return (
587
+ path !== metadataRoot && !metadataTargets.some((target) => isInside(path, target) || isInside(target, path))
588
+ );
589
+ }
590
+ return ignored.some((root) => isInside(root, path));
591
+ },
592
+ });
593
+ const changed = (path: string): void => {
594
+ if (changesIgnoreRules(workspace, path)) entry.watcherNeedsRestart = true;
595
+ this.#scheduleRefresh(entry);
596
+ };
597
+ watcher
598
+ .on("add", changed)
599
+ .on("addDir", changed)
600
+ .on("change", changed)
601
+ .on("unlink", changed)
602
+ .on("unlinkDir", changed);
603
+ try {
604
+ await waitUntilReady(watcher);
605
+ if (
606
+ this.#disposed ||
607
+ entry.suspended ||
608
+ !sameWatchTarget(entry.workspace, workspace) ||
609
+ !this.#hasInterest(entry)
610
+ ) {
611
+ await watcher.close();
612
+ return;
613
+ }
614
+ entry.watcher = watcher;
615
+ entry.watcherNeedsRestart = false;
616
+ watcher.on("error", (error) => {
617
+ if (entry.watcher === watcher) entry.watcher = undefined;
618
+ this.#options.log?.(`changeset watch failed for ${entry.session.uri}: ${String(error)}`);
619
+ void watcher.close();
620
+ });
621
+ } catch (error) {
622
+ await watcher.close();
623
+ this.#options.log?.(`cannot watch changes for ${entry.session.uri}: ${String(error)}`);
624
+ }
625
+ }
626
+
627
+ async #closeWatcher(entry: SessionEntry): Promise<void> {
628
+ entry.watchAbort?.abort();
629
+ await entry.watchStarting?.catch(() => undefined);
630
+ const watcher = entry.watcher;
631
+ if (!watcher) return;
632
+ entry.watcher = undefined;
633
+ try {
634
+ await watcher.close();
635
+ } catch (error) {
636
+ this.#options.log?.(`closing changeset watch failed for ${entry.session.uri}: ${String(error)}`);
637
+ }
638
+ }
639
+
640
+ #disposeChannel(channel: URI): void {
641
+ this.#cancelStateEviction(channel);
642
+ if (!this.#host.store.has(channel)) return;
643
+ this.#host.dispatchServerAction(channel, { type: ActionType.ChangesetCleared });
644
+ this.#host.deleteChannel(channel);
645
+ }
646
+ }
@@ -0,0 +1,64 @@
1
+ /** Stable AHP identities and catalogue entries for pi's Git changes views. */
2
+
3
+ import type { Changeset, URI } from "@microsoft/agent-host-protocol";
4
+
5
+ const CHANGESET_SCHEME = "ahp-changeset:";
6
+
7
+ export type PiChangesetKind = "latest-commit" | "uncommitted";
8
+
9
+ export interface ParsedPiChangesetUri {
10
+ readonly sessionId: string;
11
+ readonly kind: PiChangesetKind;
12
+ }
13
+
14
+ export function piChangesetUri(sessionId: string, kind: PiChangesetKind): URI {
15
+ return `${CHANGESET_SCHEME}/${encodeURIComponent(sessionId)}/${kind}`;
16
+ }
17
+
18
+ export function parsePiChangesetUri(uri: URI): ParsedPiChangesetUri | undefined {
19
+ let parsed: URL;
20
+ try {
21
+ parsed = new URL(uri);
22
+ } catch {
23
+ return undefined;
24
+ }
25
+ if (parsed.protocol !== CHANGESET_SCHEME || parsed.host || parsed.search || parsed.hash) {
26
+ return undefined;
27
+ }
28
+ const match = /^\/([^/]+)\/(latest-commit|uncommitted)$/.exec(parsed.pathname);
29
+ if (!match) {
30
+ return undefined;
31
+ }
32
+ let sessionId: string;
33
+ try {
34
+ sessionId = decodeURIComponent(match[1] ?? "");
35
+ } catch {
36
+ return undefined;
37
+ }
38
+ if (!sessionId || sessionId.includes("/")) {
39
+ return undefined;
40
+ }
41
+ const kind = match[2] as PiChangesetKind;
42
+ return piChangesetUri(sessionId, kind) === uri ? { sessionId, kind } : undefined;
43
+ }
44
+
45
+ export function piChangesetCatalogue(sessionId: string, hasCommit: boolean): Changeset[] {
46
+ return [
47
+ ...(hasCommit
48
+ ? ([
49
+ {
50
+ label: "Latest Commit",
51
+ description: "Changes introduced by the current HEAD commit",
52
+ changeKind: "branch",
53
+ uriTemplate: piChangesetUri(sessionId, "latest-commit"),
54
+ },
55
+ ] satisfies Changeset[])
56
+ : []),
57
+ {
58
+ label: "Uncommitted Changes",
59
+ description: "Current staged, unstaged, and untracked changes",
60
+ changeKind: "uncommitted",
61
+ uriTemplate: piChangesetUri(sessionId, "uncommitted"),
62
+ },
63
+ ];
64
+ }