hive-am 1.1.0 → 1.2.1

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 (262) hide show
  1. package/CHANGELOG.md +100 -1
  2. package/README.md +9 -1
  3. package/package.json +13 -2
  4. package/server/dist/api.js +42 -762
  5. package/server/dist/connections/channel-skill.js +13 -1
  6. package/server/dist/connections/files.js +45 -1
  7. package/server/dist/connections/manager.js +41 -3
  8. package/server/dist/connections/outbound.js +2 -1
  9. package/server/dist/connections/prompt.js +4 -0
  10. package/server/dist/connections/router.js +3 -2
  11. package/server/dist/connections/store.js +4 -43
  12. package/server/dist/connections/telegram.js +43 -3
  13. package/server/dist/cron.js +2 -1
  14. package/server/dist/db/json.js +16 -0
  15. package/server/dist/db/migrations.js +199 -0
  16. package/server/dist/db.js +98 -196
  17. package/server/dist/errors.js +11 -0
  18. package/server/dist/git/browse.js +99 -0
  19. package/server/dist/git/ops.js +136 -19
  20. package/server/dist/git/repo.js +2 -1
  21. package/server/dist/history/claude.js +21 -21
  22. package/server/dist/history/index.js +17 -9
  23. package/server/dist/history/kiro.js +17 -12
  24. package/server/dist/history/lines.js +12 -0
  25. package/server/dist/history/opencode.js +18 -1
  26. package/server/dist/http/body.js +46 -0
  27. package/server/dist/http/errors.js +21 -0
  28. package/server/dist/http/guards.js +44 -0
  29. package/server/dist/http/router.js +24 -0
  30. package/server/dist/http/routes/agents.js +124 -0
  31. package/server/dist/http/routes/channel.js +16 -0
  32. package/server/dist/http/routes/colonies.js +9 -0
  33. package/server/dist/http/routes/connections.js +36 -0
  34. package/server/dist/http/routes/fs.js +22 -0
  35. package/server/dist/http/routes/git.js +90 -0
  36. package/server/dist/http/routes/meta.js +11 -0
  37. package/server/dist/http/routes/notebook.js +34 -0
  38. package/server/dist/http/routes/orchestration.js +32 -0
  39. package/server/dist/http/routes/resources.js +68 -0
  40. package/server/dist/http/routes/schedules.js +40 -0
  41. package/server/dist/http/routes/skills.js +47 -0
  42. package/server/dist/http/routes/terminals.js +18 -0
  43. package/server/dist/http/routes/types.js +36 -0
  44. package/server/dist/http/validate.js +65 -0
  45. package/server/dist/index.js +67 -5
  46. package/server/dist/instructions.js +8 -2
  47. package/server/dist/objects/agent.js +49 -0
  48. package/server/dist/objects/database.js +451 -0
  49. package/server/dist/objects/db-history.js +79 -0
  50. package/server/dist/objects/docker-info.js +75 -0
  51. package/server/dist/objects/docker.js +285 -0
  52. package/server/dist/objects/driver-props.js +196 -0
  53. package/server/dist/objects/exec.js +16 -0
  54. package/server/dist/objects/http-history.js +44 -0
  55. package/server/dist/objects/http-script.js +644 -0
  56. package/server/dist/objects/http.js +735 -0
  57. package/server/dist/objects/index.js +445 -0
  58. package/server/dist/objects/model.js +250 -0
  59. package/server/dist/objects/runner.js +211 -0
  60. package/server/dist/objects/stats.js +106 -0
  61. package/server/dist/objects/store.js +115 -0
  62. package/server/dist/objects/supervisor.js +111 -0
  63. package/server/dist/objects/templates.js +67 -0
  64. package/server/dist/providers/claude.js +3 -13
  65. package/server/dist/providers/index.js +7 -1
  66. package/server/dist/providers/kiro.js +2 -5
  67. package/server/dist/providers/opencode-server.js +45 -0
  68. package/server/dist/providers/opencode.js +52 -9
  69. package/server/dist/providers/preamble.js +2 -0
  70. package/server/dist/providers/spawn.js +31 -3
  71. package/server/dist/runtime.js +8 -6
  72. package/server/dist/schedules.js +2 -21
  73. package/server/dist/seed.js +15 -11
  74. package/server/dist/services/agents.js +90 -0
  75. package/server/dist/services/colonies.js +38 -0
  76. package/server/dist/services/connections.js +76 -0
  77. package/server/dist/services/notebook.js +22 -0
  78. package/server/dist/services/providers.js +27 -0
  79. package/server/dist/services/schedules.js +18 -0
  80. package/server/dist/sessions-summary.js +51 -0
  81. package/server/dist/skills/defaults.js +37 -0
  82. package/server/dist/skills/notebook.js +3 -8
  83. package/server/dist/terminals.js +185 -0
  84. package/server/dist/wake.js +13 -16
  85. package/server/dist/watch.js +2 -8
  86. package/server/mcp/dispatch.mjs +70 -1
  87. package/web/.next/BUILD_ID +1 -1
  88. package/web/.next/app-build-manifest.json +146 -94
  89. package/web/.next/app-path-routes-manifest.json +6 -4
  90. package/web/.next/build-manifest.json +14 -14
  91. package/web/.next/prerender-manifest.json +67 -43
  92. package/web/.next/react-loadable-manifest.json +26 -1
  93. package/web/.next/required-server-files.json +13 -1
  94. package/web/.next/routes-manifest.json +26 -0
  95. package/web/.next/server/app/_not-found/page.js +2 -2
  96. package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  97. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  98. package/web/.next/server/app/_not-found.html +1 -1
  99. package/web/.next/server/app/_not-found.rsc +12 -11
  100. package/web/.next/server/app/agents/[id]/page.js +4 -4
  101. package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
  102. package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
  103. package/web/.next/server/app/agents/page.js +2 -2
  104. package/web/.next/server/app/agents/page.js.nft.json +1 -1
  105. package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
  106. package/web/.next/server/app/agents.html +1 -1
  107. package/web/.next/server/app/agents.rsc +14 -13
  108. package/web/.next/server/app/connections/page.js +2 -2
  109. package/web/.next/server/app/connections/page.js.nft.json +1 -1
  110. package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
  111. package/web/.next/server/app/connections.html +1 -1
  112. package/web/.next/server/app/connections.rsc +14 -13
  113. package/web/.next/server/app/index.html +1 -1
  114. package/web/.next/server/app/index.rsc +14 -13
  115. package/web/.next/server/app/page.js +2 -2
  116. package/web/.next/server/app/page.js.nft.json +1 -1
  117. package/web/.next/server/app/page_client-reference-manifest.js +1 -1
  118. package/web/.next/server/app/relations/page.js +2 -2
  119. package/web/.next/server/app/relations/page.js.nft.json +1 -1
  120. package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
  121. package/web/.next/server/app/relations.html +1 -1
  122. package/web/.next/server/app/relations.rsc +14 -13
  123. package/web/.next/server/app/resources/[id]/page.js +2 -0
  124. package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -0
  125. package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -0
  126. package/web/.next/server/app/resources/page.js +2 -0
  127. package/web/.next/server/app/resources/page.js.nft.json +1 -0
  128. package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -0
  129. package/web/.next/server/app/resources.html +1 -0
  130. package/web/.next/server/app/resources.meta +15 -0
  131. package/web/.next/server/app/resources.rsc +25 -0
  132. package/web/.next/server/app/schedules/page.js +2 -2
  133. package/web/.next/server/app/schedules/page.js.nft.json +1 -1
  134. package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  135. package/web/.next/server/app/schedules.html +1 -1
  136. package/web/.next/server/app/schedules.rsc +14 -13
  137. package/web/.next/server/app/sessions/page.js +2 -2
  138. package/web/.next/server/app/sessions/page.js.nft.json +1 -1
  139. package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  140. package/web/.next/server/app/sessions.html +1 -1
  141. package/web/.next/server/app/sessions.rsc +14 -13
  142. package/web/.next/server/app/skills/page.js +2 -2
  143. package/web/.next/server/app/skills/page.js.nft.json +1 -1
  144. package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
  145. package/web/.next/server/app/skills.html +1 -1
  146. package/web/.next/server/app/skills.rsc +14 -13
  147. package/web/.next/server/app/types/page.js +2 -2
  148. package/web/.next/server/app/types/page.js.nft.json +1 -1
  149. package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
  150. package/web/.next/server/app/types.html +1 -1
  151. package/web/.next/server/app/types.rsc +14 -13
  152. package/web/.next/server/app-paths-manifest.json +6 -4
  153. package/web/.next/server/chunks/143.js +1 -1
  154. package/web/.next/server/chunks/274.js +1 -0
  155. package/web/.next/server/chunks/306.js +1 -0
  156. package/web/.next/server/chunks/423.js +1 -0
  157. package/web/.next/server/chunks/505.js +6 -0
  158. package/web/.next/server/chunks/555.js +1 -0
  159. package/web/.next/server/chunks/612.js +1 -0
  160. package/web/.next/server/chunks/638.js +1 -0
  161. package/web/.next/server/chunks/692.js +18 -0
  162. package/web/.next/server/chunks/716.js +2 -0
  163. package/web/.next/server/chunks/790.js +11 -0
  164. package/web/.next/server/chunks/802.js +1 -0
  165. package/web/.next/server/chunks/890.js +1 -1
  166. package/web/.next/server/chunks/919.js +1 -0
  167. package/web/.next/server/chunks/950.js +6 -0
  168. package/web/.next/server/middleware-build-manifest.js +1 -1
  169. package/web/.next/server/middleware-react-loadable-manifest.js +1 -1
  170. package/web/.next/server/next-font-manifest.js +1 -1
  171. package/web/.next/server/next-font-manifest.json +1 -1
  172. package/web/.next/server/pages/404.html +1 -1
  173. package/web/.next/server/pages/500.html +1 -1
  174. package/web/.next/server/server-reference-manifest.json +1 -1
  175. package/web/.next/static/Bz7onClw1BajMUCwqpU8o/_buildManifest.js +1 -0
  176. package/web/.next/static/chunks/0937d497-114a2a3de515565e.js +1 -0
  177. package/web/.next/static/chunks/176-aeaf907a6ea5627d.js +1 -0
  178. package/web/.next/static/chunks/291-efae53ea5f7ee92d.js +1 -0
  179. package/web/.next/static/chunks/2b9ff8ee-969e65cc354b2150.js +1 -0
  180. package/web/.next/static/chunks/30-9adcbe92b15a6815.js +1 -0
  181. package/web/.next/static/chunks/316-26da9cc233ba7557.js +1 -0
  182. package/web/.next/static/chunks/350-56599dc3bf06066d.js +1 -0
  183. package/web/.next/static/chunks/486-13e08542f3a1cbac.js +1 -0
  184. package/web/.next/static/chunks/491-6b4e6201f5110dff.js +1 -0
  185. package/web/.next/static/chunks/517-ccaf8cc5c733cbd8.js +1 -0
  186. package/web/.next/static/chunks/545.bfbd92b7632c09de.js +1 -0
  187. package/web/.next/static/chunks/{805-d946ebedb7e85f73.js → 562-7c2e33d838e869bf.js} +1 -1
  188. package/web/.next/static/chunks/581-3ad5c4343dad3f50.js +1 -0
  189. package/web/.next/static/chunks/586-f7017d585a84738d.js +1 -0
  190. package/web/.next/static/chunks/657.b4c39e676c3e20dc.js +1 -0
  191. package/web/.next/static/chunks/701.6abcaf5e60463eef.js +6 -0
  192. package/web/.next/static/chunks/705-c7e75806f77b880c.js +1 -0
  193. package/web/.next/static/chunks/706-fa2be0f10ffebe6a.js +1 -0
  194. package/web/.next/static/chunks/837-33c7480524f48577.js +1 -0
  195. package/web/.next/static/chunks/839-4aefe6e71be6e1ef.js +1 -0
  196. package/web/.next/static/chunks/844-fe55292e0701f436.js +1 -0
  197. package/web/.next/static/chunks/89-b194fcffe5120c44.js +1 -0
  198. package/web/.next/static/chunks/97.594543c2196de524.js +1 -0
  199. package/web/.next/static/chunks/app/_not-found/page-238a7b80bda6b4ae.js +1 -0
  200. package/web/.next/static/chunks/app/agents/[id]/page-3faf40800981a736.js +1 -0
  201. package/web/.next/static/chunks/app/agents/page-f4c4c80f955f3967.js +1 -0
  202. package/web/.next/static/chunks/app/connections/page-0936beaf02d863c6.js +1 -0
  203. package/web/.next/static/chunks/app/layout-99620746d028dda8.js +1 -0
  204. package/web/.next/static/chunks/app/page-a7f292117b7d76bf.js +1 -0
  205. package/web/.next/static/chunks/app/relations/page-fba25393c8688714.js +1 -0
  206. package/web/.next/static/chunks/app/resources/[id]/page-d110d89bb8b5fca7.js +1 -0
  207. package/web/.next/static/chunks/app/resources/page-bbd0b450d3f3131d.js +1 -0
  208. package/web/.next/static/chunks/app/schedules/page-a1124397fc760413.js +1 -0
  209. package/web/.next/static/chunks/app/sessions/page-0e322811b0cde724.js +1 -0
  210. package/web/.next/static/chunks/app/skills/page-22ea0ef89d9f7a13.js +1 -0
  211. package/web/.next/static/chunks/app/types/page-18c4e1db2724d41d.js +1 -0
  212. package/web/.next/static/chunks/fcafe1ac.366e4950d3b0d4b7.js +18 -0
  213. package/web/.next/static/chunks/framework-bbac72adbbe2d328.js +1 -0
  214. package/web/.next/static/chunks/main-app-6086068e5fb5eacd.js +1 -0
  215. package/web/.next/static/chunks/main-c15f3fee674e0303.js +1 -0
  216. package/web/.next/static/chunks/pages/_app-d1670a2c6ce392fc.js +1 -0
  217. package/web/.next/static/chunks/pages/_error-95aac014d202fa7a.js +1 -0
  218. package/web/.next/static/chunks/webpack-ef515e41a83a16ac.js +1 -0
  219. package/web/.next/static/css/945795da867f5250.css +1 -0
  220. package/web/.next/static/css/b7a03c62468f4263.css +1 -0
  221. package/web/.next/trace +29 -23
  222. package/web/.next/types/app/resources/[id]/page.ts +84 -0
  223. package/web/.next/types/app/resources/page.ts +84 -0
  224. package/web/.next/types/routes.d.ts +6 -2
  225. package/web/.next/types/validator.ts +18 -0
  226. package/web/next.config.mjs +4 -0
  227. package/web/.next/server/chunks/247.js +0 -1
  228. package/web/.next/server/chunks/484.js +0 -11
  229. package/web/.next/server/chunks/851.js +0 -1
  230. package/web/.next/server/chunks/932.js +0 -2
  231. package/web/.next/static/_z2hBOHhwiR5ytdI_8alh/_buildManifest.js +0 -1
  232. package/web/.next/static/chunks/0937d497-44abb0f9355ace92.js +0 -1
  233. package/web/.next/static/chunks/2b9ff8ee-c3b61ac1e608c57d.js +0 -1
  234. package/web/.next/static/chunks/463-6ae35cdefdc59169.js +0 -1
  235. package/web/.next/static/chunks/493-50d2adf9fd5ec680.js +0 -1
  236. package/web/.next/static/chunks/517-eb26c72f6059aa26.js +0 -1
  237. package/web/.next/static/chunks/589-8bf536f331de4269.js +0 -1
  238. package/web/.next/static/chunks/657.f1e03b7b65eba1d9.js +0 -1
  239. package/web/.next/static/chunks/687-3a4205f51be38602.js +0 -1
  240. package/web/.next/static/chunks/837-fd7623601dc78ed8.js +0 -1
  241. package/web/.next/static/chunks/850-318f32484989dd59.js +0 -1
  242. package/web/.next/static/chunks/89-ace2e1aa63babfdf.js +0 -1
  243. package/web/.next/static/chunks/951-e250b541b67da7ce.js +0 -1
  244. package/web/.next/static/chunks/app/_not-found/page-bbd54d7b9a55d7ae.js +0 -1
  245. package/web/.next/static/chunks/app/agents/[id]/page-a8b7a43d2c18483f.js +0 -1
  246. package/web/.next/static/chunks/app/agents/page-e9b17f1c3439ef98.js +0 -1
  247. package/web/.next/static/chunks/app/connections/page-477f36fcd5b13d78.js +0 -1
  248. package/web/.next/static/chunks/app/layout-a2d38e3de5911b1b.js +0 -1
  249. package/web/.next/static/chunks/app/page-6fc87fc5a128a204.js +0 -1
  250. package/web/.next/static/chunks/app/relations/page-0355a93a8eb67989.js +0 -1
  251. package/web/.next/static/chunks/app/schedules/page-39befd15b3b20586.js +0 -1
  252. package/web/.next/static/chunks/app/sessions/page-beae9a74a07d69e0.js +0 -1
  253. package/web/.next/static/chunks/app/skills/page-4f839ac913e26d32.js +0 -1
  254. package/web/.next/static/chunks/app/types/page-4f6b99af3232f861.js +0 -1
  255. package/web/.next/static/chunks/framework-0e68884e76d9a8d3.js +0 -1
  256. package/web/.next/static/chunks/main-app-d6f73ccd41f325c5.js +0 -1
  257. package/web/.next/static/chunks/main-cd22509db15764e2.js +0 -1
  258. package/web/.next/static/chunks/pages/_app-0f75d689c738a6fc.js +0 -1
  259. package/web/.next/static/chunks/pages/_error-d481f6d49fb4455d.js +0 -1
  260. package/web/.next/static/chunks/webpack-d40de72e52f65f79.js +0 -1
  261. package/web/.next/static/css/da22f0402b1c40ae.css +0 -1
  262. /package/web/.next/static/{_z2hBOHhwiR5ytdI_8alh → Bz7onClw1BajMUCwqpU8o}/_ssgManifest.js +0 -0
