@rse/ase 2.0.2 → 2.0.4

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 (236) hide show
  1. package/dst/ase-agent-definition-core.js +111 -87
  2. package/dst/ase-agent-fleet.js +1 -1
  3. package/dst/ase-agent-runtime-claim.js +5 -3
  4. package/dst/ase-agent-runtime-engine.js +1 -1
  5. package/dst/ase-agent-runtime-phase.js +1 -1
  6. package/dst/ase-agent-runtime-request.js +1 -1
  7. package/dst/ase-agent-runtime.js +3 -5
  8. package/dst/ase-artifact.js +3 -3
  9. package/dst/ase-config-core.js +2 -16
  10. package/dst/ase-config-schema.js +1 -3
  11. package/dst/ase-config.js +2 -0
  12. package/dst/ase-hook.js +13 -13
  13. package/dst/ase-lib-daemon-cli.js +117 -4
  14. package/dst/ase-lib-server-cli.js +4 -5
  15. package/dst/ase-lib-version.js +14 -5
  16. package/dst/ase-man-catalog.js +229 -0
  17. package/dst/ase-man-render.js +278 -0
  18. package/dst/ase-man-tui.js +636 -0
  19. package/dst/ase-man.js +33 -27
  20. package/dst/ase-mcp.js +4 -4
  21. package/dst/ase-service-getopt.js +2 -2
  22. package/dst/ase-service-otel.js +4 -4
  23. package/dst/ase-service-skills.js +5 -15
  24. package/dst/ase-service.js +87 -210
  25. package/dst/ase-setup-common.js +1 -2
  26. package/dst/ase-setup-settings.js +6 -9
  27. package/dst/ase-setup.js +4 -5
  28. package/dst/ase-spec.js +103 -20
  29. package/dst/ase-statusline.js +170 -170
  30. package/dst/ase-task-board-filter.js +5 -4
  31. package/dst/ase-task-board-flow.js +7 -6
  32. package/dst/ase-task-board-graph.js +5 -4
  33. package/dst/ase-task-board-tui-control.js +41 -35
  34. package/dst/ase-task-board-tui-model.js +185 -167
  35. package/dst/ase-task-board-tui-popup.js +2 -3
  36. package/dst/ase-task-board-tui-view.js +74 -70
  37. package/dst/ase-task-board-web-client.js +27 -27
  38. package/dst/ase-task-board-web-server.js +2 -2
  39. package/dst/ase-task-board.js +1 -1
  40. package/dst/ase-task-format.js +1 -1
  41. package/dst/ase-task-store-client.js +17 -5
  42. package/dst/ase-task-store-core.js +5 -7
  43. package/dst/ase-task-store-issues.js +16 -1
  44. package/dst/ase-task-store-plugin-ase.js +7 -3
  45. package/dst/ase-task-store-plugin-gitea.js +9 -32
  46. package/dst/ase-task-store-plugin-github.js +3 -12
  47. package/dst/ase-task-store-plugin-gitlab.js +2 -18
  48. package/dst/ase-task.js +51 -23
  49. package/dst/ase-util-compat.js +1 -1
  50. package/dst/ase-util-diagram.js +13 -37
  51. package/dst/ase.js +5 -5
  52. package/dst/man/ase-agent-adhoc.md +1165 -0
  53. package/dst/man/ase-agent-condenser.md +383 -0
  54. package/dst/man/ase-agent-editor.md +441 -0
  55. package/dst/man/ase-agent-estimator.md +477 -0
  56. package/dst/man/ase-agent-fleet.md +358 -0
  57. package/dst/man/ase-agent-implementer.md +521 -0
  58. package/dst/man/ase-agent-integrator.md +449 -0
  59. package/dst/man/ase-agent-qualifier.md +426 -0
  60. package/dst/man/ase-agent-rebooter.md +384 -0
  61. package/dst/man/ase-agent-relay.md +262 -0
  62. package/dst/man/ase-agent-researcher.md +478 -0
  63. package/dst/man/ase-agent.md +628 -0
  64. package/dst/man/ase-artifact-list.md +57 -0
  65. package/dst/man/ase-artifact-name.md +56 -0
  66. package/dst/man/ase-artifact.md +92 -0
  67. package/dst/man/ase-config-delete.md +74 -0
  68. package/dst/man/ase-config-edit.md +83 -0
  69. package/dst/man/ase-config-get.md +70 -0
  70. package/dst/man/ase-config-init.md +110 -0
  71. package/dst/man/ase-config-list.md +68 -0
  72. package/dst/man/ase-config-set.md +92 -0
  73. package/dst/man/ase-config.md +426 -0
  74. package/dst/man/ase-hook-permission-request.md +61 -0
  75. package/dst/man/ase-hook-pre-tool-use.md +96 -0
  76. package/dst/man/ase-hook-session-end.md +71 -0
  77. package/dst/man/ase-hook-session-persona.md +65 -0
  78. package/dst/man/ase-hook-session-start.md +184 -0
  79. package/dst/man/ase-hook-stop.md +52 -0
  80. package/dst/man/ase-hook-user-prompt-submit.md +54 -0
  81. package/dst/man/ase-hook.md +93 -0
  82. package/dst/man/ase-man.md +195 -0
  83. package/dst/man/ase-mcp.md +102 -0
  84. package/dst/man/ase-service-log.md +59 -0
  85. package/dst/man/ase-service-send.md +60 -0
  86. package/dst/man/ase-service-start.md +93 -0
  87. package/dst/man/ase-service-status.md +58 -0
  88. package/dst/man/ase-service-stop.md +52 -0
  89. package/dst/man/ase-service.md +124 -0
  90. package/dst/man/ase-setup-disable.md +67 -0
  91. package/dst/man/ase-setup-enable.md +66 -0
  92. package/dst/man/ase-setup-install.md +116 -0
  93. package/dst/man/ase-setup-mcp.md +213 -0
  94. package/dst/man/ase-setup-status.md +101 -0
  95. package/dst/man/ase-setup-statusline.md +162 -0
  96. package/dst/man/ase-setup-telemetry.md +166 -0
  97. package/dst/man/ase-setup-uninstall.md +85 -0
  98. package/dst/man/ase-setup-update.md +117 -0
  99. package/dst/man/ase-setup.md +156 -0
  100. package/dst/man/ase-spec-export.md +99 -0
  101. package/dst/man/ase-spec-init.md +57 -0
  102. package/dst/man/ase-spec-lint.md +74 -0
  103. package/dst/man/ase-spec-preview.md +57 -0
  104. package/dst/man/ase-spec.md +85 -0
  105. package/dst/man/ase-statusline.md +330 -0
  106. package/dst/man/ase-task-assign.md +138 -0
  107. package/dst/man/ase-task-board.md +651 -0
  108. package/dst/man/ase-task-delete.md +46 -0
  109. package/dst/man/ase-task-edit.md +73 -0
  110. package/dst/man/ase-task-idscheme.md +92 -0
  111. package/dst/man/ase-task-lifecycle.md +92 -0
  112. package/dst/man/ase-task-list.md +86 -0
  113. package/dst/man/ase-task-load.md +48 -0
  114. package/dst/man/ase-task-newid.md +100 -0
  115. package/dst/man/ase-task-purge.md +49 -0
  116. package/dst/man/ase-task-rename.md +69 -0
  117. package/dst/man/ase-task-request.md +108 -0
  118. package/dst/man/ase-task-save.md +78 -0
  119. package/dst/man/ase-task-status.md +79 -0
  120. package/dst/man/ase-task-store.md +377 -0
  121. package/dst/man/ase-task-view.md +50 -0
  122. package/dst/man/ase-task.md +335 -0
  123. package/dst/man/ase-util-compat.md +38 -0
  124. package/dst/man/ase-util-diagram.md +140 -0
  125. package/dst/man/ase-util-meta.md +68 -0
  126. package/dst/man/ase-util-metric.md +74 -0
  127. package/dst/man/ase-util-mint.md +85 -0
  128. package/dst/man/ase-util-worktree.md +109 -0
  129. package/dst/man/ase-util.md +81 -0
  130. package/dst/man/ase.md +228 -0
  131. package/package.json +12 -17
  132. package/plugin/.claude-plugin/plugin.json +1 -1
  133. package/plugin/.codex-plugin/plugin.json +1 -1
  134. package/plugin/.github/plugin/plugin.json +1 -1
  135. package/plugin/agents/ase-code-lint.md +3 -3
  136. package/plugin/agents/ase-docs-proofread.md +3 -3
  137. package/plugin/agents/ase-docs-refine.md +3 -3
  138. package/plugin/agents/ase-spec-lint.md +338 -0
  139. package/plugin/meta/ase-skill.md +7 -0
  140. package/plugin/mods/ase-mods.ts +10 -7
  141. package/plugin/mods/claude-code.d.ts +26 -3
  142. package/plugin/skills/ase-code-analyze/SKILL.md +18 -10
  143. package/plugin/skills/ase-code-analyze/help.md +11 -6
  144. package/plugin/skills/ase-code-lint/SKILL.md +37 -23
  145. package/plugin/skills/ase-code-lint/help.md +10 -5
  146. package/plugin/skills/ase-help-intent/data.md +204 -13
  147. package/plugin/skills/ase-help-skill/catalog.md +1 -0
  148. package/plugin/skills/ase-meta-workflow/workflow.txt +2 -0
  149. package/plugin/skills/ase-repo-commit/SKILL.md +1 -0
  150. package/plugin/skills/ase-repo-merge/SKILL.md +1 -0
  151. package/plugin/skills/ase-repo-review/SKILL.md +5 -3
  152. package/plugin/skills/ase-repo-review/help.md +3 -1
  153. package/plugin/skills/ase-spec-edit/SKILL.md +3 -3
  154. package/plugin/skills/ase-spec-edit/help.md +1 -1
  155. package/plugin/skills/ase-spec-lint/SKILL.md +695 -0
  156. package/plugin/skills/ase-spec-lint/help.md +171 -0
  157. package/plugin/skills/ase-task-implement/SKILL.md +24 -6
  158. package/plugin/skills/ase-task-implement/help.md +11 -1
  159. package/dst/man/ase-agent-adhoc.1 +0 -515
  160. package/dst/man/ase-agent-condenser.1 +0 -193
  161. package/dst/man/ase-agent-editor.1 +0 -212
  162. package/dst/man/ase-agent-estimator.1 +0 -214
  163. package/dst/man/ase-agent-fleet.1 +0 -185
  164. package/dst/man/ase-agent-implementer.1 +0 -233
  165. package/dst/man/ase-agent-integrator.1 +0 -213
  166. package/dst/man/ase-agent-qualifier.1 +0 -214
  167. package/dst/man/ase-agent-rebooter.1 +0 -193
  168. package/dst/man/ase-agent-relay.1 +0 -128
  169. package/dst/man/ase-agent-researcher.1 +0 -217
  170. package/dst/man/ase-agent.1 +0 -264
  171. package/dst/man/ase-artifact-list.1 +0 -38
  172. package/dst/man/ase-artifact-name.1 +0 -39
  173. package/dst/man/ase-artifact.1 +0 -56
  174. package/dst/man/ase-config-delete.1 +0 -45
  175. package/dst/man/ase-config-edit.1 +0 -48
  176. package/dst/man/ase-config-get.1 +0 -46
  177. package/dst/man/ase-config-init.1 +0 -60
  178. package/dst/man/ase-config-list.1 +0 -42
  179. package/dst/man/ase-config-set.1 +0 -52
  180. package/dst/man/ase-config.1 +0 -231
  181. package/dst/man/ase-hook-permission-request.1 +0 -36
  182. package/dst/man/ase-hook-pre-tool-use.1 +0 -59
  183. package/dst/man/ase-hook-session-end.1 +0 -47
  184. package/dst/man/ase-hook-session-persona.1 +0 -37
  185. package/dst/man/ase-hook-session-start.1 +0 -81
  186. package/dst/man/ase-hook-stop.1 +0 -34
  187. package/dst/man/ase-hook-user-prompt-submit.1 +0 -34
  188. package/dst/man/ase-hook.1 +0 -51
  189. package/dst/man/ase-man.1 +0 -67
  190. package/dst/man/ase-mcp.1 +0 -61
  191. package/dst/man/ase-service-log.1 +0 -43
  192. package/dst/man/ase-service-send.1 +0 -47
  193. package/dst/man/ase-service-start.1 +0 -54
  194. package/dst/man/ase-service-status.1 +0 -46
  195. package/dst/man/ase-service-stop.1 +0 -37
  196. package/dst/man/ase-service.1 +0 -85
  197. package/dst/man/ase-setup-disable.1 +0 -44
  198. package/dst/man/ase-setup-enable.1 +0 -45
  199. package/dst/man/ase-setup-install.1 +0 -73
  200. package/dst/man/ase-setup-mcp.1 +0 -124
  201. package/dst/man/ase-setup-status.1 +0 -60
  202. package/dst/man/ase-setup-statusline.1 +0 -92
  203. package/dst/man/ase-setup-telemetry.1 +0 -92
  204. package/dst/man/ase-setup-uninstall.1 +0 -59
  205. package/dst/man/ase-setup-update.1 +0 -64
  206. package/dst/man/ase-setup.1 +0 -93
  207. package/dst/man/ase-spec-export.1 +0 -62
  208. package/dst/man/ase-spec-lint.1 +0 -40
  209. package/dst/man/ase-spec-preview.1 +0 -40
  210. package/dst/man/ase-spec.1 +0 -46
  211. package/dst/man/ase-statusline.1 +0 -189
  212. package/dst/man/ase-task-assign.1 +0 -71
  213. package/dst/man/ase-task-board.1 +0 -384
  214. package/dst/man/ase-task-delete.1 +0 -35
  215. package/dst/man/ase-task-edit.1 +0 -46
  216. package/dst/man/ase-task-idscheme.1 +0 -65
  217. package/dst/man/ase-task-lifecycle.1 +0 -57
  218. package/dst/man/ase-task-list.1 +0 -47
  219. package/dst/man/ase-task-load.1 +0 -36
  220. package/dst/man/ase-task-newid.1 +0 -61
  221. package/dst/man/ase-task-purge.1 +0 -37
  222. package/dst/man/ase-task-rename.1 +0 -44
  223. package/dst/man/ase-task-request.1 +0 -61
  224. package/dst/man/ase-task-save.1 +0 -48
  225. package/dst/man/ase-task-status.1 +0 -55
  226. package/dst/man/ase-task-store.1 +0 -194
  227. package/dst/man/ase-task-view.1 +0 -40
  228. package/dst/man/ase-task.1 +0 -160
  229. package/dst/man/ase-util-compat.1 +0 -25
  230. package/dst/man/ase-util-diagram.1 +0 -76
  231. package/dst/man/ase-util-meta.1 +0 -45
  232. package/dst/man/ase-util-metric.1 +0 -54
  233. package/dst/man/ase-util-mint.1 +0 -59
  234. package/dst/man/ase-util-worktree.1 +0 -72
  235. package/dst/man/ase-util.1 +0 -50
  236. package/dst/man/ase.1 +0 -124
