@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,273 @@
1
+ package pi_test
2
+
3
+ import (
4
+ "encoding/json"
5
+ "regexp"
6
+ "sort"
7
+ "strings"
8
+ "testing"
9
+
10
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
11
+
12
+ "github.com/MichaelKinsy/pigpen/ahp/internal/testkit"
13
+ "github.com/MichaelKinsy/pigpen/ahp/internal/twin"
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/wire"
15
+ )
16
+
17
+ // Twins of upstream test/client-actions.test.ts: the client-action policy of this host, seen from
18
+ // a sender and an observer. The fixture is storage-only (no backend).
19
+
20
+ type actionsFixture struct {
21
+ t *testing.T
22
+ h *harness
23
+ client *testkit.Client
24
+ observer *testkit.Client
25
+ workspace string
26
+ }
27
+
28
+ func startActions(t *testing.T) *actionsFixture {
29
+ t.Helper()
30
+ workspace := t.TempDir()
31
+ f := &actionsFixture{t: t, workspace: workspace, h: startHarness(t, harnessOptions{workingDir: workspace})}
32
+ f.client, f.observer = testkit.Connect(t, f.h.host), testkit.Connect(t, f.h.host)
33
+ t.Cleanup(f.client.Close)
34
+ t.Cleanup(f.observer.Close)
35
+ f.client.Initialize(nextClientID(), nil)
36
+ f.observer.Initialize(nextClientID(), nil)
37
+ return f
38
+ }
39
+
40
+ func (f *actionsFixture) createChannels() (session, chat string) {
41
+ f.t.Helper()
42
+ id := newID()
43
+ session, chat = wire.SessionURI(id), wire.ChatURI(id)
44
+ f.client.Must("createSession", obj{"channel": session})
45
+ var snapshot ahptypes.SubscribeResult
46
+ for _, c := range []*testkit.Client{f.client, f.observer} {
47
+ snapshot = c.Subscribe(session)
48
+ c.Subscribe(chat)
49
+ }
50
+ // The host attaches the session's backend on its own goroutine, so the subscriber may see lifecycle
51
+ // "creating" and then the host's session/ready action. Wait for that action (the snapshot already
52
+ // says "ready" when the host won the race) so that a state taken before a client action is the state
53
+ // the session settles in. Upstream's harness is ready synchronously in storage-only mode.
54
+ if snapshot.Snapshot == nil || testkit.Normalize(f.t, snapshot.Snapshot.State).(map[string]any)["lifecycle"] != string(ahptypes.SessionLifecycleReady) {
55
+ awaitAction(f.t, f.observer, session, "session/ready")
56
+ }
57
+ return session, chat
58
+ }
59
+
60
+ func (f *actionsFixture) state(channel string) any {
61
+ if wire.IsChatChannel(channel) {
62
+ return f.h.host.Store().Chat(channel)
63
+ }
64
+ return f.h.host.Store().Session(channel)
65
+ }
66
+
67
+ // rawEnvelope is an action envelope decoded generically: a rejected malformed action cannot be
68
+ // decoded into the typed union, and the echo of one must still be checkable.
69
+ type rawEnvelope struct {
70
+ Channel string
71
+ Action map[string]any
72
+ ServerSeq int64
73
+ Origin *struct{ ClientSeq int64 }
74
+ RejectionReason *string
75
+ }
76
+
77
+ func (f *actionsFixture) nextRaw(c *testkit.Client, channel string, seq int64) rawEnvelope {
78
+ f.t.Helper()
79
+ n, ok := c.Await(func(n testkit.Notification) bool {
80
+ if n.Method != "action" {
81
+ return false
82
+ }
83
+ var e rawEnvelope
84
+ return json.Unmarshal(n.Params, &e) == nil && e.Channel == channel && e.Origin != nil && e.Origin.ClientSeq == seq
85
+ }, testkit.Timeout)
86
+ if !ok {
87
+ f.t.Fatalf("timed out waiting for the echo of clientSeq %d on %s", seq, channel)
88
+ }
89
+ var e rawEnvelope
90
+ _ = json.Unmarshal(n.Params, &e)
91
+ return e
92
+ }
93
+
94
+ func (f *actionsFixture) expectRejected(channel string, action obj, reason string) {
95
+ f.t.Helper()
96
+ before := testkit.Normalize(f.t, f.state(channel))
97
+ seq := f.client.Dispatch(channel, action)
98
+ sender := f.nextRaw(f.client, channel, seq)
99
+ seen := f.nextRaw(f.observer, channel, seq)
100
+ if sender.Action["type"] != action["type"] {
101
+ f.t.Fatalf("%v: echoed action type %v", action["type"], sender.Action["type"])
102
+ }
103
+ if sender.RejectionReason == nil || !regexp.MustCompile(reason).MatchString(*sender.RejectionReason) {
104
+ f.t.Fatalf("%v: rejectionReason = %v, want /%s/", action["type"], sender.RejectionReason, reason)
105
+ }
106
+ if seen.ServerSeq != sender.ServerSeq || seen.RejectionReason == nil || *seen.RejectionReason != *sender.RejectionReason {
107
+ f.t.Fatalf("%v: the observer saw a different envelope: %+v vs %+v", action["type"], seen, sender)
108
+ }
109
+ sameJSON(f.t, seen.Action, sender.Action, "observer's action")
110
+ sameJSON(f.t, f.state(channel), before, "state must not change")
111
+ }
112
+
113
+ // The policy cases snapshot a channel's state and compare it after a rejected action, so the fixture must hand
114
+ // back sessions that have finished starting: the host attaches a session's backend on its own goroutine, and a
115
+ // "creating" snapshot followed by the "ready" transition is not a change the rejected action made.
116
+ func TestActionsFixtureHandsBackReadySessions(t *testing.T) {
117
+ f := startActions(t)
118
+ for i := 0; i < 200; i++ {
119
+ session, _ := f.createChannels()
120
+ if got := f.h.host.Store().Session(session).Lifecycle; got != ahptypes.SessionLifecycleReady {
121
+ t.Fatalf("session %d lifecycle = %s, want ready", i, got)
122
+ }
123
+ }
124
+ }
125
+
126
+ func TestPiClientActionPolicy(t *testing.T) {
127
+ twin.Run(t, "client-actions", "accounts for every client action on channels this host serves", func(t *testing.T) {
128
+ var actual []string
129
+ for _, typ := range wire.ClientDispatchableTypes() {
130
+ for _, prefix := range []string{"root/", "session/", "chat/", "terminal/"} {
131
+ if strings.HasPrefix(typ, prefix) {
132
+ actual = append(actual, typ)
133
+ }
134
+ }
135
+ }
136
+ expected := []string{
137
+ "root/configChanged", "session/titleChanged", "session/activeClientSet", "session/activeClientRemoved",
138
+ "session/workingDirectorySet", "session/workingDirectoryRemoved", "session/workingDirectoryReplaced",
139
+ "session/customizationToggled", "session/mcpServerStartRequested", "session/mcpServerStopRequested",
140
+ "session/isReadChanged", "session/isArchivedChanged", "session/configChanged",
141
+ "chat/turnStarted", "chat/turnResume", "chat/toolCallConfirmed", "chat/toolCallComplete",
142
+ "chat/toolCallResultConfirmed", "chat/toolCallContentChanged", "chat/turnCancelled",
143
+ "chat/workingDirectorySet", "chat/workingDirectoryRemoved", "chat/pendingMessageSet",
144
+ "chat/pendingMessageRemoved", "chat/queuedMessagesReordered", "chat/draftChanged",
145
+ "chat/inputAnswerChanged", "chat/inputCompleted", "chat/truncated",
146
+ "terminal/input", "terminal/resized", "terminal/claimed", "terminal/titleChanged", "terminal/cleared",
147
+ // Client-dispatchable in the pinned spec commit but absent from the npm 0.9.0 package
148
+ // upstream tested against (see PORT.md); this host refuses both.
149
+ "chat/isArchivedChanged", "session/mcpServerBackgroundRequested",
150
+ }
151
+ sort.Strings(expected)
152
+ sameJSON(t, actual, expected, "client-dispatchable actions on served channels")
153
+ })
154
+
155
+ twin.Run(t, "client-actions", "rejects actions addressed to the wrong channel kind", func(t *testing.T) {
156
+ f := startActions(t)
157
+ session, chat := f.createChannels()
158
+ f.expectRejected(session, obj{"type": "chat/draftChanged", "draft": userMessage("wrong channel")}, `does not belong on a session channel`)
159
+ f.expectRejected(chat, obj{"type": "session/titleChanged", "title": "wrong channel"}, `does not belong on a chat channel`)
160
+ f.expectRejected(chat, obj{"type": "terminal/cleared"}, `does not belong on a chat channel`)
161
+ f.expectRejected(session, obj{"type": "root/configChanged", "config": obj{}}, `does not belong on a session channel`)
162
+ })
163
+
164
+ twin.Run(t, "client-actions", "rejects capabilities pi does not implement", func(t *testing.T) {
165
+ f := startActions(t)
166
+ session, chat := f.createChannels()
167
+ wd := "file://" + f.workspace
168
+ for _, a := range []obj{
169
+ {"type": "session/workingDirectorySet", "directory": "file:///tmp/other"},
170
+ {"type": "session/workingDirectoryRemoved", "directory": wd},
171
+ {"type": "session/workingDirectoryReplaced", "directory": wd, "replacement": "file:///tmp/other"},
172
+ } {
173
+ f.expectRejected(session, a, `changing working directories`)
174
+ }
175
+ for _, a := range []obj{
176
+ {"type": "chat/workingDirectorySet", "directory": "file:///tmp/other"},
177
+ {"type": "chat/workingDirectoryRemoved", "directory": wd},
178
+ } {
179
+ f.expectRejected(chat, a, `changing working directories`)
180
+ }
181
+ f.expectRejected(session, obj{"type": "session/customizationToggled", "id": "plugin", "enablement": []any{}}, `customizations`)
182
+ for _, typ := range []string{"session/mcpServerStartRequested", "session/mcpServerStopRequested", "session/mcpServerBackgroundRequested"} {
183
+ f.expectRejected(session, obj{"type": typ, "id": "mcp"}, `MCP servers`)
184
+ }
185
+ f.expectRejected(session, obj{"type": "session/isReadChanged", "isRead": false}, `read or archive state`)
186
+ f.expectRejected(session, obj{"type": "session/isArchivedChanged", "isArchived": true}, `read or archive state`)
187
+ f.expectRejected(chat, obj{"type": "chat/isArchivedChanged", "isArchived": true}, `read or archive state`)
188
+ f.expectRejected(session, obj{"type": "session/configChanged", "config": obj{"probe": true}}, `no mutable configuration`)
189
+ for _, a := range []obj{
190
+ {"type": "chat/toolCallConfirmed", "turnId": "turn", "toolCallId": "tool", "approved": false, "reason": "denied"},
191
+ {"type": "chat/toolCallComplete", "turnId": "turn", "toolCallId": "tool", "result": obj{"success": false, "pastTenseMessage": "Failed"}},
192
+ {"type": "chat/toolCallResultConfirmed", "turnId": "turn", "toolCallId": "tool", "approved": true},
193
+ {"type": "chat/toolCallContentChanged", "turnId": "turn", "toolCallId": "tool", "content": []any{}},
194
+ } {
195
+ f.expectRejected(chat, a, `client tool execution or confirmation`)
196
+ }
197
+ f.expectRejected(chat, obj{"type": "chat/turnResume", "turnId": "turn"}, `cannot resume an errored turn`)
198
+ f.expectRejected(chat, obj{"type": "chat/inputAnswerChanged", "requestId": "input", "questionId": "question"}, `interactive input requests`)
199
+ f.expectRejected(chat, obj{"type": "chat/inputCompleted", "requestId": "input", "response": "cancel"}, `interactive input requests`)
200
+ })
201
+
202
+ twin.Run(t, "client-actions", "rejects malformed supported actions without mutating state", func(t *testing.T) {
203
+ f := startActions(t)
204
+ session, chat := f.createChannels()
205
+ f.expectRejected(session, obj{"type": "session/titleChanged", "title": 42}, `title must be a string`)
206
+ f.expectRejected(chat, obj{"type": "chat/turnStarted", "turnId": "malformed", "startedAt": "2025-01-01T00:00:00.000Z",
207
+ "message": obj{"text": 42, "origin": obj{"kind": "user"}}}, `requires text and an origin`)
208
+ f.expectRejected(chat, obj{"type": "chat/queuedMessagesReordered", "order": []any{42}}, `order must be an array of ids`)
209
+ })
210
+
211
+ twin.Run(t, "client-actions", "validates turn and user-message invariants", func(t *testing.T) {
212
+ f := startActions(t)
213
+ _, chat := f.createChannels()
214
+ agent := userMessage("not from the user")
215
+ agent["origin"] = obj{"kind": "agent"}
216
+ at := "2025-01-01T00:00:00.000Z"
217
+ f.expectRejected(chat, obj{"type": "chat/turnStarted", "turnId": "agent-turn", "startedAt": at, "message": agent}, `only start a turn with a user message`)
218
+ f.expectRejected(chat, obj{"type": "chat/pendingMessageSet", "kind": "queued", "id": "agent-queue", "message": agent}, `only queue a user message`)
219
+ f.expectRejected(chat, obj{"type": "chat/draftChanged", "draft": agent}, `only draft a user message`)
220
+ withContext := userMessage("with context")
221
+ withContext["attachments"] = []any{obj{"type": "simple", "label": "context"}}
222
+ f.expectRejected(chat, obj{"type": "chat/turnStarted", "turnId": "attachment-turn", "startedAt": at, "message": withContext}, `requires modelRepresentation`)
223
+ customAgent := userMessage("custom agent")
224
+ customAgent["agent"] = obj{"uri": "agent:/fixture"}
225
+ f.expectRejected(chat, obj{"type": "chat/draftChanged", "draft": customAgent}, `custom agents`)
226
+ f.expectRejected(chat, obj{"type": "chat/turnStarted", "turnId": "queued-turn", "startedAt": at, "message": userMessage("queued"), "queuedMessageId": "client-owned"}, `Only the host can start a queued message`)
227
+ f.expectRejected(chat, obj{"type": "chat/turnCancelled", "turnId": "absent", "duration": 0}, `No matching active turn`)
228
+ f.expectRejected(chat, obj{"type": "chat/pendingMessageRemoved", "kind": "queued", "id": "absent"}, `No matching queued message`)
229
+ f.h.host.DispatchServerAction(chat, ahptypes.StateAction{Value: &ahptypes.ChatPendingMessageSetAction{
230
+ Type: ahptypes.ActionTypeChatPendingMessageSet, Kind: ahptypes.PendingMessageKindSteering, Id: "steering",
231
+ Message: ahptypes.Message{Text: "steer", Origin: ahptypes.MessageOrigin{Kind: ahptypes.MessageKindUser}},
232
+ }})
233
+ f.expectRejected(chat, obj{"type": "chat/pendingMessageRemoved", "kind": "steering", "id": "steering"}, `cannot be withdrawn`)
234
+ f.h.host.DispatchServerAction(chat, ahptypes.StateAction{Value: &ahptypes.ChatTurnStartedAction{
235
+ Type: ahptypes.ActionTypeChatTurnStarted, TurnId: "active", StartedAt: at,
236
+ Message: ahptypes.Message{Text: "active", Origin: ahptypes.MessageOrigin{Kind: ahptypes.MessageKindUser}},
237
+ }})
238
+ f.expectRejected(chat, obj{"type": "chat/turnStarted", "turnId": "second", "startedAt": at, "message": userMessage("second")}, `A turn is already active`)
239
+ })
240
+
241
+ twin.Run(t, "client-actions", "keeps state-only draft and queued-message actions", func(t *testing.T) {
242
+ f := startActions(t)
243
+ _, chat := f.createChannels()
244
+ accepted := func(action obj) {
245
+ t.Helper()
246
+ seq := f.client.Dispatch(chat, action)
247
+ if env := f.client.NextEnvelope(chat, seq); env.RejectionReason != nil {
248
+ t.Fatalf("%v rejected: %s", action["type"], *env.RejectionReason)
249
+ }
250
+ }
251
+ accepted(obj{"type": "chat/draftChanged", "draft": userMessage("draft")})
252
+ if got := f.h.host.Store().Chat(chat).Draft; got == nil || got.Text != "draft" {
253
+ t.Fatalf("draft = %+v", got)
254
+ }
255
+ for _, id := range []string{"first", "second"} {
256
+ f.h.host.DispatchServerAction(chat, ahptypes.StateAction{Value: &ahptypes.ChatPendingMessageSetAction{
257
+ Type: ahptypes.ActionTypeChatPendingMessageSet, Kind: ahptypes.PendingMessageKindQueued, Id: id,
258
+ Message: ahptypes.Message{Text: id, Origin: ahptypes.MessageOrigin{Kind: ahptypes.MessageKindUser}},
259
+ }})
260
+ }
261
+ ids := func() []string {
262
+ var out []string
263
+ for _, m := range f.h.host.Store().Chat(chat).QueuedMessages {
264
+ out = append(out, m.Id)
265
+ }
266
+ return out
267
+ }
268
+ accepted(obj{"type": "chat/queuedMessagesReordered", "order": []string{"second", "first"}})
269
+ sameJSON(t, ids(), []string{"second", "first"}, "queued order")
270
+ accepted(obj{"type": "chat/pendingMessageRemoved", "kind": "queued", "id": "first"})
271
+ sameJSON(t, ids(), []string{"second"}, "queued after removal")
272
+ })
273
+ }
@@ -0,0 +1,230 @@
1
+ package pi
2
+
3
+ import (
4
+ "context"
5
+ "io/fs"
6
+ "net/url"
7
+ "path/filepath"
8
+ "sort"
9
+ "strings"
10
+ "unicode/utf16"
11
+
12
+ "github.com/microsoft/agent-host-protocol/clients/go/ahptypes"
13
+
14
+ "github.com/MichaelKinsy/pigpen/ahp/internal/mapper"
15
+ )
16
+
17
+ // Inline completions for a message being composed (port of src/pi/completions.ts).
18
+ //
19
+ // The protocol makes CompletionItem.attachment required, which decides what this feature is: an
20
+ // attachment picker. "@"-mentions fit; Pi's slash commands do not, because they attach nothing.
21
+
22
+ // MentionTrigger is the character that opens the file picker.
23
+ const MentionTrigger = "@"
24
+
25
+ const (
26
+ maxCompletionItems = 50
27
+ // maxScannedEntries bounds the directory walk of a huge working directory.
28
+ maxScannedEntries = 10_000
29
+ )
30
+
31
+ // skippedDirectories are never descended into. The list is deliberately narrow: build output such
32
+ // as dist/ and build/ is often exactly what a user wants to mention.
33
+ var skippedDirectories = map[string]bool{".git": true, "node_modules": true}
34
+
35
+ // score ranks a relative path against a lower-cased query: an exact file name beats a prefix beats
36
+ // a substring of the name beats a substring of the path; 0 is no match.
37
+ func score(relativePath, query string) int {
38
+ if query == "" {
39
+ return 1
40
+ }
41
+ path := strings.ToLower(relativePath)
42
+ name := path[strings.LastIndexByte(path, '/')+1:]
43
+ switch {
44
+ case name == query:
45
+ return 4
46
+ case strings.HasPrefix(name, query):
47
+ return 3
48
+ case strings.Contains(name, query):
49
+ return 2
50
+ case strings.Contains(path, query):
51
+ return 1
52
+ }
53
+ return 0
54
+ }
55
+
56
+ // Mention is the "@" token the cursor sits in; offsets are UTF-16 code units, the unit clients
57
+ // count text in.
58
+ type Mention struct {
59
+ Start, End int
60
+ Query string
61
+ }
62
+
63
+ func isSpaceUnit(u uint16) bool {
64
+ return u < 0xd800 && isJSSpaceRune(rune(u)) || u >= 0xe000 && isJSSpaceRune(rune(u))
65
+ }
66
+
67
+ func isJSSpaceRune(r rune) bool { return mapperIsSpace(r) }
68
+
69
+ // FindMention finds the mention the cursor is in, if any. An "@" only opens one at the start of
70
+ // the text or after whitespace (otherwise every email address and decorator opens the picker), and
71
+ // the cursor must not have moved past the mention's end.
72
+ func FindMention(text string, offset int) (Mention, bool) {
73
+ units := utf16.Encode([]rune(text))
74
+ clamped := offset
75
+ if clamped < 0 {
76
+ clamped = 0
77
+ }
78
+ if clamped > len(units) {
79
+ clamped = len(units)
80
+ }
81
+ for index := clamped; index > 0; index-- {
82
+ char := units[index-1]
83
+ if isSpaceUnit(char) {
84
+ return Mention{}, false
85
+ }
86
+ if char == '@' {
87
+ if index >= 2 && !isSpaceUnit(units[index-2]) {
88
+ return Mention{}, false
89
+ }
90
+ return Mention{Start: index - 1, End: clamped, Query: string(utf16.Decode(units[index:clamped]))}, true
91
+ }
92
+ }
93
+ return Mention{}, false
94
+ }
95
+
96
+ // CompletionServiceOptions configure a [CompletionService].
97
+ type CompletionServiceOptions struct {
98
+ // WorkingDirectoryFor is the directory a chat's mentions resolve against ("" when unknown).
99
+ WorkingDirectoryFor func(chat string) string
100
+ // MaxItems caps the result (default 50).
101
+ MaxItems int
102
+ }
103
+
104
+ // CompletionService implements host.CompletionHandler.
105
+ type CompletionService struct{ opts CompletionServiceOptions }
106
+
107
+ // NewCompletionService creates the service.
108
+ func NewCompletionService(opts CompletionServiceOptions) *CompletionService {
109
+ return &CompletionService{opts: opts}
110
+ }
111
+
112
+ type candidate struct {
113
+ absolute, relative string
114
+ rank int
115
+ }
116
+
117
+ // localeLess approximates String.localeCompare for path tie-breaking: case-insensitive first, then
118
+ // case-sensitive (ICU collation differs from this on punctuation; see PORT.md).
119
+ func localeLess(a, b string) bool {
120
+ la, lb := strings.ToLower(a), strings.ToLower(b)
121
+ if la != lb {
122
+ return la < lb
123
+ }
124
+ return a < b
125
+ }
126
+
127
+ // Complete implements host.CompletionHandler.
128
+ func (s *CompletionService) Complete(_ context.Context, p ahptypes.CompletionsParams) (ahptypes.CompletionsResult, error) {
129
+ empty := ahptypes.CompletionsResult{Items: []ahptypes.CompletionItem{}}
130
+ if p.Kind != ahptypes.CompletionItemKindUserMessage {
131
+ return empty, nil
132
+ }
133
+ workingDirectory := s.opts.WorkingDirectoryFor(p.Channel)
134
+ if workingDirectory == "" {
135
+ return empty, nil
136
+ }
137
+ mention, ok := FindMention(p.Text, int(p.Offset))
138
+ if !ok {
139
+ return empty, nil
140
+ }
141
+ query := strings.ToLower(strings.TrimPrefix(strings.ReplaceAll(mention.Query, `\`, "/"), "./"))
142
+
143
+ var candidates []candidate
144
+ scanned := 0
145
+ errScanLimit := fs.SkipAll
146
+ err := filepath.WalkDir(workingDirectory, func(path string, d fs.DirEntry, err error) error {
147
+ if err != nil {
148
+ if path == workingDirectory {
149
+ return err
150
+ }
151
+ return nil // an unreadable entry is skipped, not fatal
152
+ }
153
+ if path == workingDirectory {
154
+ return nil
155
+ }
156
+ name := d.Name()
157
+ // Like a glob, dot-entries are neither matched nor descended into.
158
+ if strings.HasPrefix(name, ".") {
159
+ if d.IsDir() {
160
+ return fs.SkipDir
161
+ }
162
+ return nil
163
+ }
164
+ if d.IsDir() && skippedDirectories[name] {
165
+ return fs.SkipDir
166
+ }
167
+ scanned++
168
+ if scanned > maxScannedEntries {
169
+ return errScanLimit
170
+ }
171
+ if d.IsDir() {
172
+ return nil
173
+ }
174
+ // Files and symbolic links are offered; sockets, devices and the like are not.
175
+ if !d.Type().IsRegular() && d.Type()&fs.ModeSymlink == 0 {
176
+ return nil
177
+ }
178
+ rel, err := filepath.Rel(workingDirectory, path)
179
+ if err != nil {
180
+ return nil
181
+ }
182
+ rel = filepath.ToSlash(rel)
183
+ if rank := score(rel, query); rank > 0 {
184
+ candidates = append(candidates, candidate{absolute: path, relative: rel, rank: rank})
185
+ }
186
+ return nil
187
+ })
188
+ if err != nil {
189
+ return empty, nil
190
+ }
191
+
192
+ sort.SliceStable(candidates, func(i, j int) bool {
193
+ if candidates[i].rank != candidates[j].rank {
194
+ return candidates[i].rank > candidates[j].rank
195
+ }
196
+ return localeLess(candidates[i].relative, candidates[j].relative)
197
+ })
198
+ max := s.opts.MaxItems
199
+ if max <= 0 {
200
+ max = maxCompletionItems
201
+ }
202
+ if len(candidates) > max {
203
+ candidates = candidates[:max]
204
+ }
205
+ start, end := int64(mention.Start), int64(mention.End)
206
+ items := make([]ahptypes.CompletionItem, 0, len(candidates))
207
+ for _, c := range candidates {
208
+ kind := "document"
209
+ items = append(items, ahptypes.CompletionItem{
210
+ InsertText: MentionTrigger + c.relative, RangeStart: &start, RangeEnd: &end,
211
+ Attachment: ahptypes.MessageAttachment{Value: &ahptypes.MessageResourceAttachment{
212
+ Type: ahptypes.MessageAttachmentKindResource, Label: filepathBase(c.relative), DisplayKind: &kind, Uri: fileURL(c.absolute),
213
+ }},
214
+ })
215
+ }
216
+ return ahptypes.CompletionsResult{Items: items}, nil
217
+ }
218
+
219
+ func filepathBase(rel string) string { return rel[strings.LastIndexByte(rel, '/')+1:] }
220
+
221
+ // fileURL is url.pathToFileURL(path).toString().
222
+ func fileURL(path string) string {
223
+ slashed := filepath.ToSlash(path)
224
+ if !strings.HasPrefix(slashed, "/") {
225
+ slashed = "/" + slashed
226
+ }
227
+ return (&url.URL{Scheme: "file", Path: slashed}).String()
228
+ }
229
+
230
+ func mapperIsSpace(r rune) bool { return mapper.IsJSSpace(r) }