@@ -1,13 +1,14 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { writeFile } from 'node:fs/promises';
3
3
  import { findRepo, gitEnv, hasHead, isPathError, parseNumstat, PathError, repoState, safePath } from './repo.js';
4
+ import { DomainError } from '../errors.js';
4
5
  /**
5
6
  * Git actions for the explorer: history (reads) and commit / pull / push / fetch / branches (writes).
6
7
  * Same rules as git.ts: `execFile` with no shell, user input only ever as an argument value (messages after -m,
7
8
  * paths after `--`, branch names validated), never `--force`, and no command that can prompt for credentials.
8
9
  */
9
10
  export { isPathError };
10
- export class GitOpError extends Error {
11
+ export class GitOpError extends DomainError {
11
12
  }
12
13
  const LOCAL_TIMEOUT = 30_000;
13
14
  const NET_TIMEOUT = 120_000;
@@ -54,41 +55,74 @@ export async function exclusive(root, fn) {
54
55
  locks.delete(root);
55
56
  }
56
57
  }
57
- const SHA = /^[0-9a-f]{4,40}$/i;
58
+ export const SHA = /^[0-9a-f]{4,40}$/i;
58
59
  export async function validBranch(root, name) {
59
60
  if (!name || name.startsWith('-') || name.includes('\0'))
60
61
  throw new GitOpError('Invalid branch name');
61
62
  await run(root, ['check-ref-format', '--branch', name]).catch(() => { throw new GitOpError(`"${name}" is not a valid branch name`); });
62
63
  }
