@harperfast/harper 5.2.0-alpha.5 → 5.2.0-beta.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 (311) hide show
  1. package/agent/agent.ts +152 -10
  2. package/agent/bestPractices.ts +58 -0
  3. package/agent/mcpTools.ts +122 -0
  4. package/agent/operations.ts +10 -1
  5. package/agent/registryTools.ts +117 -0
  6. package/agent/session.ts +16 -7
  7. package/agent/tools/fsTools.ts +74 -27
  8. package/agent/tools/inspectorTool.ts +459 -0
  9. package/agent/toolset.ts +27 -7
  10. package/agent/types.ts +2 -0
  11. package/components/Application.ts +566 -85
  12. package/components/OptionsWatcher.ts +62 -2
  13. package/components/Scope.ts +31 -8
  14. package/components/componentLoader.ts +5 -1
  15. package/components/componentSecrets.ts +514 -55
  16. package/components/deploymentRecorder.ts +7 -6
  17. package/components/gitCredentialHelper.js +115 -0
  18. package/components/gitCredentialServer.ts +251 -0
  19. package/components/mcp/tools/application.ts +197 -23
  20. package/components/mcp/tools/operations.ts +1 -1
  21. package/components/mcp/tools/schemas/derive.ts +6 -2
  22. package/components/operations.js +21 -19
  23. package/components/operationsValidation.js +86 -21
  24. package/components/secretOperations.ts +110 -30
  25. package/config/harperConfigEnvVars.ts +90 -5
  26. package/config-root.schema.json +4 -0
  27. package/dataLayer/harperBridge/ResourceBridge.ts +7 -0
  28. package/dist/agent/agent.d.ts +24 -3
  29. package/dist/agent/agent.js +172 -10
  30. package/dist/agent/agent.js.map +1 -1
  31. package/dist/agent/bestPractices.d.ts +24 -0
  32. package/dist/agent/bestPractices.js +60 -0
  33. package/dist/agent/bestPractices.js.map +1 -0
  34. package/dist/agent/mcpTools.d.ts +24 -0
  35. package/dist/agent/mcpTools.js +115 -0
  36. package/dist/agent/mcpTools.js.map +1 -0
  37. package/dist/agent/operations.js +10 -1
  38. package/dist/agent/operations.js.map +1 -1
  39. package/dist/agent/registryTools.d.ts +45 -0
  40. package/dist/agent/registryTools.js +113 -0
  41. package/dist/agent/registryTools.js.map +1 -0
  42. package/dist/agent/session.js +16 -7
  43. package/dist/agent/session.js.map +1 -1
  44. package/dist/agent/tools/fsTools.js +70 -28
  45. package/dist/agent/tools/fsTools.js.map +1 -1
  46. package/dist/agent/tools/inspectorTool.d.ts +40 -0
  47. package/dist/agent/tools/inspectorTool.js +428 -0
  48. package/dist/agent/tools/inspectorTool.js.map +1 -0
  49. package/dist/agent/toolset.d.ts +16 -6
  50. package/dist/agent/toolset.js +17 -7
  51. package/dist/agent/toolset.js.map +1 -1
  52. package/dist/agent/types.d.ts +2 -0
  53. package/dist/components/Application.d.ts +73 -18
  54. package/dist/components/Application.js +475 -64
  55. package/dist/components/Application.js.map +1 -1
  56. package/dist/components/OptionsWatcher.d.ts +1 -1
  57. package/dist/components/OptionsWatcher.js +65 -2
  58. package/dist/components/OptionsWatcher.js.map +1 -1
  59. package/dist/components/Scope.d.ts +8 -6
  60. package/dist/components/Scope.js +22 -6
  61. package/dist/components/Scope.js.map +1 -1
  62. package/dist/components/componentLoader.js +5 -1
  63. package/dist/components/componentLoader.js.map +1 -1
  64. package/dist/components/componentSecrets.d.ts +33 -7
  65. package/dist/components/componentSecrets.js +473 -59
  66. package/dist/components/componentSecrets.js.map +1 -1
  67. package/dist/components/deploymentRecorder.d.ts +2 -2
  68. package/dist/components/deploymentRecorder.js +1 -1
  69. package/dist/components/deploymentRecorder.js.map +1 -1
  70. package/dist/components/gitCredentialHelper.d.ts +1 -0
  71. package/dist/components/gitCredentialHelper.js +113 -0
  72. package/dist/components/gitCredentialHelper.js.map +1 -0
  73. package/dist/components/gitCredentialServer.d.ts +33 -0
  74. package/dist/components/gitCredentialServer.js +236 -0
  75. package/dist/components/gitCredentialServer.js.map +1 -0
  76. package/dist/components/mcp/tools/application.d.ts +19 -0
  77. package/dist/components/mcp/tools/application.js +180 -22
  78. package/dist/components/mcp/tools/application.js.map +1 -1
  79. package/dist/components/mcp/tools/operations.d.ts +13 -0
  80. package/dist/components/mcp/tools/operations.js +1 -0
  81. package/dist/components/mcp/tools/operations.js.map +1 -1
  82. package/dist/components/mcp/tools/schemas/derive.js +6 -2
  83. package/dist/components/mcp/tools/schemas/derive.js.map +1 -1
  84. package/dist/components/operations.js +23 -21
  85. package/dist/components/operations.js.map +1 -1
  86. package/dist/components/operationsValidation.js +84 -21
  87. package/dist/components/operationsValidation.js.map +1 -1
  88. package/dist/components/secretOperations.d.ts +33 -11
  89. package/dist/components/secretOperations.js +90 -26
  90. package/dist/components/secretOperations.js.map +1 -1
  91. package/dist/config/harperConfigEnvVars.d.ts +21 -0
  92. package/dist/config/harperConfigEnvVars.js +95 -5
  93. package/dist/config/harperConfigEnvVars.js.map +1 -1
  94. package/dist/dataLayer/harperBridge/ResourceBridge.js +8 -0
  95. package/dist/dataLayer/harperBridge/ResourceBridge.js.map +1 -1
  96. package/dist/index.d.ts +4 -0
  97. package/dist/index.js +14 -1
  98. package/dist/index.js.map +1 -1
  99. package/dist/resources/Resource.d.ts +19 -0
  100. package/dist/resources/Resource.js +98 -4
  101. package/dist/resources/Resource.js.map +1 -1
  102. package/dist/resources/Table.d.ts +12 -1
  103. package/dist/resources/Table.js +269 -53
  104. package/dist/resources/Table.js.map +1 -1
  105. package/dist/resources/analytics/read.js +28 -25
  106. package/dist/resources/analytics/read.js.map +1 -1
  107. package/dist/resources/databases.js +21 -0
  108. package/dist/resources/databases.js.map +1 -1
  109. package/dist/resources/defineResource.d.ts +180 -0
  110. package/dist/resources/defineResource.js +505 -0
  111. package/dist/resources/defineResource.js.map +1 -0
  112. package/dist/resources/defineTable.d.ts +221 -0
  113. package/dist/resources/defineTable.js +227 -0
  114. package/dist/resources/defineTable.js.map +1 -0
  115. package/dist/resources/jsonSchemaTypes.d.ts +2 -0
  116. package/dist/resources/jsonSchemaTypes.js +12 -4
  117. package/dist/resources/jsonSchemaTypes.js.map +1 -1
  118. package/dist/resources/openApi.js +69 -13
  119. package/dist/resources/openApi.js.map +1 -1
  120. package/dist/resources/search.js +6 -8
  121. package/dist/resources/search.js.map +1 -1
  122. package/dist/server/graphqlQuerying.js +4 -2
  123. package/dist/server/graphqlQuerying.js.map +1 -1
  124. package/dist/server/http.d.ts +12 -0
  125. package/dist/server/http.js +35 -15
  126. package/dist/server/http.js.map +1 -1
  127. package/dist/server/serverHelpers/serverUtilities.js +8 -5
  128. package/dist/server/serverHelpers/serverUtilities.js.map +1 -1
  129. package/dist/server/serverHelpers/uwsServer.js +23 -0
  130. package/dist/server/serverHelpers/uwsServer.js.map +1 -1
  131. package/dist/sqlEngine/binder/bind.d.ts +72 -0
  132. package/dist/sqlEngine/binder/bind.js +289 -0
  133. package/dist/sqlEngine/binder/bind.js.map +1 -0
  134. package/dist/sqlEngine/config.d.ts +35 -0
  135. package/dist/sqlEngine/config.js +62 -0
  136. package/dist/sqlEngine/config.js.map +1 -0
  137. package/dist/sqlEngine/diff/differential.d.ts +23 -0
  138. package/dist/sqlEngine/diff/differential.js +90 -0
  139. package/dist/sqlEngine/diff/differential.js.map +1 -0
  140. package/dist/sqlEngine/errors.d.ts +22 -0
  141. package/dist/sqlEngine/errors.js +36 -0
  142. package/dist/sqlEngine/errors.js.map +1 -0
  143. package/dist/sqlEngine/executor/runMutation.d.ts +35 -0
  144. package/dist/sqlEngine/executor/runMutation.js +352 -0
  145. package/dist/sqlEngine/executor/runMutation.js.map +1 -0
  146. package/dist/sqlEngine/executor/runSelect.d.ts +7 -0
  147. package/dist/sqlEngine/executor/runSelect.js +14 -0
  148. package/dist/sqlEngine/executor/runSelect.js.map +1 -0
  149. package/dist/sqlEngine/expressions/compile.d.ts +24 -0
  150. package/dist/sqlEngine/expressions/compile.js +326 -0
  151. package/dist/sqlEngine/expressions/compile.js.map +1 -0
  152. package/dist/sqlEngine/functions/aggregates.d.ts +12 -0
  153. package/dist/sqlEngine/functions/aggregates.js +299 -0
  154. package/dist/sqlEngine/functions/aggregates.js.map +1 -0
  155. package/dist/sqlEngine/functions/registry.d.ts +36 -0
  156. package/dist/sqlEngine/functions/registry.js +33 -0
  157. package/dist/sqlEngine/functions/registry.js.map +1 -0
  158. package/dist/sqlEngine/functions/standard.d.ts +9 -0
  159. package/dist/sqlEngine/functions/standard.js +86 -0
  160. package/dist/sqlEngine/functions/standard.js.map +1 -0
  161. package/dist/sqlEngine/index.d.ts +29 -0
  162. package/dist/sqlEngine/index.js +62 -0
  163. package/dist/sqlEngine/index.js.map +1 -0
  164. package/dist/sqlEngine/logical/build.d.ts +20 -0
  165. package/dist/sqlEngine/logical/build.js +326 -0
  166. package/dist/sqlEngine/logical/build.js.map +1 -0
  167. package/dist/sqlEngine/logical/op.d.ts +105 -0
  168. package/dist/sqlEngine/logical/op.js +10 -0
  169. package/dist/sqlEngine/logical/op.js.map +1 -0
  170. package/dist/sqlEngine/optimizer/joinAnalysis.d.ts +37 -0
  171. package/dist/sqlEngine/optimizer/joinAnalysis.js +144 -0
  172. package/dist/sqlEngine/optimizer/joinAnalysis.js.map +1 -0
  173. package/dist/sqlEngine/optimizer/optimize.d.ts +13 -0
  174. package/dist/sqlEngine/optimizer/optimize.js +53 -0
  175. package/dist/sqlEngine/optimizer/optimize.js.map +1 -0
  176. package/dist/sqlEngine/optimizer/ruleEngine.d.ts +7 -0
  177. package/dist/sqlEngine/optimizer/ruleEngine.js +26 -0
  178. package/dist/sqlEngine/optimizer/ruleEngine.js.map +1 -0
  179. package/dist/sqlEngine/optimizer/rules/limitPushdown.d.ts +19 -0
  180. package/dist/sqlEngine/optimizer/rules/limitPushdown.js +55 -0
  181. package/dist/sqlEngine/optimizer/rules/limitPushdown.js.map +1 -0
  182. package/dist/sqlEngine/optimizer/rules/planJoins.d.ts +19 -0
  183. package/dist/sqlEngine/optimizer/rules/planJoins.js +62 -0
  184. package/dist/sqlEngine/optimizer/rules/planJoins.js.map +1 -0
  185. package/dist/sqlEngine/optimizer/rules/predicateNormalize.d.ts +18 -0
  186. package/dist/sqlEngine/optimizer/rules/predicateNormalize.js +80 -0
  187. package/dist/sqlEngine/optimizer/rules/predicateNormalize.js.map +1 -0
  188. package/dist/sqlEngine/optimizer/rules/predicatePushdown.d.ts +11 -0
  189. package/dist/sqlEngine/optimizer/rules/predicatePushdown.js +48 -0
  190. package/dist/sqlEngine/optimizer/rules/predicatePushdown.js.map +1 -0
  191. package/dist/sqlEngine/optimizer/rules/projectionPushdown.d.ts +23 -0
  192. package/dist/sqlEngine/optimizer/rules/projectionPushdown.js +160 -0
  193. package/dist/sqlEngine/optimizer/rules/projectionPushdown.js.map +1 -0
  194. package/dist/sqlEngine/optimizer/rules/sortFromIndex.d.ts +14 -0
  195. package/dist/sqlEngine/optimizer/rules/sortFromIndex.js +49 -0
  196. package/dist/sqlEngine/optimizer/rules/sortFromIndex.js.map +1 -0
  197. package/dist/sqlEngine/optimizer/rules/validateScannable.d.ts +15 -0
  198. package/dist/sqlEngine/optimizer/rules/validateScannable.js +60 -0
  199. package/dist/sqlEngine/optimizer/rules/validateScannable.js.map +1 -0
  200. package/dist/sqlEngine/optimizer/whereToConditions.d.ts +85 -0
  201. package/dist/sqlEngine/optimizer/whereToConditions.js +484 -0
  202. package/dist/sqlEngine/optimizer/whereToConditions.js.map +1 -0
  203. package/dist/sqlEngine/parser/ast.d.ts +139 -0
  204. package/dist/sqlEngine/parser/ast.js +10 -0
  205. package/dist/sqlEngine/parser/ast.js.map +1 -0
  206. package/dist/sqlEngine/parser/normalizer.d.ts +28 -0
  207. package/dist/sqlEngine/parser/normalizer.js +413 -0
  208. package/dist/sqlEngine/parser/normalizer.js.map +1 -0
  209. package/dist/sqlEngine/parser/parse.d.ts +14 -0
  210. package/dist/sqlEngine/parser/parse.js +20 -0
  211. package/dist/sqlEngine/parser/parse.js.map +1 -0
  212. package/dist/sqlEngine/physical/PhysicalDistinct.d.ts +9 -0
  213. package/dist/sqlEngine/physical/PhysicalDistinct.js +29 -0
  214. package/dist/sqlEngine/physical/PhysicalDistinct.js.map +1 -0
  215. package/dist/sqlEngine/physical/PhysicalFilter.d.ts +10 -0
  216. package/dist/sqlEngine/physical/PhysicalFilter.js +25 -0
  217. package/dist/sqlEngine/physical/PhysicalFilter.js.map +1 -0
  218. package/dist/sqlEngine/physical/PhysicalHashAggregate.d.ts +27 -0
  219. package/dist/sqlEngine/physical/PhysicalHashAggregate.js +100 -0
  220. package/dist/sqlEngine/physical/PhysicalHashAggregate.js.map +1 -0
  221. package/dist/sqlEngine/physical/PhysicalHashJoin.d.ts +29 -0
  222. package/dist/sqlEngine/physical/PhysicalHashJoin.js +93 -0
  223. package/dist/sqlEngine/physical/PhysicalHashJoin.js.map +1 -0
  224. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.d.ts +36 -0
  225. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.js +78 -0
  226. package/dist/sqlEngine/physical/PhysicalIndexNestedLoopJoin.js.map +1 -0
  227. package/dist/sqlEngine/physical/PhysicalIndexScan.d.ts +22 -0
  228. package/dist/sqlEngine/physical/PhysicalIndexScan.js +77 -0
  229. package/dist/sqlEngine/physical/PhysicalIndexScan.js.map +1 -0
  230. package/dist/sqlEngine/physical/PhysicalLimit.d.ts +5 -0
  231. package/dist/sqlEngine/physical/PhysicalLimit.js +28 -0
  232. package/dist/sqlEngine/physical/PhysicalLimit.js.map +1 -0
  233. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.d.ts +20 -0
  234. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.js +51 -0
  235. package/dist/sqlEngine/physical/PhysicalNestedLoopJoin.js.map +1 -0
  236. package/dist/sqlEngine/physical/PhysicalProject.d.ts +18 -0
  237. package/dist/sqlEngine/physical/PhysicalProject.js +117 -0
  238. package/dist/sqlEngine/physical/PhysicalProject.js.map +1 -0
  239. package/dist/sqlEngine/physical/PhysicalQualify.d.ts +13 -0
  240. package/dist/sqlEngine/physical/PhysicalQualify.js +29 -0
  241. package/dist/sqlEngine/physical/PhysicalQualify.js.map +1 -0
  242. package/dist/sqlEngine/physical/PhysicalSort.d.ts +12 -0
  243. package/dist/sqlEngine/physical/PhysicalSort.js +59 -0
  244. package/dist/sqlEngine/physical/PhysicalSort.js.map +1 -0
  245. package/dist/sqlEngine/physical/op.d.ts +11 -0
  246. package/dist/sqlEngine/physical/op.js +9 -0
  247. package/dist/sqlEngine/physical/op.js.map +1 -0
  248. package/dist/sqlEngine/physical/plan.d.ts +23 -0
  249. package/dist/sqlEngine/physical/plan.js +162 -0
  250. package/dist/sqlEngine/physical/plan.js.map +1 -0
  251. package/dist/sqlEngine/router.d.ts +26 -0
  252. package/dist/sqlEngine/router.js +48 -0
  253. package/dist/sqlEngine/router.js.map +1 -0
  254. package/dist/sqlEngine/types.d.ts +37 -0
  255. package/dist/sqlEngine/types.js +13 -0
  256. package/dist/sqlEngine/types.js.map +1 -0
  257. package/dist/sqlTranslator/index.js +7 -1
  258. package/dist/sqlTranslator/index.js.map +1 -1
  259. package/dist/utility/errors/hdbError.d.ts +21 -0
  260. package/dist/utility/errors/hdbError.js +23 -1
  261. package/dist/utility/errors/hdbError.js.map +1 -1
  262. package/dist/validation/configValidator.js +22 -6
  263. package/dist/validation/configValidator.js.map +1 -1
  264. package/dist/validation/deleteValidator.js +10 -2
  265. package/dist/validation/deleteValidator.js.map +1 -1
  266. package/index.ts +33 -0
  267. package/npm-shrinkwrap.json +9197 -15066
  268. package/package.json +3 -1
  269. package/resources/DESIGN.md +43 -15
  270. package/resources/Resource.ts +104 -4
  271. package/resources/Table.ts +298 -74
  272. package/resources/analytics/read.ts +30 -25
  273. package/resources/databases.ts +24 -0
  274. package/resources/defineResource.ts +651 -0
  275. package/resources/defineTable.ts +407 -0
  276. package/resources/jsonSchemaTypes.ts +12 -4
  277. package/resources/openApi.ts +68 -16
  278. package/resources/search.ts +5 -8
  279. package/server/graphqlQuerying.ts +4 -2
  280. package/server/http.ts +40 -16
  281. package/server/serverHelpers/serverUtilities.ts +19 -6
  282. package/server/serverHelpers/uwsServer.ts +24 -0
  283. package/sqlTranslator/index.ts +16 -6
  284. package/studio/web/assets/{Chat-Cv_2paZE.js → Chat-BZks8dVF.js} +2 -2
  285. package/studio/web/assets/{Chat-Cv_2paZE.js.map → Chat-BZks8dVF.js.map} +1 -1
  286. package/studio/web/assets/{FloatingChat-CPyPIcVR.js → FloatingChat-Dic8paVO.js} +4 -4
  287. package/studio/web/assets/{FloatingChat-CPyPIcVR.js.map → FloatingChat-Dic8paVO.js.map} +1 -1
  288. package/studio/web/assets/{applications-C0jT2vdZ.js → applications-uOXkeUIN.js} +2 -2
  289. package/studio/web/assets/{applications-C0jT2vdZ.js.map → applications-uOXkeUIN.js.map} +1 -1
  290. package/studio/web/assets/{index-D_GKOkhn.js → index-i-2wrKhv.js} +6 -6
  291. package/studio/web/assets/{index-D_GKOkhn.js.map → index-i-2wrKhv.js.map} +1 -1
  292. package/studio/web/assets/{index.lazy-CAmCIA65.js → index.lazy-Csk8eCoB.js} +4 -4
  293. package/studio/web/assets/{index.lazy-CAmCIA65.js.map → index.lazy-Csk8eCoB.js.map} +1 -1
  294. package/studio/web/assets/{profile-CaF-4aZe.js → profile-Sb3mGDl6.js} +2 -2
  295. package/studio/web/assets/{profile-CaF-4aZe.js.map → profile-Sb3mGDl6.js.map} +1 -1
  296. package/studio/web/assets/{setComponentFile-DLW1DHCt.js → setComponentFile-BgZcaPJ2.js} +2 -2
  297. package/studio/web/assets/{setComponentFile-DLW1DHCt.js.map → setComponentFile-BgZcaPJ2.js.map} +1 -1
  298. package/studio/web/assets/{setup-TGCErIhq.js → setup-DKtlLgmT.js} +2 -2
  299. package/studio/web/assets/{setup-TGCErIhq.js.map → setup-DKtlLgmT.js.map} +1 -1
  300. package/studio/web/assets/{status-DG0Maoao.js → status-B45iLeug.js} +2 -2
  301. package/studio/web/assets/{status-DG0Maoao.js.map → status-B45iLeug.js.map} +1 -1
  302. package/studio/web/assets/{swagger-ui-react-8T4SgQ1m.js → swagger-ui-react-Csu4026e.js} +2 -2
  303. package/studio/web/assets/{swagger-ui-react-8T4SgQ1m.js.map → swagger-ui-react-Csu4026e.js.map} +1 -1
  304. package/studio/web/assets/{tsMode-DJ6Sl24Q.js → tsMode-DVgxUr_l.js} +2 -2
  305. package/studio/web/assets/{tsMode-DJ6Sl24Q.js.map → tsMode-DVgxUr_l.js.map} +1 -1
  306. package/studio/web/assets/{useEntityRestURL-JO2mfWTQ.js → useEntityRestURL-yfDQMV1f.js} +2 -2
  307. package/studio/web/assets/{useEntityRestURL-JO2mfWTQ.js.map → useEntityRestURL-yfDQMV1f.js.map} +1 -1
  308. package/studio/web/index.html +1 -1
  309. package/utility/errors/hdbError.ts +32 -0
  310. package/validation/configValidator.ts +23 -6
  311. package/validation/deleteValidator.ts +11 -2