package/dst/ase-man.js CHANGED
@@ -5,13 +5,14 @@
5
5
  */
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
- import { spawnSync } from "node:child_process";
9
8
  import { fileURLToPath } from "node:url";
10
9
  import { writeStdout } from "./ase-lib-stdio.js";
10
+ /* the width of the manual pages printed as plain text */
11
+ const WIDTH = 80;
11
12
  /* reusable functionality: resolve the internally shipped manual pages */
12
13
  export class Man {
13
14
  /* determine the directory of the manual pages; the build process
14
- renders "man/*.md" into "dst/man/*.1" (see the "build-man" build
15
+ copies "man/*.md" into "dst/man/*.md" (see the "build-man" build
15
16
  step), right next to the compiled tool */
16
17
  static dir() {
17
18
  return path.join(path.dirname(fileURLToPath(import.meta.url)), "man");
@@ -26,15 +27,15 @@ export class Man {
26
27
  throw new Error(`man: no manual pages found in "${Man.dir()}" (tool not completely built?)`, { cause: err });
27
28
  }
28
29
  return files
29
- .filter((file) => /^ase(?:-[a-z0-9-]+)?\.\d$/.test(file))
30
- .map((file) => file.replace(/\.\d$/, ""))
30
+ .filter((file) => /^ase(?:-[a-z0-9-]+)?\.md$/.test(file))
31
+ .map((file) => file.replace(/\.md$/, ""))
31
32
  .sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
32
33
  }
33
- /* resolve the words of a requested manual page to the absolute path of
34
- its file: the words are joined with dashes, the "ase" prefix is optional,
35
- and a command path deeper than the shipped pages (e.g. "ase task store
36
- start") falls back to the page of its nearest parent (e.g. "ase-task-store"),
37
- as the pages of a group with third-level commands cover them */
34
+ /* resolve the words of a requested manual page to its name: the words
35
+ are joined with dashes, the "ase" prefix is optional, and a command path
36
+ deeper than the shipped pages (e.g. "ase task store start") falls back to
37
+ the page of its nearest parent (e.g. "ase-task-store"), as the pages of a
38
+ group with third-level commands cover them */
38
39
  static resolve(words) {
39
40
  const name = words.join("-").toLowerCase();
40
41
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name))
@@ -46,7 +47,7 @@ export class Man {
46
47
  while (parts.length > 0) {
47
48
  const page = parts.join("-");
48
49
  if (pages.has(page))
49
- return path.join(Man.dir(), `${page}.1`);
50
+ return page;
50
51
  parts.pop();
51
52
  if (parts.length === 1)
52
53
  break;
@@ -64,33 +65,38 @@ export default class ManCommand {
64
65
  register(program) {
65
66
  program
66
67
  .command("man")
67
- .description("Show an internally shipped manual page with man(1)")
68
+ .description("Browse all manual pages interactively, or print a manual page as plain text")
68
69
  .argument("[page...]", "manual page name, either as \"ase-<command>[-<sub-command>]\" " +
69
- "or as the words of the command path (\"ase\" prefix optional, default: \"ase\")")
70
+ "or as the words of the command path (\"ase\" prefix optional), selected in the " +
71
+ "interactive browser on a terminal (or searched for, if no manual page fits exactly), " +
72
+ "else printed as plain text (default: \"ase\")")
70
73
  .option("-l, --list", "list the names of all shipped manual pages instead", false)
71
- .option("-p, --path", "print the path of the manual page file instead", false)
72
74
  .action(async (words, opts) => {
73
75
  /* list all manual pages */
74
76
  if (opts.list) {
75
77
  await writeStdout(Man.list().map((page) => `${page}\n`).join(""));
76
78
  return;
77
79
  }
78
- /* resolve manual page */
79
- const file = Man.resolve(words.length > 0 ? words : ["ase"]);
80
- if (opts.path) {
81
- await writeStdout(`${file}\n`);
80
+ /* browse all manual pages interactively (only on a terminal), with
81
+ the given manual page selected resp. searched for (if any) */
82
+ if (process.stdin.isTTY && process.stdout.isTTY) {
83
+ const { runManTUI } = await import("./ase-man-tui.js");
84
+ await runManTUI(this.log, words);
82
85
  return;
83
86
  }
84
- /* show manual page with man(1), passing the file by its path,
85
- which all common man(1) implementations support */
86
- this.log.write("debug", `man: showing manual page "${file}"`);
87
- const result = spawnSync("man", [file], { stdio: "inherit" });
88
- if (result.error !== undefined) {
89
- const message = result.error.message;
90
- throw new Error(`man: failed to run man(1): ${message} ` +
91
- `(the manual page file is "${file}")`, { cause: result.error });
92
- }
93
- process.exitCode = result.status ?? 1;
87
+ /* resolve manual page, where a skill or command fitting exactly
88
+ is preferred over the fallback onto the nearest parent command */
89
+ const { loadCatalog, findPage } = await import("./ase-man-catalog.js");
90
+ const { renderPage } = await import("./ase-man-render.js");
91
+ const catalog = loadCatalog();
92
+ const request = words.length > 0 ? words : ["ase"];
93
+ const page = findPage(catalog, request) ?? catalog.pages.get(`${Man.resolve(request)}.1`);
94
+ if (page === undefined)
95
+ throw new Error(`man: no manual page found for "${request.join(" ")}" (see "ase man --list")`);
96
+ /* print manual page as plain text */
97
+ this.log.write("debug", `man: printing manual page "${page.id}"`);
98
+ const { lines } = renderPage(page.md, WIDTH, page.id, (id) => catalog.pages.has(id));
99
+ await writeStdout(lines.map((line) => line.map((span) => span.text).join("").trimEnd() + "\n").join(""));
94
100
  });
95
101
  }
96
102
  }
package/dst/ase-mcp.js CHANGED
@@ -72,7 +72,7 @@ export default class MCPCommand {
72
72
  const value = state.getExplicit("basedir");
73
73
  return typeof value === "string" && value !== "" ? value : null;
74
74
  }
75
- catch (_e) {
75
+ catch {
76
76
  /* best-effort: an unreadable session config just defers the lookup */
77
77
  return null;
78
78
  }
@@ -139,7 +139,7 @@ export default class MCPCommand {
139
139
  next.onerror = (err) => {
140
140
  this.log.write("error", `mcp: http: ${err.message}`);
141
141
  };
142
- /* service closed the connection — try to recover */
142
+ /* service closed the connection -- try to recover */
143
143
  next.onclose = () => {
144
144
  if (client !== next || closedByUs.has(next) || bridgeDone || reconnecting)
145
145
  return;
@@ -183,7 +183,7 @@ export default class MCPCommand {
183
183
  if (reconnecting)
184
184
  return;
185
185
  reconnecting = true;
186
- this.log.write("warning", `mcp: ${reason} — reconnecting`);
186
+ this.log.write("warning", `mcp: ${reason} -- reconnecting`);
187
187
  reconnect(0).catch(() => { });
188
188
  };
189
189
  /* bounded buffer for messages arriving while no HTTP client is connected */
@@ -315,7 +315,7 @@ export default class MCPCommand {
315
315
  await connectClient();
316
316
  }
317
317
  catch (err) {
318
- /* service vanished between probe and connect — recover instead of crashing */
318
+ /* service vanished between probe and connect -- recover instead of crashing */
319
319
  triggerReconnect(`initial connect failed: ${this.asError(err).message}`);
320
320
  }
321
321
  }
@@ -23,7 +23,7 @@ const tokenizeRanges = (input) => {
23
23
  const quote = ch;
24
24
  i++;
25
25
  while (i < input.length && input[i] !== quote) {
26
- if (input[i] === "\\" && i + 1 < input.length)
26
+ if (quote === "\"" && input[i] === "\\" && i + 1 < input.length)
27
27
  i++;
28
28
  i++;
29
29
  }
@@ -50,7 +50,7 @@ const unquote = (s) => {
50
50
  const quote = ch;
51
51
  j++;
52
52
  while (j < s.length && s[j] !== quote) {
53
- if (s[j] === "\\" && j + 1 < s.length) {
53
+ if (quote === "\"" && s[j] === "\\" && j + 1 < s.length) {
54
54
  out += s[j + 1];
55
55
  j += 2;
56
56
  }
@@ -580,7 +580,7 @@ export class UsageTracker {
580
580
  const self = userId();
581
581
  if (assignee.trim() === "")
582
582
  throw new Error("usage: empty assignee (use \"none\" to unassign)");
583
- const to = assignee === "me" ? self : assignee.trim();
583
+ const to = assignee.trim() === "me" ? self : assignee.trim();
584
584
  /* (keep the session out of the adoptions meanwhile, and let an assignment to oneself
585
585
  settle the adoption of its active task, as the explicit assignment takes precedence) */
586
586
  this.explicit.add(session);
@@ -591,6 +591,7 @@ export class UsageTracker {
591
591
  this.explicit.delete(session);
592
592
  }
593
593
  }
594
+ /* perform the assignment of "assign" (with the session kept out of the adoptions) */
594
595
  async assignSession(id, to, self, session) {
595
596
  const warning = await Task.checkAssignee(this.log, to);
596
597
  const from = await Task.assign(this.log, id, to === "none" ? null : to);
@@ -952,6 +953,7 @@ export class UsageTracker {
952
953
  this.unwatch = null;
953
954
  }
954
955
  }
956
+ /* close resp. implicitly account the open windows of the watched tasks whose assignee changed elsewhere */
955
957
  async check() {
956
958
  for (const task of Array.from(this.watched)) {
957
959
  const w = this.openWindow(task);
@@ -1212,9 +1214,7 @@ export class UsageTracker {
1212
1214
  (dropping the records of unregistered sessions), with the session registry
1213
1215
  re-read from the telemetry state file only once the file changed */
1214
1216
  route(records) {
1215
- const bySession = new Map();
1216
- for (const r of records)
1217
- bySession.set(r.session, [...(bySession.get(r.session) ?? []), r]);
1217
+ const bySession = Map.groupBy(records, (r) => r.session);
1218
1218
  let registry;
1219
1219
  try {
1220
1220
  const file = UsageTracker.stateFile();
@@ -187,7 +187,8 @@ export class Skills {
187
187
  if (m === null)
188
188
  return "N.A.";
189
189
  const base = parseFloat(m[1].replace(/,/g, ""));
190
- const mult = m[2] === "K" ? 1_000 : m[2] === "M" ? 1_000_000 : m[2] === "B" ? 1_000_000_000 : 1;
190
+ const unit = m[2].toUpperCase();
191
+ const mult = unit === "K" ? 1_000 : unit === "M" ? 1_000_000 : unit === "B" ? 1_000_000_000 : 1;
191
192
  const n = Math.round(base * mult);
192
193
  return Number.isFinite(n) ? n : "N.A.";
193
194
  }
@@ -246,20 +247,9 @@ export class Skills {
246
247
  const now = Date.now();
247
248
  const msPerDay = 1000 * 60 * 60 * 24;
248
249
  const halfLife = 365 / 2;
249
- /* lifespan requires both timestamps; recentness requires the
250
- updated timestamp -- any unavailable date-derived factor is
251
- given a neutral default so the entry can still be ranked by the
252
- remaining metrics, instead of collapsing the product to zero.
253
- For `lifespan` (an unbounded duration) the multiplicative-neutral
254
- `1` is also the low floor, so a missing value cannot unfairly
255
- inflate the rank. For `recentness`, however, the value is an
256
- exp-decay factor bounded in `(0,1]` where `1` is the *ceiling*
257
- (a just-now update), not a floor -- defaulting a missing update
258
- date to `1` would hand packages with absent metadata the highest
259
- possible recentness and let them outrank genuinely fresh ones.
260
- We therefore default a missing `recentness` to the decay value at
261
- one half-life (`0.5`), a conservative midpoint that keeps the
262
- entry rankable without rewarding the missing date. */
250
+ /* an unavailable date-derived factor gets a neutral default: a missing lifespan
251
+ its floor "1", a missing recentness (whose ceiling is "1") the decay value at
252
+ one half-life ("0.5"), so a missing date is never rewarded */
263
253
  const lifespan = (!Number.isNaN(cMs) && !Number.isNaN(uMs)) ? Math.max(1, uMs - cMs) : 1;
264
254
  const recentness = !Number.isNaN(uMs) ? 0.5 ** (Math.max(0, (now - uMs) / msPerDay) / halfLife) : 0.5;
265
255
  let rank = d * s * lifespan * recentness;
@@ -5,7 +5,6 @@
5
5
  */
6
6
  import path from "node:path";
7
7
  import fs from "node:fs";
8
- import net from "node:net";
9
8
  import { fileURLToPath } from "node:url";
10
9
  import { spawn } from "node:child_process";
11
10
  import Hapi from "@hapi/hapi";
@@ -13,7 +12,6 @@ import { ofetch } from "ofetch";
13
12
  import { isMap } from "yaml";
14
13
  import prettyMs from "pretty-ms";
15
14
  import * as v from "valibot";
16
- import { Tail } from "tail";
17
15
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
18
16
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
19
17
  import { ConfigMCP } from "./ase-config.js";
@@ -21,6 +19,7 @@ import { Config } from "./ase-config-core.js";
21
19
  import { configSchema } from "./ase-config-schema.js";
22
20
  import { ensureAseGitignore } from "./ase-config-scope.js";
23
21
  import { isLogLevel } from "./ase-lib-log.js";
22
+ import { Daemon } from "./ase-lib-daemon-cli.js";
24
23
  import { CompatMCP } from "./ase-util-compat.js";
25
24
  import { DiagramMCP } from "./ase-util-diagram.js";
26
25
  import { Task, TaskMCP } from "./ase-task.js";
@@ -76,14 +75,7 @@ const PORT_ENV = "ASE_SERVICE_PORT";
76
75
  const LEVEL_ENV = "ASE_SERVICE_LOG_LEVEL";
77
76
  const IDLE_MS = 30 * 60 * 1000;
78
77
  const TICK_MS = 60 * 1000;
79
- const PORT_MIN = 42000;
80
- const PORT_MAX = 44000;
81
- const PORT_TRIES = 20;
82
- /* bounds for the append-only ".ase/service.log" file:
83
- maximum tolerated size, number of tail lines surviving a trim,
84
- and maximum length of the MCP tool call arguments logged per request */
85
- const LOG_MAX_SIZE = 1024 * 1024;
86
- const LOG_KEEP_LINES = 2000;
78
+ /* maximum length of the MCP tool call arguments logged per request */
87
79
  const LOG_ARGS_MAX = 200;
88
80
  /* load the optional "config.yaml" and "service.yaml" files and derive
89
81
  the service identity context (project id, port, preferred port, service config) */
@@ -105,33 +97,8 @@ export const loadServiceContext = (log) => {
105
97
  /* return context information */
106
98
  return { projectId, port, portPref, svc, aseDir };
107
99
  };
108
- /* reusable functionality: port allocation, persistence, and process spawning */
100
+ /* reusable functionality: port persistence and process spawning */
109
101
  export class Service {
110
- /* try binding a single candidate port to verify availability */
111
- static tryBind(port) {
112
- return new Promise((resolve) => {
113
- const s = net.createServer();
114
- s.once("error", () => {
115
- resolve(false);
116
- });
117
- s.once("listening", () => {
118
- s.close(() => resolve(true));
119
- });
120
- s.listen(port, HOST);
121
- });
122
- }
123
- /* allocate the preferred port, if given and available,
124
- else a fresh random port in PORT_MIN..PORT_MAX */
125
- static async allocatePort(pref = null) {
126
- if (pref !== null && await Service.tryBind(pref))
127
- return pref;
128
- for (let i = 0; i < PORT_TRIES; i++) {
129
- const p = PORT_MIN + Math.floor(Math.random() * (PORT_MAX - PORT_MIN + 1));
130
- if (await Service.tryBind(p))
131
- return p;
132
- }
133
- throw new Error(`failed to allocate a port in ${PORT_MIN}..${PORT_MAX} after ${PORT_TRIES} attempts`);
134
- }
135
102
  /* persist an allocated port into ".ase/service.yaml" */
136
103
  static persistPort(svc, port) {
137
104
  ensureAseGitignore(path.dirname(svc.filename));
@@ -156,19 +123,6 @@ export class Service {
156
123
  svc.write();
157
124
  });
158
125
  }
159
- /* trim the log file down to its tail if it grew beyond the size limit */
160
- static trimLog(logFile) {
161
- try {
162
- if (!fs.existsSync(logFile) || fs.statSync(logFile).size <= LOG_MAX_SIZE)
163
- return;
164
- const tail = Service.readLogTail(logFile, LOG_KEEP_LINES);
165
- fs.writeFileSync(logFile, tail.length > 0 ? `${tail}\n` : "");
166
- }
167
- catch {
168
- /* intentionally ignore all trimming errors, as a
169
- non-trimmable log file must never block the service */
170
- }
171
- }
172
126
  /* spawn the current executable detached as a background service */
173
127
  static spawnDetached(aseDir, port, logLevel) {
174
128
  fs.mkdirSync(aseDir, { recursive: true });
@@ -177,7 +131,7 @@ export class Service {
177
131
  /* trim the log before handing it to the service, as the detached
178
132
  service inherits the file descriptor for its entire lifetime and
179
133
  hence cannot rotate the file itself while it is running */
180
- Service.trimLog(logFile);
134
+ Daemon.trimLog(logFile);
181
135
  const fd = fs.openSync(logFile, "a");
182
136
  const entry = fileURLToPath(new URL("./ase.js", import.meta.url));
183
137
  const child = spawn(process.execPath, [entry, "service", "start"], {
@@ -188,68 +142,6 @@ export class Service {
188
142
  fs.closeSync(fd);
189
143
  return { child, logFile };
190
144
  }
191
- /* read the last N non-empty lines of a log file for diagnostics */
192
- static readLogTail(logFile, lines) {
193
- let fd = null;
194
- try {
195
- fd = fs.openSync(logFile, "r");
196
- const size = fs.fstatSync(fd).size;
197
- const CHUNK = 8192;
198
- const chunks = [];
199
- let pos = size;
200
- let count = 0;
201
- while (pos > 0 && count <= lines) {
202
- const n = Math.min(CHUNK, pos);
203
- const buf = Buffer.alloc(n);
204
- pos -= n;
205
- fs.readSync(fd, buf, 0, n, pos);
206
- chunks.unshift(buf);
207
- for (let i = 0; i < n; i++)
208
- if (buf[i] === 10)
209
- count++;
210
- }
211
- /* decode once after re-assembly to avoid splitting
212
- multi-byte UTF-8 sequences at chunk boundaries */
213
- const all = Buffer.concat(chunks).toString("utf8")
214
- .split("\n").filter((l) => l.length > 0);
215
- return all.slice(-lines).join("\n");
216
- }
217
- catch {
218
- return "";
219
- }
220
- finally {
221
- if (fd !== null)
222
- fs.closeSync(fd);
223
- }
224
- }
225
- /* show a log file on stdout, either completely or its last <lines> lines only, and
226
- optionally follow it for newly appended lines (default: the last 10 lines first),
227
- where each line is passed through <transform> (e.g. to colorize parts of it) */
228
- static async showLog(logFile, opts, transform = (line) => line) {
229
- const lines = opts.lines !== undefined ? Number(opts.lines) : null;
230
- if (lines !== null && (!Number.isInteger(lines) || lines < 1))
231
- throw new Error(`invalid number of lines: ${opts.lines}`);
232
- if (!fs.existsSync(logFile))
233
- return 0;
234
- if (opts.follow) {
235
- /* follow until interrupted (on log trimming,
236
- "tail" continues at the new end of the file) */
237
- const tail = new Tail(logFile, { nLines: lines ?? 10 });
238
- tail.on("line", (line) => {
239
- process.stdout.write(`${transform(line)}\n`);
240
- });
241
- return await new Promise((_resolve, reject) => {
242
- tail.on("error", (err) => {
243
- reject(err instanceof Error ? err : new Error(String(err)));
244
- });
245
- });
246
- }
247
- const text = lines !== null ?
248
- Service.readLogTail(logFile, lines) :
249
- fs.readFileSync(logFile, "utf8").replace(/\n$/, "");
250
- process.stdout.write(text.length > 0 ? `${text.split("\n").map(transform).join("\n")}\n` : "");
251
- return 0;
252
- }
253
145
  }
254
146
  /* MCP registration entry point for service-identity tools */
255
147
  export class ServiceMCP {
@@ -332,6 +224,68 @@ export default class ServiceCommand {
332
224
  }
333
225
  return { method: bMethod, info };
334
226
  }
227
+ /* register the endpoints of the AI usage accounting: the usage records forwarded
228
+ by the OTLP ingress, the session end, the task assignment on behalf of a session,
229
+ and the task rename */
230
+ static registerUsageRoutes(server, usage) {
231
+ server.route({
232
+ method: "POST",
233
+ path: "/usage/records",
234
+ options: { payload: { parse: true, allow: "application/json", maxBytes: 32 * 1024 * 1024 } },
235
+ handler: (request, h) => {
236
+ const payload = request.payload;
237
+ if (!Array.isArray(payload?.records))
238
+ return h.response({ error: "missing or invalid 'records' field" }).code(400);
239
+ usage.ingest(payload.records);
240
+ return h.response({ ok: true }).code(200);
241
+ }
242
+ });
243
+ server.route({
244
+ method: "POST",
245
+ path: "/usage/detach",
246
+ options: { payload: { parse: true, allow: "application/json" } },
247
+ handler: (request, h) => {
248
+ const payload = request.payload;
249
+ if (typeof payload?.session !== "string")
250
+ return h.response({ error: "missing or invalid 'session' field" }).code(400);
251
+ usage.detach(payload.session);
252
+ return h.response({ ok: true }).code(200);
253
+ }
254
+ });
255
+ server.route({
256
+ method: "POST",
257
+ path: "/usage/rename",
258
+ options: { payload: { parse: true, allow: "application/json" } },
259
+ handler: (request, h) => {
260
+ const payload = request.payload;
261
+ if (typeof payload?.old !== "string" || typeof payload.new !== "string")
262
+ return h.response({ error: "missing or invalid 'old' or 'new' field" }).code(400);
263
+ try {
264
+ usage.rename(payload.old, payload.new);
265
+ }
266
+ catch (err) {
267
+ return h.response({ error: err instanceof Error ? err.message : String(err) }).code(422);
268
+ }
269
+ return h.response({ ok: true }).code(200);
270
+ }
271
+ });
272
+ server.route({
273
+ method: "POST",
274
+ path: "/usage/assign",
275
+ options: { payload: { parse: true, allow: "application/json" } },
276
+ handler: async (request, h) => {
277
+ const payload = request.payload;
278
+ if (typeof payload?.id !== "string" || typeof payload.assignee !== "string" || typeof payload.session !== "string")
279
+ return h.response({ error: "missing or invalid 'id', 'assignee', or 'session' field" }).code(400);
280
+ try {
281
+ return h.response(await usage.assign(payload.id, payload.assignee, payload.session)).code(200);
282
+ }
283
+ catch (err) {
284
+ return h.response({ error: err instanceof Error ? err.message : String(err) }).code(422);
285
+ }
286
+ }
287
+ });
288
+ }
335
289
  /* service-side: bind HAPI server until "/stop" command is received or idle timeout happens
336
290
  (or, in the foreground, until "/stop" command is received or a termination signal arrives) */
337
291
  async runService(ctx, foreground = false) {
@@ -361,6 +315,18 @@ export default class ServiceCommand {
361
315
  const usage = new UsageTracker(this.log, Task.projectRoot(), ctx.projectId, ctx.aseDir, () => {
362
316
  lastActivity = Date.now();
363
317
  });
318
+ /* stop the HTTP server and the usage tracker, clear the persisted port, and exit */
319
+ const stopService = async () => {
320
+ try {
321
+ await server.stop({ timeout: 1000 });
322
+ }
323
+ catch (err) {
324
+ this.log.write("error", `service: stop failed: ${err instanceof Error ? err.message : String(err)}`);
325
+ }
326
+ await usage.stop();
327
+ Service.clearPort(ctx.svc);
328
+ process.exit(0);
329
+ };
364
330
  /* listen to HTTP/REST endpoints */
365
331
  server.route({
366
332
  method: "OPTIONS",
@@ -379,17 +345,7 @@ export default class ServiceCommand {
379
345
  if (!ct.startsWith("application/json"))
380
346
  return h.response({ error: "unsupported media type" }).code(415);
381
347
  this.log.write("info", "service: stop requested");
382
- setImmediate(async () => {
383
- try {
384
- await server.stop({ timeout: 1000 });
385
- }
386
- catch (err) {
387
- const e = err;
388
- this.log.write("error", `service: stop failed: ${e.message}`);
389
- }
390
- await usage.stop();
391
- process.exit(0);
392
- });
348
+ setImmediate(stopService);
393
349
  return h.response({ ok: true }).code(200);
394
350
  }
395
351
  });
@@ -456,66 +412,8 @@ export default class ServiceCommand {
456
412
  return h.response({ error: "unknown command", command: cmd }).code(400);
457
413
  }
458
414
  });
459
- /* listen to the endpoints of the AI usage accounting: the usage records forwarded
460
- by the OTLP ingress, the session end, the task assignment on behalf of a session,
461
- and the task rename */
462
- server.route({
463
- method: "POST",
464
- path: "/usage/records",
465
- options: { payload: { parse: true, allow: "application/json", maxBytes: 32 * 1024 * 1024 } },
466
- handler: (request, h) => {
467
- const payload = request.payload;
468
- if (!Array.isArray(payload?.records))
469
- return h.response({ error: "missing or invalid 'records' field" }).code(400);
470
- usage.ingest(payload.records);
471
- return h.response({ ok: true }).code(200);
472
- }
473
- });
474
- server.route({
475
- method: "POST",
476
- path: "/usage/detach",
477
- options: { payload: { parse: true, allow: "application/json" } },
478
- handler: (request, h) => {
479
- const payload = request.payload;
480
- if (typeof payload?.session !== "string")
481
- return h.response({ error: "missing or invalid 'session' field" }).code(400);
482
- usage.detach(payload.session);
483
- return h.response({ ok: true }).code(200);
484
- }
485
- });
486
- server.route({
487
- method: "POST",
488
- path: "/usage/rename",
489
- options: { payload: { parse: true, allow: "application/json" } },
490
- handler: (request, h) => {
491
- const payload = request.payload;
492
- if (typeof payload?.old !== "string" || typeof payload.new !== "string")
493
- return h.response({ error: "missing or invalid 'old' or 'new' field" }).code(400);
494
- try {
495
- usage.rename(payload.old, payload.new);
496
- }
497
- catch (err) {
498
- return h.response({ error: err instanceof Error ? err.message : String(err) }).code(422);
499
- }
500
- return h.response({ ok: true }).code(200);
501
- }
502
- });
503
- server.route({
504
- method: "POST",
505
- path: "/usage/assign",
506
- options: { payload: { parse: true, allow: "application/json" } },
507
- handler: async (request, h) => {
508
- const payload = request.payload;
509
- if (typeof payload?.id !== "string" || typeof payload.assignee !== "string" || typeof payload.session !== "string")
510
- return h.response({ error: "missing or invalid 'id', 'assignee', or 'session' field" }).code(400);
511
- try {
512
- return h.response(await usage.assign(payload.id, payload.assignee, payload.session)).code(200);
513
- }
514
- catch (err) {
515
- return h.response({ error: err instanceof Error ? err.message : String(err) }).code(422);
516
- }
517
- }
518
- });
415
+ /* listen to the endpoints of the AI usage accounting */
416
+ ServiceCommand.registerUsageRoutes(server, usage);
519
417
  /* serve the web board of the project (imported dynamically to keep it out of all
520
418
  other CLI commands; its rendering dependencies marked, MRCS, and ELK load on
521
419
  first request only, its board core dependencies at service startup) */
@@ -549,16 +447,7 @@ export default class ServiceCommand {
549
447
  return;
550
448
  stopping = true;
551
449
  this.log.write("info", `service: received ${signal}, stopping`);
552
- try {
553
- await server.stop({ timeout: 1000 });
554
- }
555
- catch (err) {
556
- const e = err;
557
- this.log.write("error", `service: stop failed: ${e.message}`);
558
- }
559
- await usage.stop();
560
- Service.clearPort(ctx.svc);
561
- process.exit(0);
450
+ await stopService();
562
451
  };
563
452
  process.on("SIGTERM", () => shutdown("SIGTERM"));
564
453
  process.on("SIGINT", () => shutdown("SIGINT"));
@@ -578,25 +467,13 @@ export default class ServiceCommand {
578
467
  if (Date.now() - lastActivity > IDLE_MS) {
579
468
  stopping = true;
580
469
  this.log.write("info", "service: idle timeout reached, stopping");
581
- try {
582
- await server.stop({ timeout: 1000 });
583
- await usage.stop();
584
- Service.clearPort(ctx.svc);
585
- process.exit(0);
586
- }
587
- catch (err) {
588
- const e = err;
589
- this.log.write("error", `service: stop failed: ${e.message}`);
590
- await usage.stop();
591
- Service.clearPort(ctx.svc);
592
- process.exit(1);
593
- }
470
+ await stopService();
594
471
  }
595
472
  }, TICK_MS).unref();
596
473
  }
597
474
  /* allocate the service port, preferring the configured one, else falling back to a random one */
598
475
  async allocatePort(ctx) {
599
- const port = await Service.allocatePort(ctx.portPref);
476
+ const port = await Daemon.allocatePort(ctx.portPref);
600
477
  if (ctx.portPref !== null && port !== ctx.portPref)
601
478
  this.log.write("warning", `service: preferred port ${ctx.portPref} unavailable, falling back to port ${port}`);
602
479
  return port;
@@ -612,7 +489,7 @@ export default class ServiceCommand {
612
489
  if (level !== undefined && isLogLevel(level))
613
490
  this.log.logLevel(level);
614
491
  const raw = process.env[PORT_ENV];
615
- port = raw !== undefined ? Number(raw) : await Service.allocatePort();
492
+ port = raw !== undefined ? Number(raw) : await Daemon.allocatePort();
616
493
  await this.runService({ ...ctx, port });
617
494
  return await new Promise(() => { });
618
495
  }
@@ -668,7 +545,7 @@ export default class ServiceCommand {
668
545
  break;
669
546
  }
670
547
  }
671
- const tail = Service.readLogTail(logFile, 20);
548
+ const tail = Daemon.readLogTail(logFile, 20);
672
549
  const reason = exited ?
673
550
  `service exited during startup (code ${exitCode})` :
674
551
  foreign ?
@@ -755,7 +632,7 @@ export default class ServiceCommand {
755
632
  /* log flow: show the (tail of the) log file and optionally follow it */
756
633
  async doLog(opts) {
757
634
  const ctx = this.loadContext();
758
- return Service.showLog(path.join(ctx.aseDir, "service.log"), opts);
635
+ return Daemon.showLog(path.join(ctx.aseDir, "service.log"), opts);
759
636
  }
760
637
  /* stop flow: no-op if no port configured or connection refused */
761
638
  async doStop() {
@@ -51,8 +51,7 @@ export class SetupRunner {
51
51
  if (process.platform === "win32")
52
52
  return false;
53
53
  /* already running as root */
54
- const getuid = process.getuid;
55
- if (typeof getuid === "function" && getuid.call(process) === 0)
54
+ if (process.getuid?.() === 0)
56
55
  return false;
57
56
  /* determine the npm global prefix and probe writability of the
58
57
  directories that "npm -g" actually mutates */