63
- export async function gitLog(cwd, skip = 0) {
64
+ export async function gitLog(cwd, skip = 0, f = {}) {
64
65
  const { root, scope } = await repoOf(cwd);
65
66
  if (!(await hasHead(root)))
66
67
  return { commits: [], hasMore: false };
67
- const { out } = await run(root, ['log', `--skip=${Math.max(0, Math.floor(skip))}`, `--max-count=${LOG_PAGE + 1}`, '--decorate=short',
68
- '--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%D%x1f%P%x1e', '--', scope || '.']);
68
+ const q = (f.q ?? '').trim().slice(0, 200);
69
+ const args = ['log', `--skip=${Math.max(0, Math.floor(skip))}`, `--max-count=${LOG_PAGE + 1}`, '--decorate=short', '--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%D%x1f%P%x1e'];
70
+ if (f.ref === 'all')
71
+ args.push('--all');
72
+ else if (f.ref && f.ref !== 'HEAD')
73
+ args.push(await resolveRef(root, f.ref));
74
+ if (q) {
75
+ // A hash (or the start of one) finds that commit directly.
76
+ if (/^[0-9a-f]{7,40}$/i.test(q) && (!f.by || f.by === 'message')) {
77
+ const hit = (await run(root, ['rev-parse', '--verify', '--quiet', `${q}^{commit}`], { okCodes: [1] })).out.trim();
78
+ if (SHA.test(hit))
79
+ return gitLogOne(root, hit);
80
+ }
81
+ if (f.by === 'author')
82
+ args.push('--regexp-ignore-case', '--fixed-strings', `--author=${q}`);
83
+ else if (f.by === 'content')
84
+ args.push('--regexp-ignore-case', '-S', q);
85
+ else
86
+ args.push('--regexp-ignore-case', '--fixed-strings', `--grep=${q}`);
87
+ }
88
+ let spec = scope || '.';
89
+ if (f.path) {
90
+ if (f.path.includes('\0') || f.path.startsWith('/') || f.path.split('/').includes('..'))
91
+ throw new PathError('Invalid path');
92
+ args.push('--follow');
93
+ spec = f.path;
94
+ }
95
+ const { out } = await run(root, [...args, '--', spec]);
96
+ return { ...parseLog(out) };
97
+ }
98
+ function parseLog(out) {
69
99
  const all = out.split('\x1e').map((r) => r.trim()).filter(Boolean).map((r) => {
70
100
  const [sha, short, author, date, subject, refs, parents] = r.split('\x1f');
71
101
  return { sha, short, author, date, subject, refs: (refs ?? '').split(', ').map((x) => x.replace(/^HEAD -> /, '').trim()).filter((x) => x && x !== 'HEAD'), merge: (parents ?? '').trim().split(' ').filter(Boolean).length > 1 };
72
102
  });
73
103
  return { commits: all.slice(0, LOG_PAGE), hasMore: all.length > LOG_PAGE };
74
104
  }
105
+ async function gitLogOne(root, sha) {
106
+ const { out } = await run(root, ['log', '-1', '--decorate=short', '--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%D%x1f%P%x1e', sha]);
107
+ return { ...parseLog(out), hasMore: false };
108
+ }
109
+ /** A branch, tag, remote branch, commit or HEAD~N from the browser, resolved to the commit it names (never an option, never a path). */
110
+ export async function resolveRef(root, ref) {
111
+ if (!ref || ref.length > 200 || !/^[\w][\w./@+~^-]*$/.test(ref) || ref.includes('..'))
112
+ throw new PathError('Invalid reference');
113
+ const r = await run(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { okCodes: [1] });
114
+ const sha = r.out.trim();
115
+ if (!SHA.test(sha))
116
+ throw new PathError(`"${ref}" was not found`);
117
+ return sha;
118
+ }
75
119
  const isMergeCommit = async (root, sha) => (await run(root, ['rev-list', '--parents', '-n', '1', sha])).out.trim().split(' ').length > 2;
76
120
  const MAX_COMMIT_FILES = 500;
77
- export async function gitCommitDetail(cwd, sha) {
78
- if (!SHA.test(sha))
79
- throw new PathError('Invalid commit');
80
- const { root } = await repoOf(cwd);
81
- const isMerge = await isMergeCommit(root, sha);
82
- // A merge commit has no diff of its own; show what it brought in relative to the branch it was made on (first parent).
83
- const range = isMerge ? [`${sha}^1`, sha] : ['--root', sha];
84
- const [meta, names, nums] = await Promise.all([
85
- run(root, ['show', '-s', '--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%B', sha]),
86
- run(root, ['diff-tree', '-r', '-M', '--name-status', '-z', '--no-commit-id', ...range]),
87
- run(root, ['diff-tree', '-r', '-M', '--histogram', '--numstat', '-z', '--no-commit-id', ...range]),
88
- ]);
89
- const counts = parseNumstat(nums.out);
121
+ /** The files of a `diff-tree --name-status -z` plus its `--numstat -z`, as the explorer lists them (renames and counts included). */
122
+ export function filesFromDiffTree(namesOut, numsOut) {
123
+ const counts = parseNumstat(numsOut);
90
124
  const files = [];
91
- const st = names.out.split('\0');
125
+ const st = namesOut.split('\0');
92
126
  for (let i = 0; i < st.length; i++) {
93
127
  const code = st[i];
94
128
  if (!code)
@@ -105,6 +139,21 @@ export async function gitCommitDetail(cwd, sha) {
105
139
  files.push({ path, status: k === 'A' ? 'added' : k === 'D' ? 'deleted' : k === 'T' ? 'typechange' : 'modified', additions: c?.a ?? null, deletions: c?.d ?? null });
106
140
  }
107
141
  }
142
+ return files;
143
+ }
144
+ export async function gitCommitDetail(cwd, sha) {
145
+ if (!SHA.test(sha))
146
+ throw new PathError('Invalid commit');
147
+ const { root } = await repoOf(cwd);
148
+ const isMerge = await isMergeCommit(root, sha);
149
+ // A merge commit has no diff of its own; show what it brought in relative to the branch it was made on (first parent).
150
+ const range = isMerge ? [`${sha}^1`, sha] : ['--root', sha];
151
+ const [meta, names, nums] = await Promise.all([
152
+ run(root, ['show', '-s', '--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%B', sha]),
153
+ run(root, ['diff-tree', '-r', '-M', '--name-status', '-z', '--no-commit-id', ...range]),
154
+ run(root, ['diff-tree', '-r', '-M', '--histogram', '--numstat', '-z', '--no-commit-id', ...range]),
155
+ ]);
156
+ const files = filesFromDiffTree(names.out, nums.out);
108
157
  const [h, author, email, date, ...msg] = meta.out.split('\x1f');
109
158
  return { sha: h, author, email, date, message: msg.join('\x1f').trim(), files: files.slice(0, MAX_COMMIT_FILES), truncated: files.length > MAX_COMMIT_FILES };
110
159
  }
@@ -174,6 +223,58 @@ export async function gitBranches(cwd) {
174
223
  }
175
224
  return res;
176
225
  }
226
+ const MAX_TAGS = 1000;
227
+ export async function gitTags(cwd) {
228
+ const { root } = await repoOf(cwd);
229
+ // %(*objectname) is the commit an annotated tag points to; a lightweight tag has none and %(objectname) is already the commit.
230
+ const { out } = await run(root, ['for-each-ref', '--sort=-creatordate', `--count=${MAX_TAGS + 1}`, '--format=%(refname:strip=2)%1f%(objecttype)%1f%(*objectname)%1f%(objectname)%1f%(creatordate:iso-strict)%1f%(contents:subject)', 'refs/tags']);
231
+ const all = out.split('\n').filter(Boolean).map((line) => {
232
+ const [name, type, peeled, direct, date, subject] = line.split('\x1f');
233
+ return { name, sha: peeled || direct, date, subject: subject ?? '', annotated: type === 'tag' };
234
+ });
235
+ return { tags: all.slice(0, MAX_TAGS), truncated: all.length > MAX_TAGS };
236
+ }
237
+ /** A tag name from the browser, resolved to the commit it points to. Only names that really are tags get through. */
238
+ async function tagCommit(root, tag) {
239
+ if (!tag || tag.length > 200 || tag.startsWith('-') || /[\s\0~^:?*[\\]/.test(tag))
240
+ throw new PathError('Invalid tag');
241
+ const r = await run(root, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}^{commit}`], { okCodes: [1] });
242
+ const sha = r.out.trim();
243
+ if (!SHA.test(sha))
244
+ throw new PathError(`Tag "${tag}" not found`);
245
+ return sha;
246
+ }
247
+ export async function gitTagTree(cwd, tag) {
248
+ const { root, scope } = await repoOf(cwd);
249
+ const sha = await tagCommit(root, tag);
250
+ const { out } = await run(root, ['ls-tree', '-r', '--name-only', '-z', sha, '--', scope || '.'], { raw: true });
251
+ const files = out.split('\0').filter(Boolean);
252
+ const MAX = 30_000;
253
+ return { tag, sha, files: files.slice(0, MAX), truncated: files.length > MAX };
254
+ }
255
+ const MAX_TAG_FILE = 1_000_000;
256
+ /** A file as it was at a commit (read-only, nothing is checked out). */
257
+ export async function fileAtCommit(root, sha, rel, source) {
258
+ if (!rel || rel.includes('\0') || rel.startsWith('/') || rel.split('/').includes('..'))
259
+ throw new PathError('Invalid path');
260
+ // The path must be a file of that commit (not a folder, not a submodule).
261
+ const kind = (await run(root, ['ls-tree', '-z', sha, '--', rel], { raw: true })).out.split('\0')[0] ?? '';
262
+ if (!/^100\d{3} blob /.test(kind) && !/^120000 blob /.test(kind))
263
+ throw new PathError('File not found at that version');
264
+ const size = Number((await run(root, ['cat-file', '-s', `${sha}:${rel}`])).out) || 0;
265
+ const { out } = await run(root, ['show', `${sha}:${rel}`], { raw: true });
266
+ const binary = out.slice(0, 8000).includes('\0');
267
+ return { path: rel, size, binary, truncated: !binary && out.length > MAX_TAG_FILE, content: binary ? '' : out.slice(0, MAX_TAG_FILE), source };
268
+ }
269
+ export async function gitTagFile(cwd, tag, rel) {
270
+ const { root } = await repoOf(cwd);
271
+ return fileAtCommit(root, await tagCommit(root, tag), rel, 'tag');
272
+ }
273
+ /** Any version of a file: a branch, a tag or a commit. */
274
+ export async function gitRefFile(cwd, ref, rel) {
275
+ const { root } = await repoOf(cwd);
276
+ return fileAtCommit(root, await resolveRef(root, ref), rel, 'ref');
277
+ }
177
278
  export const done = (output) => ({ ok: true, output });
178
279
  export async function gitCommitChanges(cwd, message, paths) {
179
280
  const msg = message.trim();
@@ -230,6 +331,22 @@ export async function gitSwitch(cwd, branch, create = false) {
230
331
  await validBranch(root, branch);
231
332
  return exclusive(root, async () => done((await run(root, create ? ['switch', '-c', branch] : ['switch', branch])).out || `On ${branch}`));
232
333
  }
334
+ /** Creates a branch at a tag, another branch or a commit WITHOUT switching to it: the working folder is not touched. */
335
+ export async function gitBranchCreate(cwd, branch, from) {
336
+ const { root } = await repoOf(cwd);
337
+ await validBranch(root, branch);
338
+ const sha = await resolveRef(root, from);
339
+ return exclusive(root, async () => done((await run(root, ['branch', '--', branch, sha])).out || `Created ${branch}`));
340
+ }
341
+ /** Deletes a local branch. Without `force` git refuses when it has commits that are not merged anywhere. */
342
+ export async function gitBranchDelete(cwd, branch, force = false) {
343
+ const { root } = await repoOf(cwd);
344
+ await validBranch(root, branch);
345
+ const cur = (await run(root, ['branch', '--show-current'])).out.trim();
346
+ if (cur === branch)
347
+ throw new GitOpError('You cannot delete the branch you are on. Switch to another one first.');
348
+ return exclusive(root, async () => done((await run(root, ['branch', force ? '-D' : '-d', '--', branch])).out || `Deleted ${branch}`));
349
+ }
233
350
  export async function gitMerge(cwd, branch) {
234
351
  const { root } = await repoOf(cwd);
235
352
  if (!branch || branch.startsWith('-') || branch.includes('\0'))
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { open, readdir, readFile, realpath, stat } from 'node:fs/promises';
4
4
  import { extname, isAbsolute, normalize, resolve, sep } from 'node:path';
5
+ import { DomainError } from '../errors.js';
5
6
  /**
6
7
  * Read-only git access for the explorer (writes live in gitops.ts). Everything here only *reads*: no command writes to the repository,
7
8
  * and `GIT_OPTIONAL_LOCKS=0` keeps `git status` from touching the index while an agent is working in it.
@@ -239,7 +240,7 @@ export async function gitList(cwd, rel) {
239
240
  .map((d) => ({ name: d.name, dir: d.isDirectory() })).sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1));
240
241
  return { path: rel.replace(/\/+$/, ''), entries: entries.slice(0, MAX_LISTED), truncated: entries.length > MAX_LISTED };
241
242
  }
242
- export class PathError extends Error {
243
+ export class PathError extends DomainError {
243
244
  }
244
245
  export const isPathError = (e) => e instanceof PathError;
245
246
  /** Repo-relative path → absolute path, guaranteed to be inside the agent's own folder (symlinks resolved). */
@@ -1,12 +1,15 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { estimateCost } from '../pricing.js';
5
+ import { flattenText } from '../providers/spawn.js';
6
+ import { stripInstructions } from '../providers/preamble.js';
7
+ import { jsonLines } from './lines.js';
5
8
  const ROOT = join(homedir(), '.claude', 'projects');
6
9
  /** Claude encodes the project path by replacing both "/" and "." with "-". */
7
10
  const encode = (cwd) => cwd.replace(/[/.]/g, '-');
8
11
  /** An instructions update sent in front of a message (see providers/preamble.ts) is not part of what the user wrote. */
9
- const stripPreamble = (x) => x.replace(/^<instructions[^>]*>[\s\S]*?<\/instructions>\s*/, '');
12
+ const stripPreamble = stripInstructions;
10
13
  export function claudeFile(cwd, sessionId) {
11
14
  if (!/^[\w-]+$/.test(sessionId))
12
15
  return null;
@@ -23,6 +26,19 @@ export function claudeFile(cwd, sessionId) {
23
26
  catch { /* no Claude data yet */ }
24
27
  return null;
25
28
  }
29
+ /** Changes when the conversation changes (used to cache what is computed from it). */
30
+ export function claudeSig(cwd, sessionId) {
31
+ const f = claudeFile(cwd, sessionId);
32
+ if (!f)
33
+ return null;
34
+ try {
35
+ const st = statSync(f);
36
+ return `${st.mtimeMs}:${st.size}`;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
26
42
  // Parsing a long transcript (tens of MB) takes about a second and the UI asks for it often: keep the last results while the file is unchanged.
27
43
  const cache = new Map();
28
44
  export function readClaude(cwd, sessionId) {
@@ -35,7 +51,7 @@ export function readClaude(cwd, sessionId) {
35
51
  const msgs = parseClaude(file);
36
52
  cache.delete(file);
37
53
  cache.set(file, { sig, msgs });
38
- if (cache.size > 4)
54
+ if (cache.size > 8)
39
55
  cache.delete(cache.keys().next().value);
40
56
  return msgs;
41
57
  }
@@ -44,16 +60,7 @@ function parseClaude(file) {
44
60
  const byMsgId = new Map();
45
61
  const toolBlocks = new Map();
46
62
  const toolStart = new Map();
47
- for (const line of readFileSync(file, 'utf8').split('\n')) {
48
- if (!line)
49
- continue;
50
- let e;
51
- try {
52
- e = JSON.parse(line);
53
- }
54
- catch {
55
- continue;
56
- }
63
+ for (const e of jsonLines(file)) {
57
64
  if (e.isSidechain || e.isMeta || (e.type !== 'user' && e.type !== 'assistant'))
58
65
  continue;
59
66
  const m = e.message;
@@ -73,7 +80,7 @@ function parseClaude(file) {
73
80
  if (b.type === 'tool_result') {
74
81
  const t = toolBlocks.get(b.tool_use_id);
75
82
  if (t) {
76
- t.output = flat(b.content);
83
+ t.output = flattenText(b.content);
77
84
  t.error = !!b.is_error;
78
85
  const st = toolStart.get(b.tool_use_id);
79
86
  if (st && ts)
@@ -132,10 +139,3 @@ export function claudeMeta(file) {
132
139
  const st = statSync(file);
133
140
  return { mtime: st.mtimeMs, size: st.size };
134
141
  }
135
- function flat(c) {
136
- if (typeof c === 'string')
137
- return c;
138
- if (Array.isArray(c))
139
- return c.map((x) => (typeof x === 'string' ? x : x?.text ?? '')).join('\n');
140
- return c == null ? '' : JSON.stringify(c);
141
- }
@@ -1,19 +1,27 @@
1
- import { readClaude } from './claude.js';
2
- import { readOpencode } from './opencode.js';
3
- import { readKiro } from './kiro.js';
4
- /** The conversation lives in each CLI's own store; hive-am only keeps the pointer. */
1
+ import { claudeSig, readClaude } from './claude.js';
2
+ import { opencodeSig, readOpencode } from './opencode.js';
3
+ import { kiroSig, readKiro } from './kiro.js';
4
+ export const historyReaders = {
5
+ claude: { read: readClaude, sig: claudeSig },
6
+ opencode: { read: (_cwd, id) => readOpencode(id), sig: (_cwd, id) => opencodeSig(id) },
7
+ kiro: { read: (_cwd, id) => readKiro(id), sig: (_cwd, id) => kiroSig(id) },
8
+ };
5
9
  export function readHistory(a, sessionId = a.session_id) {
6
10
  if (!sessionId)
7
11
  return [];
8
12
  try {
9
- switch (a.provider) {
10
- case 'claude': return readClaude(a.cwd, sessionId);
11
- case 'opencode': return readOpencode(sessionId);
12
- case 'kiro': return readKiro(sessionId);
13
- }
13
+ return historyReaders[a.provider].read(a.cwd, sessionId);
14
14
  }
15
15
  catch (e) {
16
16
  console.error('[history]', a.provider, sessionId, e);
17
17
  return [];
18
18
  }
19
19
  }
20
+ export function historySig(a, sessionId) {
21
+ try {
22
+ return historyReaders[a.provider].sig(a.cwd, sessionId);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
@@ -1,8 +1,22 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { stripInstructions } from '../providers/preamble.js';
5
+ import { jsonLines } from './lines.js';
4
6
  const DIR = join(homedir(), '.kiro', 'sessions', 'cli');
5
7
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ /** Changes when the conversation changes (used to cache what is computed from it). */
9
+ export function kiroSig(sessionId) {
10
+ if (!UUID.test(sessionId))
11
+ return null;
12
+ try {
13
+ const st = statSync(join(DIR, `${sessionId}.jsonl`));
14
+ return `${st.mtimeMs}:${st.size}`;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
6
20
  export function readKiro(sessionId) {
7
21
  if (!UUID.test(sessionId))
8
22
  return [];
@@ -16,16 +30,7 @@ export function readKiro(sessionId) {
16
30
  const flush = () => { if (lastAssistant && turnIdx >= 0 && turns[turnIdx])
17
31
  lastAssistant.meta = turns[turnIdx]; lastAssistant = null; };
18
32
  const tools = new Map();
19
- for (const line of readFileSync(file, 'utf8').split('\n')) {
20
- if (!line)
21
- continue;
22
- let e;
23
- try {
24
- e = JSON.parse(line);
25
- }
26
- catch {
27
- continue;
28
- }
33
+ for (const e of jsonLines(file)) {
29
34
  const d = e.data;
30
35
  if (!d)
31
36
  continue;
@@ -35,7 +40,7 @@ export function readKiro(sessionId) {
35
40
  turnIdx++;
36
41
  const text = (d.content ?? []).filter((b) => b.kind === 'text').map((b) => b.data).join('');
37
42
  if (text.trim())
38
- out.push({ id: d.message_id, role: 'user', ts, blocks: [{ type: 'text', text: text.replace(/^<instructions[^>]*>[\s\S]*?<\/instructions>\s*/, '') }] });
43
+ out.push({ id: d.message_id, role: 'user', ts, blocks: [{ type: 'text', text: stripInstructions(text) }] });
39
44
  }
40
45
  else if (e.kind === 'AssistantMessage') {
41
46
  const blocks = [];
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /** The JSON objects of a JSONL transcript, in order. A line that is cut or damaged (a CLI still writing) is skipped, not fatal. */
3
+ export function* jsonLines(file) {
4
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
5
+ if (!line)
6
+ continue;
7
+ try {
8
+ yield JSON.parse(line);
9
+ }
10
+ catch { /* partial line */ }
11
+ }
12
+ }
@@ -3,7 +3,24 @@ import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { estimateCost } from '../pricing.js';
6
+ import { stripInstructions as stripPreamble } from '../providers/preamble.js';
6
7
  const DB = join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
8
+ /** Changes when the conversation changes (used to cache what is computed from it). */
9
+ export function opencodeSig(sessionId) {
10
+ if (!existsSync(DB))
11
+ return null;
12
+ const db = new Database(DB, { readonly: true, fileMustExist: true });
13
+ try {
14
+ const r = db.prepare('SELECT COUNT(*) n, COALESCE(MAX(time_created), 0) t FROM session_message WHERE session_id=?').get(sessionId);
15
+ return `${r.n}:${r.t}`;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ finally {
21
+ db.close();
22
+ }
23
+ }
7
24
  export function readOpencode(sessionId) {
8
25
  if (!existsSync(DB))
9
26
  return [];
@@ -58,7 +75,7 @@ function stripInstructions(t) {
58
75
  let x = t.trim();
59
76
  if (x.length > 1 && x.startsWith('"') && x.endsWith('"'))
60
77
  x = x.slice(1, -1);
61
- return x.replace(/^<instructions[^>]*>[\s\S]*?<\/instructions>\s*/, '').trim();
78
+ return stripPreamble(x).trim();
62
79
  }
63
80
  /** Folder OpenCode recorded for a session, or null if unknown. Used to avoid resuming a session that belongs to another folder. */
64
81
  export function opencodeSessionDir(sessionId) {
@@ -0,0 +1,46 @@
1
+ import { UPLOAD_MAX_BYTES, UPLOAD_MAX_FILES } from '../connections/files.js';
2
+ import { bad, tooLarge } from './errors.js';
3
+ /** Notes, skills, SQL, http files and settings are text: 2 MiB is far above what any of them needs. */
4
+ export const JSON_LIMIT = 2 * 1024 * 1024;
5
+ /** Files sent from the chat travel as base64 inside the JSON (4/3 of the bytes), up to UPLOAD_MAX_FILES of UPLOAD_MAX_BYTES each. */
6
+ export const UPLOAD_LIMIT = Math.ceil(UPLOAD_MAX_FILES * UPLOAD_MAX_BYTES * 4 / 3) + JSON_LIMIT;
7
+ const mb = (n) => `${Math.round(n / 1048576)} MB`;
8
+ /** Raw bytes of the request, refusing (413) anything over `max`. Once over the limit the rest is read and dropped, so the answer can still be delivered. */
9
+ export function readRaw(req, max) {
10
+ return new Promise((resolve, reject) => {
11
+ const declared = Number(req.headers['content-length']);
12
+ const chunks = [];
13
+ let size = 0, over = false;
14
+ const refuse = () => { if (over)
15
+ return; over = true; chunks.length = 0; reject(tooLarge(`The request is too big (limit ${mb(max)})`)); };
16
+ if (Number.isFinite(declared) && declared > max) {
17
+ refuse();
18
+ req.resume();
19
+ return;
20
+ }
21
+ req.on('data', (c) => { if (over)
22
+ return; size += c.length; if (size > max)
23
+ return refuse(); chunks.push(c); });
24
+ req.on('end', () => { if (!over)
25
+ resolve(Buffer.concat(chunks)); });
26
+ req.on('error', (e) => { if (!over) {
27
+ over = true;
28
+ reject(e);
29
+ } });
30
+ });
31
+ }
32
+ export async function body(req, max = JSON_LIMIT) {
33
+ const raw = await readRaw(req, max);
34
+ if (!raw.length)
35
+ return {};
36
+ let v;
37
+ try {
38
+ v = JSON.parse(raw.toString('utf8'));
39
+ }
40
+ catch {
41
+ throw bad('Request body is not valid JSON');
42
+ }
43
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
44
+ throw bad('Request body must be a JSON object');
45
+ return v;
46
+ }
@@ -0,0 +1,21 @@
1
+ import { DomainError, STATUS_OF_KIND } from '../errors.js';
2
+ /** An error with the status the client should see. Its message is shown to the person, so it is in plain English. */
3
+ export class HttpError extends Error {
4
+ status;
5
+ constructor(status, msg) {
6
+ super(msg);
7
+ this.status = status;
8
+ }
9
+ }
10
+ export const bad = (m) => new HttpError(400, m);
11
+ export const forbidden = (m) => new HttpError(403, m);
12
+ export const notFound = (m = 'Not found') => new HttpError(404, m);
13
+ export const tooLarge = (m) => new HttpError(413, m);
14
+ /** The one place where an error becomes a status and a message. `null` means "not ours": the caller logs it and answers 500. */
15
+ export function toHttpError(e) {
16
+ if (e instanceof HttpError)
17
+ return e;
18
+ if (e instanceof DomainError)
19
+ return new HttpError(STATUS_OF_KIND[e.kind] ?? 400, e.message);
20
+ return null;
21
+ }
@@ -0,0 +1,44 @@
1
+ import { forbidden } from './errors.js';
2
+ const LOOPBACK = ['localhost', '127.0.0.1', '[::1]', '::1'];
3
+ const loop = (h) => LOOPBACK.includes(h.replace(/:\d+$/, '').toLowerCase());
4
+ const hostOf = (origin) => { try {
5
+ const h = new URL(origin).hostname;
6
+ return h === '::1' ? '[::1]' : h;
7
+ }
8
+ catch {
9
+ return null;
10
+ } };
11
+ /** The browser's origin (when it sends one) is this machine, or the same name the request was addressed to. Programs (curl, the MCP) send no origin. */
12
+ export function originIsLocal(req) {
13
+ const origin = req.headers.origin;
14
+ if (!origin)
15
+ return true;
16
+ const host = hostOf(origin);
17
+ if (host === null)
18
+ return false;
19
+ const own = String(req.headers.host ?? '').replace(/:\d+$/, '');
20
+ return loop(host) || host === own;
21
+ }
22
+ /**
23
+ * Only a page or a program of this machine: the Host must be a loopback name (a page that reached us through another name, DNS rebinding,
24
+ * is refused) and a browser's origin must be loopback too. Used by terminals (REST and WebSocket).
25
+ */
26
+ export function isLocalRequest(req) {
27
+ if (!loop(String(req.headers.host ?? '')))
28
+ return false;
29
+ const origin = req.headers.origin;
30
+ if (origin) {
31
+ const h = hostOf(origin);
32
+ if (h === null || !loop(h))
33
+ return false;
34
+ }
35
+ return true;
36
+ }
37
+ /**
38
+ * The single guard for endpoints that act on the machine. The API answers any origin (`*`, it is a local tool), so these check the
39
+ * request itself. `strict` also pins the Host (terminals); otherwise only the origin is checked, as git actions and resources always did.
40
+ */
41
+ export function requireLocal(req, message, opts = {}) {
42
+ if (!(opts.strict ? isLocalRequest(req) : originIsLocal(req)))
43
+ throw forbidden(message);
44
+ }
@@ -0,0 +1,24 @@
1
+ import { bad } from './errors.js';
2
+ /** The route table: the modules in ./routes fill it when they are imported (see api.ts for the order). */
3
+ export const routes = [];
4
+ /** `:name` matches one path segment; its decoded text reaches the handler in `params`, in order. */
5
+ export const route = (method, pattern, handler) => {
6
+ routes.push({ method, pattern, re: new RegExp('^' + pattern.replace(/:[^/]+/g, '([^/]+)') + '$'), handler });
7
+ };
8
+ /** The first route that answers this method and path, with its (decoded) parameters. A malformed %-escape is a 400. */
9
+ export function match(method, pathname) {
10
+ for (const r of routes) {
11
+ if (r.method !== method)
12
+ continue;
13
+ const m = r.re.exec(pathname);
14
+ if (!m)
15
+ continue;
16
+ try {
17
+ return { route: r, params: m.slice(1).map(decodeURIComponent) };
18
+ }
19
+ catch {
20
+ throw bad('Malformed URL');
21
+ }
22
+ }
23
+ return null;
24
+ }