@@ -23,13 +23,49 @@ const DEFAULT_TAIL_LINES = 200;
23
23
  const TAIL_READ_BYTES = 1 * 1024 * 1024; // 1 MiB — enough for thousands of normal log lines
24
24
 
25
25
  type Access = 'read' | 'write';
26
+ type Scope = 'components' | 'logs' | 'config';
26
27
 
27
- async function resolveScoped(scopes: AgentScopes, path: string, access: Access): Promise<string> {
28
- const absolute = resolve(path);
29
- const candidates = [scopes.componentsRoot];
30
- if (access === 'read') {
31
- candidates.push(scopes.logDir, scopes.configDir);
28
+ // The three filesystem scopes the agent can address. Exposed to the model as a `root` enum on the
29
+ // fs tools so it names the scope explicitly instead of guessing an absolute path — which is what led
30
+ // the model to invent literal `componentsRoot/…` prefixes. `components` is the only writable scope.
31
+ const SCOPES: Scope[] = ['components', 'logs', 'config'];
32
+ const SCOPE_DESCRIPTION =
33
+ "Which scope the `path` is relative to: 'components' (app source directory, the only writable scope), " +
34
+ "'logs' (read-only), or 'config' (read-only). Defaults to 'components'.";
35
+
36
+ function scopeRoot(scopes: AgentScopes, scope: Scope): string {
37
+ switch (scope) {
38
+ case 'components':
39
+ return scopes.componentsRoot;
40
+ case 'logs':
41
+ return scopes.logDir;
42
+ case 'config':
43
+ return scopes.configDir;
44
+ default:
45
+ throw new Error(`Unknown fs root '${scope}'. Use one of: ${SCOPES.join(', ')}.`);
46
+ }
47
+ }
48
+
49
+ /** Coerce/validate a tool's `root` argument, defaulting to the writable components scope. */
50
+ function normalizeScope(root: unknown): Scope {
51
+ if (root == null) return 'components';
52
+ if (typeof root !== 'string' || !SCOPES.includes(root as Scope))
53
+ throw new Error(`Invalid fs root '${String(root)}'. Use one of: ${SCOPES.join(', ')}.`);
54
+ return root as Scope;
55
+ }
56
+
57
+ async function resolveScoped(scopes: AgentScopes, scope: Scope, path: string, access: Access): Promise<string> {
58
+ // Writes are confined to the components (app) scope; logs and config are observation-only.
59
+ if (access === 'write' && scope !== 'components') {
60
+ throw new Error(`Cannot write to the '${scope}' scope; writes are limited to 'components'.`);
61
+ }
62
+ const root = scopeRoot(scopes, scope);
63
+ // Paths are relative to the chosen scope root. Rejecting absolute paths removes the ambiguity that
64
+ // led the model to guess literal prefixes, and keeps resolution a single unambiguous join.
65
+ if (isAbsolute(path)) {
66
+ throw new Error(`Path must be relative to the '${scope}' root, not absolute: ${path}`);
32
67
  }
68
+ const absolute = resolve(root, path);
33
69
  // Reject a symlink leaf. `safeRealPath` resolves existing symlinks via `realpath` (so a link to
34
70
  // an out-of-scope *existing* file is caught by the isInside check below) — but a link whose
35
71
  // target does NOT exist makes `realpath` throw, and the fallback returns the link's own in-scope
@@ -46,11 +82,9 @@ async function resolveScoped(scopes: AgentScopes, path: string, access: Access):
46
82
  if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err;
47
83
  }
48
84
  const realAbsolute = await safeRealPath(absolute);
49
- for (const root of candidates) {
50
- const realRoot = await safeRealPath(root);
51
- if (isInside(realAbsolute, realRoot)) return realAbsolute;
52
- }
53
- throw new Error(`Path is outside the agent's ${access} scope: ${path}`);
85
+ const realRoot = await safeRealPath(root);
86
+ if (isInside(realAbsolute, realRoot)) return realAbsolute;
87
+ throw new Error(`Path is outside the agent's '${scope}' scope: ${path}`);
54
88
  }
55
89
 
56
90
  async function safeRealPath(p: string): Promise<string> {
@@ -77,17 +111,18 @@ function isInside(child: string, parent: string): boolean {
77
111
  export const readFileTool: AgentTool = {
78
112
  def: {
79
113
  name: 'read_file',
80
- description: 'Read a UTF-8 text file within componentsRoot, logDir, or configDir.',
114
+ description: 'Read a UTF-8 text file from the components, logs, or config scope.',
81
115
  parameters: {
82
116
  type: 'object',
83
117
  properties: {
84
- path: { type: 'string', description: 'Absolute filesystem path.' },
118
+ root: { type: 'string', enum: SCOPES, description: SCOPE_DESCRIPTION },
119
+ path: { type: 'string', description: 'Path relative to the chosen root, e.g. "my_app/schema.graphql".' },
85
120
  },
86
121
  required: ['path'],
87
122
  },
88
123
  },
89
124
  handler: async (args: any, ctx: AgentToolContext) => {
90
- const path = await resolveScoped(ctx.scopes, args.path, 'read');
125
+ const path = await resolveScoped(ctx.scopes, normalizeScope(args.root), args.path, 'read');
91
126
  const st = await stat(path);
92
127
  if (st.size > MAX_READ_BYTES) {
93
128
  throw new Error(`File ${path} exceeds ${MAX_READ_BYTES}-byte read cap (size ${st.size})`);
@@ -100,11 +135,15 @@ export const readFileTool: AgentTool = {
100
135
  export const writeFileTool: AgentTool = {
101
136
  def: {
102
137
  name: 'write_file',
103
- description: 'Write a UTF-8 text file within componentsRoot. Creates parent directories as needed.',
138
+ description:
139
+ 'Write a UTF-8 text file into the components (app source) scope — the only writable scope. Creates parent directories as needed.',
104
140
  parameters: {
105
141
  type: 'object',
106
142
  properties: {
107
- path: { type: 'string', description: 'Absolute filesystem path under componentsRoot.' },
143
+ path: {
144
+ type: 'string',
145
+ description: 'Path relative to the components directory, e.g. "my_app/schema.graphql".',
146
+ },
108
147
  content: { type: 'string', description: 'UTF-8 file contents.' },
109
148
  },
110
149
  required: ['path', 'content'],
@@ -115,7 +154,7 @@ export const writeFileTool: AgentTool = {
115
154
  if (Buffer.byteLength(content, 'utf8') > MAX_WRITE_BYTES) {
116
155
  throw new Error(`Write exceeds ${MAX_WRITE_BYTES}-byte cap`);
117
156
  }
118
- const path = await resolveScoped(ctx.scopes, args.path, 'write');
157
+ const path = await resolveScoped(ctx.scopes, 'components', args.path, 'write');
119
158
  await mkdir(dirname(path), { recursive: true });
120
159
  await writeFile(path, content, 'utf8');
121
160
  return { path, bytesWritten: Buffer.byteLength(content, 'utf8') };
@@ -126,17 +165,20 @@ export const writeFileTool: AgentTool = {
126
165
  export const listDirTool: AgentTool = {
127
166
  def: {
128
167
  name: 'list_dir',
129
- description: 'List the immediate entries in a directory within an allowed scope.',
168
+ description: 'List the immediate entries in a directory within the components, logs, or config scope.',
130
169
  parameters: {
131
170
  type: 'object',
132
171
  properties: {
133
- path: { type: 'string', description: 'Absolute filesystem path.' },
172
+ root: { type: 'string', enum: SCOPES, description: SCOPE_DESCRIPTION },
173
+ path: {
174
+ type: 'string',
175
+ description: 'Directory relative to the chosen root. Omit or "" for the root itself.',
176
+ },
134
177
  },
135
- required: ['path'],
136
178
  },
137
179
  },
138
180
  handler: async (args: any, ctx: AgentToolContext) => {
139
- const path = await resolveScoped(ctx.scopes, args.path, 'read');
181
+ const path = await resolveScoped(ctx.scopes, normalizeScope(args.root), args.path ?? '', 'read');
140
182
  const entries = await readdir(path, { withFileTypes: true });
141
183
  return {
142
184
  path,
@@ -151,20 +193,24 @@ export const listDirTool: AgentTool = {
151
193
  export const grepFilesTool: AgentTool = {
152
194
  def: {
153
195
  name: 'grep_files',
154
- description: 'Search recursively under a scoped directory for a regex pattern. Returns matched lines.',
196
+ description: 'Search recursively within a scope for a regex pattern. Returns matched lines.',
155
197
  parameters: {
156
198
  type: 'object',
157
199
  properties: {
158
- root: { type: 'string', description: 'Directory to search under.' },
200
+ root: { type: 'string', enum: SCOPES, description: SCOPE_DESCRIPTION },
201
+ path: {
202
+ type: 'string',
203
+ description: 'Subdirectory relative to the chosen root to search under. Omit or "" for the whole scope.',
204
+ },
159
205
  pattern: { type: 'string', description: 'JavaScript-compatible regular expression source.' },
160
206
  flags: { type: 'string', description: 'Regex flags (default: "i").' },
161
207
  maxResults: { type: 'integer', minimum: 1, maximum: MAX_GREP_RESULTS },
162
208
  },
163
- required: ['root', 'pattern'],
209
+ required: ['pattern'],
164
210
  },
165
211
  },
166
212
  handler: async (args: any, ctx: AgentToolContext) => {
167
- const root = await resolveScoped(ctx.scopes, args.root, 'read');
213
+ const root = await resolveScoped(ctx.scopes, normalizeScope(args.root), args.path ?? '', 'read');
168
214
  // Cap pattern length. A maliciously crafted regex (e.g. nested quantifiers) can backtrack
169
215
  // catastrophically and block the main thread; JS has no native per-match timeout. The agent
170
216
  // is super_user-gated so this is self-inflicted DoS rather than a privilege boundary, but a
@@ -204,18 +250,19 @@ export const grepFilesTool: AgentTool = {
204
250
  export const tailFileTool: AgentTool = {
205
251
  def: {
206
252
  name: 'tail_file',
207
- description: 'Return the last N lines of a UTF-8 file. Useful for log tails.',
253
+ description: 'Return the last N lines of a UTF-8 file. Useful for log tails (root: "logs").',
208
254
  parameters: {
209
255
  type: 'object',
210
256
  properties: {
211
- path: { type: 'string', description: 'Absolute filesystem path.' },
257
+ root: { type: 'string', enum: SCOPES, description: SCOPE_DESCRIPTION },
258
+ path: { type: 'string', description: 'Path relative to the chosen root, e.g. "hdb.log".' },
212
259
  lines: { type: 'integer', minimum: 1, maximum: 5000 },
213
260
  },
214
261
  required: ['path'],
215
262
  },
216
263
  },
217
264
  handler: async (args: any, ctx: AgentToolContext) => {
218
- const path = await resolveScoped(ctx.scopes, args.path, 'read');
265
+ const path = await resolveScoped(ctx.scopes, normalizeScope(args.root), args.path, 'read');
219
266
  const wanted = Math.min(args.lines ?? DEFAULT_TAIL_LINES, 5000);
220
267
  // Read only the trailing TAIL_READ_BYTES — a multi-GB log file otherwise OOMs the process.
221
268
  const st = await stat(path);
@@ -0,0 +1,459 @@
1
+ /**
2
+ * V8 inspector (CDP) tools for the built-in agent (#626).
3
+ *
4
+ * The agent runs on the main thread; these tools attach over the Chrome
5
+ * DevTools Protocol to *worker* thread inspector ports so it can evaluate,
6
+ * set breakpoints/logpoints, and CPU-profile a worker without stalling the
7
+ * thread it runs on. A worker's debug port is `threads_debug_startingPort +
8
+ * workerIndex` (see `server/threads/threadServer.js`); attaching therefore
9
+ * requires `threads_debug: true` and a configured `threads_debug_startingPort`.
10
+ *
11
+ * Attaching to the main thread (`workerIndex < 0`) is rejected — that's the
12
+ * agent's own thread, and a self-attach + breakpoint would deadlock. This is
13
+ * the operator-only counterpart to the app-developer agent, which runs on a
14
+ * worker and must never inspector-attach to itself.
15
+ *
16
+ * Safety against wedging a live worker: every connection registers a
17
+ * `Debugger.paused` handler that logs a stack snapshot and immediately resumes,
18
+ * so a breakpoint hit is observable (via the Harper log) but never leaves the
19
+ * worker paused. CDP calls carry a per-call abort signal + timeout so an
20
+ * unresponsive worker can't hang the agent loop.
21
+ *
22
+ * Dependencies (debug config + live worker count) are injected so the tool is
23
+ * unit-testable without booting a server.
24
+ */
25
+
26
+ import WebSocket from 'ws';
27
+ import harperLogger from '../../utility/logging/harper_logger.ts';
28
+ import type { AgentTool, AgentToolContext } from '../types.ts';
29
+
30
+ const log = harperLogger.loggerWithTag('agent');
31
+
32
+ const DEFAULT_CALL_TIMEOUT_MS = 30_000;
33
+ const MAX_TCP_PORT = 65535;
34
+
35
+ export interface InspectorDeps {
36
+ /** Whether `threads_debug` is enabled. */
37
+ debugEnabled: boolean;
38
+ /** `threads_debug_startingPort`, or undefined when not configured. */
39
+ startingPort: number | undefined;
40
+ /** Inspector host (`threads_debug_host`), defaulting to 127.0.0.1. */
41
+ host: string;
42
+ /** Live worker-slot count, for range-checking `workerIndex`. */
43
+ getWorkerCount: () => number;
44
+ }
45
+
46
+ interface CdpSession {
47
+ send(method: string, params?: object, signal?: AbortSignal): Promise<any>;
48
+ on(event: string, cb: (params: any) => void): void;
49
+ close(): void;
50
+ title?: string;
51
+ }
52
+
53
+ // One live CDP connection per worker debug port, reused across tool calls. Keyed by port and
54
+ // storing the *promise* so concurrent opens dedupe onto a single connection.
55
+ const sessions = new Map<number, Promise<CdpSession>>();
56
+
57
+ /** Strictly parse a worker index — reject `""`/`null`/`false`/`[]`, which `Number()` would coerce to 0. */
58
+ function toWorkerIndex(raw: unknown): number {
59
+ if (typeof raw === 'number' && Number.isInteger(raw)) return raw;
60
+ if (typeof raw === 'string' && raw.trim() !== '' && Number.isInteger(Number(raw))) return Number(raw);
61
+ throw new Error('workerIndex must be an integer');
62
+ }
63
+
64
+ /**
65
+ * Resolve the worker's inspector port, enforcing the safety envelope:
66
+ * debug enabled, a starting port configured, and an in-range worker index that
67
+ * is NOT the main thread (`< 0`).
68
+ */
69
+ function resolvePort(deps: InspectorDeps, workerIndex: number): number {
70
+ if (!deps.debugEnabled) {
71
+ throw new Error('threads_debug is not enabled; set threads_debug: true to allow inspector attach');
72
+ }
73
+ if (deps.startingPort == null) {
74
+ throw new Error('threads_debug_startingPort is not configured; per-worker inspector ports are unavailable');
75
+ }
76
+ if (workerIndex < 0) {
77
+ throw new Error('cannot attach to the main thread (the agent runs there); specify a worker index >= 0');
78
+ }
79
+ const count = deps.getWorkerCount();
80
+ if (workerIndex >= count) throw new Error(`no worker at index ${workerIndex} (worker count is ${count})`);
81
+ const port = deps.startingPort + workerIndex;
82
+ if (port > MAX_TCP_PORT) throw new Error(`resolved inspector port ${port} exceeds ${MAX_TCP_PORT}`);
83
+ return port;
84
+ }
85
+
86
+ async function fetchWebSocketUrl(
87
+ host: string,
88
+ port: number,
89
+ signal?: AbortSignal
90
+ ): Promise<{ wsUrl: string; title?: string }> {
91
+ const res = await fetch(`http://${host}:${port}/json/list`, { signal });
92
+ if (!res.ok) throw new Error(`inspector /json/list on ${host}:${port} returned ${res.status}`);
93
+ const targets = (await res.json()) as Array<{ webSocketDebuggerUrl?: string; title?: string }>;
94
+ const target = Array.isArray(targets) ? targets.find((t) => t.webSocketDebuggerUrl) : undefined;
95
+ if (!target?.webSocketDebuggerUrl) {
96
+ throw new Error(`no debuggable target at ${host}:${port} (is the worker inspector open?)`);
97
+ }
98
+ return { wsUrl: target.webSocketDebuggerUrl, title: target.title };
99
+ }
100
+
101
+ function openCdp(wsUrl: string, opts: { signal?: AbortSignal; onClose: () => void }): Promise<CdpSession> {
102
+ return new Promise((resolve, reject) => {
103
+ const ws = new WebSocket(wsUrl);
104
+ let opened = false;
105
+ let nextId = 1;
106
+ const pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>();
107
+ const listeners = new Map<string, Set<(params: any) => void>>();
108
+
109
+ const failPending = (err: Error) => {
110
+ for (const p of pending.values()) p.reject(err);
111
+ pending.clear();
112
+ };
113
+ // The connect signal guards ONLY the handshake — once open, per-call signals take over, so
114
+ // aborting the first caller must not tear down the shared connection.
115
+ const onConnectAbort = () => {
116
+ try {
117
+ ws.terminate();
118
+ } catch {
119
+ /* already gone */
120
+ }
121
+ };
122
+ if (opts.signal) {
123
+ if (opts.signal.aborted) {
124
+ onConnectAbort();
125
+ return reject(new Error('aborted'));
126
+ }
127
+ opts.signal.addEventListener('abort', onConnectAbort, { once: true });
128
+ }
129
+
130
+ ws.on('message', (data: WebSocket.RawData) => {
131
+ let msg: any;
132
+ try {
133
+ msg = JSON.parse(data.toString());
134
+ } catch {
135
+ return;
136
+ }
137
+ if (msg.id != null && pending.has(msg.id)) {
138
+ const p = pending.get(msg.id)!;
139
+ pending.delete(msg.id);
140
+ if (msg.error) p.reject(new Error(msg.error.message ?? 'CDP error'));
141
+ else p.resolve(msg.result);
142
+ } else if (msg.method) {
143
+ for (const cb of listeners.get(msg.method) ?? []) cb(msg.params);
144
+ }
145
+ });
146
+ ws.on('error', (err: Error) => {
147
+ if (!opened) reject(err);
148
+ failPending(err instanceof Error ? err : new Error(String(err)));
149
+ });
150
+ ws.on('close', () => {
151
+ opts.signal?.removeEventListener('abort', onConnectAbort);
152
+ if (!opened) reject(new Error('CDP connection closed before it opened'));
153
+ failPending(new Error('CDP connection closed'));
154
+ opts.onClose();
155
+ });
156
+ ws.on('open', () => {
157
+ opened = true;
158
+ opts.signal?.removeEventListener('abort', onConnectAbort);
159
+ const session: CdpSession = {
160
+ send(method, params, signal) {
161
+ return new Promise((res, rej) => {
162
+ if (signal?.aborted) return rej(new Error('aborted'));
163
+ const id = nextId++;
164
+ const timer = setTimeout(() => {
165
+ if (pending.delete(id)) rej(new Error(`CDP ${method} timed out after ${DEFAULT_CALL_TIMEOUT_MS}ms`));
166
+ }, DEFAULT_CALL_TIMEOUT_MS);
167
+ const onAbort = () => {
168
+ if (pending.delete(id)) {
169
+ clearTimeout(timer);
170
+ rej(new Error('aborted'));
171
+ }
172
+ };
173
+ signal?.addEventListener('abort', onAbort, { once: true });
174
+ pending.set(id, {
175
+ resolve: (v) => {
176
+ clearTimeout(timer);
177
+ signal?.removeEventListener('abort', onAbort);
178
+ res(v);
179
+ },
180
+ reject: (e) => {
181
+ clearTimeout(timer);
182
+ signal?.removeEventListener('abort', onAbort);
183
+ rej(e);
184
+ },
185
+ });
186
+ try {
187
+ ws.send(JSON.stringify({ id, method, params: params ?? {} }));
188
+ } catch (e) {
189
+ if (pending.delete(id)) {
190
+ clearTimeout(timer);
191
+ rej(e as Error);
192
+ }
193
+ }
194
+ });
195
+ },
196
+ on(event, cb) {
197
+ let set = listeners.get(event);
198
+ if (!set) listeners.set(event, (set = new Set()));
199
+ set.add(cb);
200
+ },
201
+ close() {
202
+ try {
203
+ ws.close();
204
+ } catch {
205
+ /* already closing */
206
+ }
207
+ },
208
+ };
209
+ // A breakpoint hit pauses the worker; log a stack snapshot and resume immediately so the
210
+ // worker is never left wedged. Fires only when Debugger is enabled (i.e. a breakpoint was set).
211
+ session.on('Debugger.paused', (params: any) => {
212
+ const frames = (params?.callFrames ?? [])
213
+ .slice(0, 5)
214
+ .map((f: any) => `${f.functionName || '(anonymous)'}@${f.url}:${f.location?.lineNumber}`);
215
+ log.info?.(`[agent breakpoint hit] reason=${params?.reason} ${frames.join(' <- ')}`);
216
+ session.send('Debugger.resume').catch(() => {});
217
+ });
218
+ resolve(session);
219
+ });
220
+ });
221
+ }
222
+
223
+ function sessionFor(port: number, host: string, signal?: AbortSignal): Promise<CdpSession> {
224
+ const existing = sessions.get(port);
225
+ if (existing) return existing;
226
+ const promise = (async () => {
227
+ const { wsUrl, title } = await fetchWebSocketUrl(host, port, signal);
228
+ const session = await openCdp(wsUrl, {
229
+ signal,
230
+ // Evict only if this exact promise is still the cached one — a stale close from a replaced
231
+ // connection must not drop a newer active session.
232
+ onClose: () => {
233
+ if (sessions.get(port) === promise) sessions.delete(port);
234
+ },
235
+ });
236
+ session.title = title;
237
+ return session;
238
+ })();
239
+ // Drop a failed connect from the cache so the next call retries instead of re-awaiting a rejection.
240
+ promise.catch(() => {
241
+ if (sessions.get(port) === promise) sessions.delete(port);
242
+ });
243
+ sessions.set(port, promise);
244
+ return promise;
245
+ }
246
+
247
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
248
+ return new Promise((resolve, reject) => {
249
+ if (signal?.aborted) return reject(new Error('aborted'));
250
+ const timer = setTimeout(resolve, ms);
251
+ signal?.addEventListener(
252
+ 'abort',
253
+ () => {
254
+ clearTimeout(timer);
255
+ reject(new Error('aborted'));
256
+ },
257
+ { once: true }
258
+ );
259
+ });
260
+ }
261
+
262
+ /** Reduce a CDP CPU profile to the hottest functions by self time — the raw profile is megabytes. */
263
+ export function summarizeProfile(profile: any, topN: number): object {
264
+ const nodes: any[] = profile?.nodes ?? [];
265
+ const byId = new Map(nodes.map((n) => [n.id, n]));
266
+ const samples: number[] = profile?.samples ?? [];
267
+ const deltas: number[] = profile?.timeDeltas ?? [];
268
+ const selfUs = new Map<number, number>();
269
+ for (let i = 0; i < samples.length; i++) {
270
+ const id = samples[i];
271
+ selfUs.set(id, (selfUs.get(id) ?? 0) + (deltas[i] ?? 0));
272
+ }
273
+ const topFunctions = [...selfUs.entries()]
274
+ .map(([id, us]) => {
275
+ const cf = byId.get(id)?.callFrame ?? {};
276
+ return {
277
+ function: cf.functionName || '(anonymous)',
278
+ url: cf.url,
279
+ line: cf.lineNumber,
280
+ selfMs: +(us / 1000).toFixed(1),
281
+ };
282
+ })
283
+ .sort((a, b) => b.selfMs - a.selfMs)
284
+ .slice(0, topN);
285
+ const totalMs = deltas.reduce((a, b) => a + b, 0) / 1000;
286
+ return { totalMs: +totalMs.toFixed(1), sampleCount: samples.length, topFunctions };
287
+ }
288
+
289
+ const workerIndexProp = { type: 'integer', minimum: 0, description: '0-based worker thread index' };
290
+
291
+ export function buildInspectorTools(deps: InspectorDeps): AgentTool[] {
292
+ const attach: AgentTool = {
293
+ def: {
294
+ name: 'inspector_attach',
295
+ description:
296
+ 'Attach the V8 inspector to a worker thread so it can be evaluated, breakpointed, or CPU-profiled. Requires threads_debug + threads_debug_startingPort. Rejects the main thread.',
297
+ parameters: { type: 'object', properties: { workerIndex: workerIndexProp }, required: ['workerIndex'] },
298
+ },
299
+ handler: async (args: any, ctx: AgentToolContext) => {
300
+ const workerIndex = toWorkerIndex(args.workerIndex);
301
+ const port = resolvePort(deps, workerIndex);
302
+ const session = await sessionFor(port, deps.host, ctx.signal);
303
+ await session.send('Runtime.enable', {}, ctx.signal);
304
+ await session.send('Debugger.enable', {}, ctx.signal);
305
+ return { attached: true, workerIndex, port, target: session.title };
306
+ },
307
+ };
308
+
309
+ const evaluate: AgentTool = {
310
+ def: {
311
+ name: 'inspector_evaluate',
312
+ description:
313
+ 'Evaluate a JavaScript expression in a worker thread and return the result. Runs arbitrary code in the worker.',
314
+ parameters: {
315
+ type: 'object',
316
+ properties: {
317
+ workerIndex: workerIndexProp,
318
+ expression: { type: 'string' },
319
+ awaitPromise: { type: 'boolean', description: 'Await the result if it is a promise.' },
320
+ },
321
+ required: ['workerIndex', 'expression'],
322
+ },
323
+ },
324
+ destructive: true, // arbitrary in-worker code execution
325
+ handler: async (args: any, ctx: AgentToolContext) => {
326
+ const port = resolvePort(deps, toWorkerIndex(args.workerIndex));
327
+ const session = await sessionFor(port, deps.host, ctx.signal);
328
+ await session.send('Runtime.enable', {}, ctx.signal);
329
+ const res = await session.send(
330
+ 'Runtime.evaluate',
331
+ { expression: String(args.expression), returnByValue: true, awaitPromise: !!args.awaitPromise },
332
+ ctx.signal
333
+ );
334
+ if (res.exceptionDetails) {
335
+ throw new Error(res.exceptionDetails.exception?.description ?? res.exceptionDetails.text ?? 'evaluation threw');
336
+ }
337
+ return { value: res.result?.value, type: res.result?.type };
338
+ },
339
+ };
340
+
341
+ const setBreakpoint: AgentTool = {
342
+ def: {
343
+ name: 'inspector_set_breakpoint',
344
+ description:
345
+ 'Set a breakpoint by script URL + line in a worker. lineNumber is 0-based (CDP). On hit, a stack snapshot is written to the Harper log and the worker is auto-resumed (it is NOT left paused).',
346
+ parameters: {
347
+ type: 'object',
348
+ properties: {
349
+ workerIndex: workerIndexProp,
350
+ url: { type: 'string', description: 'Exact script URL (usually file:///…) to break in.' },
351
+ lineNumber: { type: 'integer', minimum: 0, description: '0-based line number.' },
352
+ columnNumber: { type: 'integer', minimum: 0 },
353
+ condition: { type: 'string', description: 'Optional JS condition; breaks only when it is truthy.' },
354
+ },
355
+ required: ['workerIndex', 'url', 'lineNumber'],
356
+ },
357
+ },
358
+ destructive: true, // runs a condition in the worker; a hit briefly pauses it before auto-resume
359
+ handler: async (args: any, ctx: AgentToolContext) => {
360
+ const port = resolvePort(deps, toWorkerIndex(args.workerIndex));
361
+ const session = await sessionFor(port, deps.host, ctx.signal);
362
+ await session.send('Debugger.enable', {}, ctx.signal);
363
+ const res = await session.send(
364
+ 'Debugger.setBreakpointByUrl',
365
+ {
366
+ url: String(args.url),
367
+ lineNumber: Number(args.lineNumber),
368
+ ...(args.columnNumber != null ? { columnNumber: Number(args.columnNumber) } : {}),
369
+ ...(args.condition ? { condition: String(args.condition) } : {}),
370
+ },
371
+ ctx.signal
372
+ );
373
+ return { breakpointId: res.breakpointId, locations: res.locations };
374
+ },
375
+ };
376
+
377
+ const setLogpoint: AgentTool = {
378
+ def: {
379
+ name: 'inspector_set_logpoint',
380
+ description:
381
+ 'Set a non-pausing logpoint: logs an expression each time the line is hit, without stopping the worker. lineNumber is 0-based. Runs the expression in the worker.',
382
+ parameters: {
383
+ type: 'object',
384
+ properties: {
385
+ workerIndex: workerIndexProp,
386
+ url: { type: 'string', description: 'Exact script URL (usually file:///…).' },
387
+ lineNumber: { type: 'integer', minimum: 0, description: '0-based line number.' },
388
+ logExpression: { type: 'string', description: 'JS expression whose value is logged on each hit.' },
389
+ },
390
+ required: ['workerIndex', 'url', 'lineNumber', 'logExpression'],
391
+ },
392
+ },
393
+ destructive: true, // runs an arbitrary expression in the worker on every hit
394
+ handler: async (args: any, ctx: AgentToolContext) => {
395
+ const port = resolvePort(deps, toWorkerIndex(args.workerIndex));
396
+ const session = await sessionFor(port, deps.host, ctx.signal);
397
+ await session.send('Debugger.enable', {}, ctx.signal);
398
+ // A logpoint is a breakpoint whose condition logs and returns false, so it never pauses. The
399
+ // expression is JSON-encoded into a string and eval'd (not spliced as raw code), so it can't
400
+ // break out of the wrapper to force a pause; a stray pause would auto-resume anyway (see
401
+ // openCdp). Direct eval keeps the paused frame's locals in scope.
402
+ const condition = `(function(){try{console.log('[agent logpoint]',eval(${JSON.stringify(
403
+ String(args.logExpression)
404
+ )}));}catch(e){}return false;})()`;
405
+ const res = await session.send(
406
+ 'Debugger.setBreakpointByUrl',
407
+ { url: String(args.url), lineNumber: Number(args.lineNumber), condition },
408
+ ctx.signal
409
+ );
410
+ return { breakpointId: res.breakpointId, locations: res.locations };
411
+ },
412
+ };
413
+
414
+ const profileCpu: AgentTool = {
415
+ def: {
416
+ name: 'inspector_profile_cpu',
417
+ description: 'Record a CPU profile of a worker for durationMs and return the hottest functions by self time.',
418
+ parameters: {
419
+ type: 'object',
420
+ properties: {
421
+ workerIndex: workerIndexProp,
422
+ durationMs: { type: 'integer', minimum: 100, maximum: 60000 },
423
+ topN: {
424
+ type: 'integer',
425
+ minimum: 1,
426
+ maximum: 50,
427
+ description: 'How many hot functions to return (default 15).',
428
+ },
429
+ },
430
+ required: ['workerIndex', 'durationMs'],
431
+ },
432
+ },
433
+ handler: async (args: any, ctx: AgentToolContext) => {
434
+ const port = resolvePort(deps, toWorkerIndex(args.workerIndex));
435
+ const duration = Math.min(Math.max(Number(args.durationMs), 100), 60000);
436
+ const session = await sessionFor(port, deps.host, ctx.signal);
437
+ await session.send('Profiler.enable', {}, ctx.signal);
438
+ await session.send('Profiler.start', {}, ctx.signal);
439
+ let stopped: any;
440
+ try {
441
+ await sleep(duration, ctx.signal);
442
+ } finally {
443
+ // Always stop + disable the profiler even if aborted, so we don't leave it running on the worker.
444
+ stopped = await session.send('Profiler.stop').catch(() => undefined);
445
+ await session.send('Profiler.disable').catch(() => {});
446
+ }
447
+ if (!stopped?.profile) throw new Error('profiling produced no profile');
448
+ return summarizeProfile(stopped.profile, args.topN ? Number(args.topN) : 15);
449
+ },
450
+ };
451
+
452
+ return [attach, evaluate, setBreakpoint, setLogpoint, profileCpu];
453
+ }
454
+
455
+ /** Close all live inspector connections (agent shutdown / test teardown). Handles in-flight opens. */
456
+ export function _closeInspectorSessions(): void {
457
+ for (const promise of sessions.values()) promise.then((s) => s.close()).catch(() => {});
458
+ sessions.clear();
459
+ }