@rse/ase 0.0.46 → 0.0.49

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 (34) hide show
  1. package/dst/ase-config.js +103 -6
  2. package/dst/ase-diagram.js +3 -3
  3. package/dst/ase-getopt.js +8 -4
  4. package/dst/ase-hook.js +122 -23
  5. package/dst/ase-mcp.js +26 -20
  6. package/dst/ase-service.js +10 -3
  7. package/dst/ase-skills.js +182 -42
  8. package/dst/ase-statusline.js +17 -21
  9. package/dst/ase-task.js +60 -1
  10. package/package.json +1 -1
  11. package/plugin/.claude-plugin/plugin.json +1 -1
  12. package/plugin/.github/plugin/plugin.json +1 -1
  13. package/plugin/agents/ase-code-lint.md +370 -0
  14. package/plugin/agents/ase-docs-proofread.md +100 -0
  15. package/plugin/meta/ase-constitution.md +7 -7
  16. package/plugin/meta/ase-control.md +1 -1
  17. package/plugin/meta/ase-dialog.md +2 -2
  18. package/plugin/meta/ase-persona.md +2 -2
  19. package/plugin/meta/ase-plan.md +2 -2
  20. package/plugin/meta/ase-skill.md +2 -2
  21. package/plugin/package.json +1 -1
  22. package/plugin/skills/ase-arch-analyze/SKILL.md +4 -4
  23. package/plugin/skills/ase-arch-discover/SKILL.md +14 -3
  24. package/plugin/skills/ase-code-analyze/SKILL.md +2 -2
  25. package/plugin/skills/ase-code-explain/SKILL.md +1 -1
  26. package/plugin/skills/ase-code-lint/SKILL.md +179 -298
  27. package/plugin/skills/ase-code-resolve/SKILL.md +1 -1
  28. package/plugin/skills/ase-docs-proofread/SKILL.md +151 -51
  29. package/plugin/skills/ase-meta-changes/SKILL.md +4 -4
  30. package/plugin/skills/ase-meta-diagram/SKILL.md +5 -5
  31. package/plugin/skills/ase-meta-evaluate/SKILL.md +2 -2
  32. package/plugin/skills/ase-meta-persona/SKILL.md +1 -1
  33. package/plugin/skills/ase-meta-quorum/SKILL.md +1 -1
  34. package/plugin/skills/ase-task-rename/SKILL.md +92 -0
package/dst/ase-config.js CHANGED
@@ -13,6 +13,7 @@ import * as v from "valibot";
13
13
  import Table from "cli-table3";
14
14
  import writeFileAtomic from "write-file-atomic";
15
15
  import lockfile from "proper-lockfile";
16
+ import { z } from "zod";
16
17
  /* classification taxonomy */
17
18
  export const projectClassification = {
18
19
  boxing: ["white", "grey", "black"]
@@ -53,7 +54,8 @@ export const projectClassificationPresets = {
53
54
  (reads always cascade through the full chain; this restricts writes only);
54
55
  keys absent from this map default to all non-"default" scope kinds */
55
56
  export const configWritableScopes = {
56
- "agent.task": ["session"]
57
+ "agent.task": ["session"],
58
+ "agent.skill": ["session"]
57
59
  };
58
60
  /* default set of scope kinds writable for any unrestricted key */
59
61
  const configWritableScopesDefault = ["user", "project", "task", "session"];
@@ -132,7 +134,8 @@ export const configSchema = v.nullish(v.strictObject({
132
134
  })),
133
135
  agent: v.optional(v.strictObject({
134
136
  persona: v.optional(v.picklist(agentClassification.persona)),
135
- task: v.optional(v.pipe(v.string(), v.minLength(1)))
137
+ task: v.optional(v.pipe(v.string(), v.minLength(1))),
138
+ skill: v.optional(v.pipe(v.string(), v.minLength(1)))
136
139
  }))
137
140
  }));
138
141
  /* encapsulate read/write access to a stack of "<name>.yaml" configuration files,
@@ -333,10 +336,7 @@ export class Config {
333
336
  doc.deleteIn(segs);
334
337
  progressed = true;
335
338
  }
336
- else
337
- /* root-level issue cannot be deleted; skip it and process
338
- remaining issues so progressed is tracked correctly */
339
- continue;
339
+ /* root-level issues cannot be deleted; processing continues with the remaining issues */
340
340
  }
341
341
  if (!progressed)
342
342
  return;
@@ -626,3 +626,100 @@ export default class ConfigCommand {
626
626
  });
627
627
  }
628
628
  }
629
+ /* MCP registration entry point for layered YAML configuration access */
630
+ export class ConfigMCP {
631
+ log;
632
+ constructor(log) {
633
+ this.log = log;
634
+ }
635
+ /* register the MCP tools */
636
+ register(mcp) {
637
+ /* config get */
638
+ mcp.registerTool("config_get", {
639
+ title: "ASE config get",
640
+ description: "Read the effective value of a dotted configuration `key` from the layered " +
641
+ "configuration, cascading through default/user/project/task/session chain up to and " +
642
+ "including the requested `scope`. Returns the value as JSON-encoded `text`; " +
643
+ "returns an empty string if no value is set.",
644
+ inputSchema: {
645
+ key: z.string()
646
+ .describe("dotted configuration key (e.g. \"agent.skill\")"),
647
+ scope: z.string()
648
+ .describe("scope chain (e.g. \"session:<id>\", \"task:<id>\", \"project\", \"user\")")
649
+ }
650
+ }, async (args) => {
651
+ try {
652
+ const scope = parseScope(args.scope);
653
+ const cfg = new Config("config", configSchema, this.log, scope);
654
+ let text = "";
655
+ cfg.lock(() => {
656
+ cfg.read();
657
+ const val = cfg.get(args.key);
658
+ text = val === undefined ? "" : JSON.stringify(val);
659
+ });
660
+ return { content: [{ type: "text", text }] };
661
+ }
662
+ catch (err) {
663
+ const message = err instanceof Error ? err.message : String(err);
664
+ return { isError: true, content: [{ type: "text", text: `config_get: ERROR: ${message}` }] };
665
+ }
666
+ });
667
+ /* config set */
668
+ mcp.registerTool("config_set", {
669
+ title: "ASE config set",
670
+ description: "Write `val` to a dotted configuration `key` at the target `scope` " +
671
+ "(the strongest scope term in the chain). The value is validated against " +
672
+ "the configuration schema before being persisted.",
673
+ inputSchema: {
674
+ key: z.string()
675
+ .describe("dotted configuration key (e.g. \"agent.skill\")"),
676
+ val: z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(z.any()), z.record(z.string(), z.any())])
677
+ .describe("value to store under `key`"),
678
+ scope: z.string()
679
+ .describe("scope chain (e.g. \"session:<id>\", \"task:<id>\", \"project\", \"user\")")
680
+ }
681
+ }, async (args) => {
682
+ try {
683
+ const scope = parseScope(args.scope);
684
+ const cfg = new Config("config", configSchema, this.log, scope);
685
+ cfg.lock(() => {
686
+ cfg.read();
687
+ cfg.set(args.key, args.val);
688
+ cfg.write();
689
+ });
690
+ return { content: [{ type: "text", text: `config_set: OK: stored "${args.key}" on scope "${args.scope}"` }] };
691
+ }
692
+ catch (err) {
693
+ const message = err instanceof Error ? err.message : String(err);
694
+ return { isError: true, content: [{ type: "text", text: `config_set: ERROR: ${message}` }] };
695
+ }
696
+ });
697
+ /* config delete */
698
+ mcp.registerTool("config_delete", {
699
+ title: "ASE config delete",
700
+ description: "Delete the value at a dotted configuration `key` from the target `scope` " +
701
+ "(the strongest scope term in the chain). No-op if the key is not present.",
702
+ inputSchema: {
703
+ key: z.string()
704
+ .describe("dotted configuration key (e.g. \"agent.skill\")"),
705
+ scope: z.string()
706
+ .describe("scope chain (e.g. \"session:<id>\", \"task:<id>\", \"project\", \"user\")")
707
+ }
708
+ }, async (args) => {
709
+ try {
710
+ const scope = parseScope(args.scope);
711
+ const cfg = new Config("config", configSchema, this.log, scope);
712
+ cfg.lock(() => {
713
+ cfg.read();
714
+ cfg.delete(args.key);
715
+ cfg.write();
716
+ });
717
+ return { content: [{ type: "text", text: `config_delete: OK: removed "${args.key}" on scope "${args.scope}"` }] };
718
+ }
719
+ catch (err) {
720
+ const message = err instanceof Error ? err.message : String(err);
721
+ return { isError: true, content: [{ type: "text", text: `config_delete: ERROR: ${message}` }] };
722
+ }
723
+ });
724
+ }
725
+ }
@@ -278,9 +278,9 @@ export class DiagramMCP {
278
278
  "Use for visualizing " +
279
279
  "structure/layout/components/dependencies as a Flowchart, " +
280
280
  "control-flow/branching/concurrency as a Flowchart, " +
281
- "state-machine/states/transitions as an UML State Diagram, " +
282
- "data-flow/actors/messages/protocols as an UML Sequence Diagram, " +
283
- "data-structure/classes/methods as an UML Class Diagram, " +
281
+ "state-machine/states/transitions as a UML State Diagram, " +
282
+ "data-flow/actors/messages/protocols as a UML Sequence Diagram, " +
283
+ "data-structure/classes/methods as a UML Class Diagram, " +
284
284
  "data-model/entities/relationships as an ER Diagram, or " +
285
285
  "metrics/distributions/time-series as an XY-Chart. " +
286
286
  "Pass the Mermaid diagram specification as `diagram`. " +
package/dst/ase-getopt.js CHANGED
@@ -34,7 +34,11 @@ export class GetoptMCP {
34
34
  /* normalize args */
35
35
  const argsRaw = typeof args.args === "string" ? args.args : null;
36
36
  const argsVec = typeof args.args === "string" ?
37
- shParse(args.args).filter((e) => typeof e === "string") :
37
+ shParse(args.args)
38
+ .map((e) => typeof e === "string" ? e :
39
+ (e !== null && typeof e === "object" && "op" in e && e.op === "glob" ?
40
+ e.pattern : null))
41
+ .filter((e) => e !== null) :
38
42
  args.args;
39
43
  /* build a fresh commander program */
40
44
  const cmd = new Command(args.name)
@@ -104,9 +108,9 @@ export class GetoptMCP {
104
108
  }
105
109
  ranges.push({ start, end: i });
106
110
  }
107
- const tail = cmd.args.length;
108
- if (tail > 0 && ranges.length >= tail) {
109
- const first = ranges[ranges.length - tail].start;
111
+ const consumed = argsVec.length - cmd.args.length;
112
+ if (cmd.args.length > 0 && consumed >= 0 && consumed < ranges.length) {
113
+ const first = ranges[consumed].start;
110
114
  argsVerbatim = argsRaw.slice(first);
111
115
  }
112
116
  }
package/dst/ase-hook.js CHANGED
@@ -8,6 +8,7 @@ import fs from "node:fs";
8
8
  import os from "node:os";
9
9
  import { execaSync } from "execa";
10
10
  import { quote } from "shell-quote";
11
+ import * as v from "valibot";
11
12
  import Version from "./ase-version.js";
12
13
  import { Config, configSchema, parseScope } from "./ase-config.js";
13
14
  const toolSpecs = {
@@ -36,10 +37,36 @@ export default class HookCommand {
36
37
  constructor(log) {
37
38
  this.log = log;
38
39
  }
40
+ /* validate a session id against the accepted character set */
41
+ isValidSessionId(id) {
42
+ return /^[A-Za-z0-9._-]+$/.test(id);
43
+ }
44
+ /* best-effort JSON parse with valibot schema validation: returns
45
+ an empty object on blank input, malformed JSON, or schema
46
+ mismatch, so callers can treat the result uniformly. Extra
47
+ properties in the data are tolerated; only the declared schema
48
+ entries are required to match. */
49
+ parseJSON(text, schema) {
50
+ const empty = {};
51
+ if (text.trim() === "")
52
+ return empty;
53
+ let raw;
54
+ try {
55
+ raw = JSON.parse(text);
56
+ }
57
+ catch (_e) {
58
+ /* best-effort: return empty object on malformed JSON */
59
+ return empty;
60
+ }
61
+ const result = v.safeParse(schema, raw);
62
+ if (!result.success)
63
+ return empty;
64
+ return result.output;
65
+ }
39
66
  /* recursively expand "@<path>" file references in a Markdown text,
40
67
  resolving paths relative to the directory of the containing file */
41
68
  expandReferences(text, baseDir, visited = new Set()) {
42
- return text.replace(/@([^\s]+)/g, (match, ref) => {
69
+ return text.replace(/@(\S+)/g, (match, ref) => {
43
70
  let resolved = ref;
44
71
  if (resolved.startsWith("~/"))
45
72
  resolved = path.join(process.env.HOME ?? "", resolved.slice(2));
@@ -71,10 +98,23 @@ export default class HookCommand {
71
98
  const filePkg = path.join(pluginRoot, ".claude-plugin", "plugin.json");
72
99
  const fileMd = path.join(pluginRoot, "meta", "ase-constitution.md");
73
100
  /* read external files */
74
- const pkg = fs.readFileSync(filePkg, "utf8");
75
- let md = fs.readFileSync(fileMd, "utf8");
101
+ let pkg;
102
+ let md;
103
+ try {
104
+ pkg = fs.readFileSync(filePkg, "utf8");
105
+ }
106
+ catch (_e) {
107
+ throw new Error(`failed to read plugin manifest: ${filePkg}`);
108
+ }
109
+ try {
110
+ md = fs.readFileSync(fileMd, "utf8");
111
+ }
112
+ catch (_e) {
113
+ throw new Error(`failed to read constitution file: ${fileMd}`);
114
+ }
76
115
  /* determine own version */
77
- const versionCurrentPlugin = JSON.parse(pkg).version ?? "";
116
+ const pkgObj = this.parseJSON(pkg, v.object({ version: v.optional(v.string()) }));
117
+ const versionCurrentPlugin = pkgObj.version ?? "";
78
118
  const versionCurrentTool = Version.current();
79
119
  const versionLatestTool = await Version.latest();
80
120
  /* sanity check situation */
@@ -90,11 +130,15 @@ export default class HookCommand {
90
130
  /* read session information (Claude Code uses snake_case fields,
91
131
  Copilot CLI uses camelCase fields) */
92
132
  const stdin = fs.readFileSync(0, "utf8");
93
- const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
133
+ const input = this.parseJSON(stdin, v.object({
134
+ session_id: v.optional(v.string()),
135
+ sessionId: v.optional(v.string()),
136
+ cwd: v.optional(v.string())
137
+ }));
94
138
  /* determine session id */
95
- const sessionId = input.session_id ?? input.sessionId ?? "";
139
+ const sessionId = this.pickSessionId(input);
96
140
  /* establish config context (session-scoped only if a valid sessionId is present) */
97
- const hasSession = /^[A-Za-z0-9._-]+$/.test(sessionId);
141
+ const hasSession = this.isValidSessionId(sessionId);
98
142
  const cfg = new Config("config", configSchema, this.log, hasSession ? parseScope(`session:${sessionId}`) : parseScope(undefined));
99
143
  cfg.lock(() => {
100
144
  cfg.read();
@@ -196,18 +240,32 @@ export default class HookCommand {
196
240
  /* handler for "ase hook stop" (both tools) */
197
241
  doStop(_tool) {
198
242
  this.writeAgentStatus("ready");
243
+ /* safety net: clear any lingering "agent.skill" marker so a
244
+ crashed or aborted skill loop does not leave information active */
245
+ const sessionId = this.readSessionIdFromStdin();
246
+ if (this.isValidSessionId(sessionId)) {
247
+ try {
248
+ const cfg = new Config("config", configSchema, this.log, parseScope(`session:${sessionId}`));
249
+ cfg.lock(() => {
250
+ cfg.read();
251
+ if (typeof cfg.get("agent.skill") === "string") {
252
+ cfg.delete("agent.skill");
253
+ cfg.write();
254
+ }
255
+ });
256
+ }
257
+ catch (_e) {
258
+ /* best-effort: ignore failures */
259
+ }
260
+ }
199
261
  return 0;
200
262
  }
201
263
  /* handler for "ase hook session-end" (both tools) */
202
264
  doSessionEnd(_tool) {
203
- /* read session information (Claude Code uses snake_case fields,
204
- Copilot CLI uses camelCase fields) */
205
- const stdin = fs.readFileSync(0, "utf8");
206
- const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
207
265
  /* determine session id */
208
- const sessionId = input.session_id ?? input.sessionId ?? "";
266
+ const sessionId = this.readSessionIdFromStdin();
209
267
  /* remove the session directory ~/.ase/session/<id> (only for a valid sessionId) */
210
- if (/^[A-Za-z0-9._-]+$/.test(sessionId)) {
268
+ if (this.isValidSessionId(sessionId)) {
211
269
  const dir = path.join(os.homedir(), ".ase", "session", sessionId);
212
270
  try {
213
271
  fs.rmSync(dir, { recursive: true, force: true });
@@ -218,26 +276,59 @@ export default class HookCommand {
218
276
  }
219
277
  return 0;
220
278
  }
279
+ /* pick the session id from a parsed payload (Claude Code uses
280
+ snake_case fields, Copilot CLI uses camelCase fields) */
281
+ pickSessionId(input) {
282
+ return input.session_id ?? input.sessionId ?? "";
283
+ }
284
+ /* read session id from stdin JSON payload */
285
+ readSessionIdFromStdin() {
286
+ const stdin = fs.readFileSync(0, "utf8");
287
+ const input = this.parseJSON(stdin, v.object({
288
+ session_id: v.optional(v.string()),
289
+ sessionId: v.optional(v.string())
290
+ }));
291
+ return this.pickSessionId(input);
292
+ }
293
+ /* read the session-scoped "agent.skill" config value */
294
+ readActiveSkill(sessionId) {
295
+ if (!this.isValidSessionId(sessionId))
296
+ return "";
297
+ try {
298
+ const cfg = new Config("config", configSchema, this.log, parseScope(`session:${sessionId}`));
299
+ let val = "";
300
+ cfg.lock(() => {
301
+ cfg.read();
302
+ const skill = cfg.get("agent.skill");
303
+ if (typeof skill === "string")
304
+ val = skill;
305
+ });
306
+ return val;
307
+ }
308
+ catch (_e) {
309
+ return "";
310
+ }
311
+ }
221
312
  /* handler for "ase hook pre-tool-use" (both tools) */
222
313
  doPreToolUse(tool) {
223
314
  const spec = toolSpecs[tool];
224
315
  /* read tool invocation information */
225
316
  const stdin = fs.readFileSync(0, "utf8");
226
- const input = stdin.trim() !== "" ? JSON.parse(stdin) : {};
317
+ const input = this.parseJSON(stdin, v.looseObject({
318
+ session_id: v.optional(v.string()),
319
+ sessionId: v.optional(v.string())
320
+ }));
227
321
  /* determine whether to auto-approve the tool invocation
228
322
  (field names and value shapes differ between tools) */
229
323
  const toolName = typeof input[spec.toolNameField] === "string" ?
230
324
  input[spec.toolNameField] : "";
231
325
  let toolInput = {};
232
326
  const rawInput = input[spec.toolInputField];
233
- if (spec.toolInputIsString && typeof rawInput === "string") {
234
- try {
235
- toolInput = JSON.parse(rawInput);
236
- }
237
- catch (_e) {
238
- /* best-effort: leave toolInput empty on parse failure */
239
- }
240
- }
327
+ if (spec.toolInputIsString && typeof rawInput === "string")
328
+ toolInput = this.parseJSON(rawInput, v.object({
329
+ command: v.optional(v.string()),
330
+ skill: v.optional(v.string())
331
+ }));
241
332
  else if (!spec.toolInputIsString && typeof rawInput === "object" && rawInput !== null)
242
333
  toolInput = rawInput;
243
334
  let approve = false;
@@ -254,6 +345,14 @@ export default class HookCommand {
254
345
  approve = true;
255
346
  reason = "ASE MCP tool invocation auto-approved";
256
347
  }
348
+ else if (toolName === "Edit") {
349
+ const sessionId = this.pickSessionId(input);
350
+ const activeSkill = this.readActiveSkill(sessionId);
351
+ if (activeSkill === "ase-docs-proofread" || activeSkill === "ase-code-lint") {
352
+ approve = true;
353
+ reason = `${activeSkill}: user already consented via AskUserQuestion`;
354
+ }
355
+ }
257
356
  /* emit permission decision (or stay silent to defer to default flow).
258
357
  Claude Code expects the decision nested in "hookSpecificOutput";
259
358
  Copilot CLI expects flat top-level fields. */
@@ -282,7 +381,7 @@ export default class HookCommand {
282
381
  register(program) {
283
382
  /* default for --tool derived from ASE_TOOL environment variable */
284
383
  const envTool = process.env.ASE_TOOL ?? "";
285
- const toolDflt = envTool !== "" ? envTool : "claude";
384
+ const toolDflt = envTool !== "" ? this.parseTool(envTool) : "claude";
286
385
  /* register CLI top-level command "ase hook" */
287
386
  const hookCmd = program
288
387
  .command("hook")
package/dst/ase-mcp.js CHANGED
@@ -26,9 +26,9 @@ export default class MCPCommand {
26
26
  const projectId = rawId ?? path.basename(process.cwd());
27
27
  const rawPort = svc.get("port");
28
28
  const port = rawPort ?? null;
29
- return { projectId, port, svc };
29
+ return { projectId, port };
30
30
  }
31
- /* spawn "ase service start" detached and wait for it to come up */
31
+ /* run "ase service start" and wait for the service to come up */
32
32
  async ensureService() {
33
33
  let ctx = this.loadContext();
34
34
  /* fast path: already running */
@@ -52,6 +52,10 @@ export default class MCPCommand {
52
52
  throw new Error(`mcp: service not responding on port ${ctx.port} after start`);
53
53
  return { projectId: ctx.projectId, port: ctx.port };
54
54
  }
55
+ /* coerce an unknown thrown value into an Error */
56
+ asError(e) {
57
+ return e instanceof Error ? e : new Error(String(e));
58
+ }
55
59
  /* bridge stdio to a Streamable HTTP MCP endpoint on the local service */
56
60
  async runBridge() {
57
61
  /* ensure the service is running */
@@ -60,8 +64,9 @@ export default class MCPCommand {
60
64
  const server = new StdioServerTransport();
61
65
  /* track active client and bridge-level closed state */
62
66
  let client = null;
63
- let closedByUs = false; /* set when we initiated the client close */
64
- let bridgeDone = false; /* set when stdio side closes */
67
+ let closedByUs = false; /* set when we initiated the client close */
68
+ let bridgeDone = false; /* set when stdio side closes */
69
+ let reconnecting = false; /* set while a reconnect chain is active */
65
70
  /* cleanly shut down the whole bridge */
66
71
  const shutdown = async () => {
67
72
  if (bridgeDone)
@@ -79,11 +84,9 @@ export default class MCPCommand {
79
84
  const connectClient = async () => {
80
85
  const url = new URL(`http://${HOST}:${port}/mcp`);
81
86
  const next = new StreamableHTTPClientTransport(url);
82
- client = next;
83
87
  next.onmessage = (msg) => {
84
- server.send(msg).catch((_err) => {
85
- const err = _err instanceof Error ? _err : new Error(String(_err));
86
- this.log.write("error", `mcp: stdout send: ${err.message}`);
88
+ server.send(msg).catch((err) => {
89
+ this.log.write("error", `mcp: stdout send: ${this.asError(err).message}`);
87
90
  });
88
91
  };
89
92
  next.onerror = (err) => {
@@ -91,12 +94,14 @@ export default class MCPCommand {
91
94
  };
92
95
  /* service closed the connection — try to recover */
93
96
  next.onclose = () => {
94
- if (closedByUs || bridgeDone)
97
+ if (client !== next || closedByUs || bridgeDone || reconnecting)
95
98
  return;
99
+ reconnecting = true;
96
100
  this.log.write("warning", "mcp: http connection lost — reconnecting");
97
- reconnect().catch(() => { });
101
+ reconnect(0, () => { reconnecting = false; }).catch(() => { });
98
102
  };
99
103
  await next.start();
104
+ client = next;
100
105
  };
101
106
  /* reconnect loop: restart service if needed, then reconnect client */
102
107
  const reconnect = async (attempt = 0, done) => {
@@ -111,22 +116,21 @@ export default class MCPCommand {
111
116
  port = ctx.port;
112
117
  closedByUs = true;
113
118
  await client?.close();
114
- closedByUs = false;
115
119
  await connectClient();
120
+ closedByUs = false;
116
121
  this.log.write("info", "mcp: reconnected to service");
117
122
  done?.();
118
123
  }
119
- catch (_err) {
120
- const err = _err instanceof Error ? _err : new Error(String(_err));
121
- this.log.write("error", `mcp: reconnect failed: ${err.message}`);
124
+ catch (err) {
125
+ closedByUs = false;
126
+ this.log.write("error", `mcp: reconnect failed: ${this.asError(err).message}`);
122
127
  reconnect(attempt + 1, done).catch(() => { });
123
128
  }
124
129
  };
125
130
  /* wire stdio server */
126
131
  server.onmessage = (msg) => {
127
- client?.send(msg).catch((_err) => {
128
- const err = _err instanceof Error ? _err : new Error(String(_err));
129
- this.log.write("error", `mcp: http send: ${err.message}`);
132
+ client?.send(msg).catch((err) => {
133
+ this.log.write("error", `mcp: http send: ${this.asError(err).message}`);
130
134
  });
131
135
  };
132
136
  server.onerror = (err) => {
@@ -140,7 +144,6 @@ export default class MCPCommand {
140
144
  await connectClient();
141
145
  /* periodically probe the service; trigger reconnect if it is gone */
142
146
  const HEALTH_INTERVAL_MS = 30_000;
143
- let reconnecting = false;
144
147
  const healthTimer = setInterval(async () => {
145
148
  if (bridgeDone || reconnecting)
146
149
  return;
@@ -153,7 +156,10 @@ export default class MCPCommand {
153
156
  reconnect(0, () => { reconnecting = false; }).catch(() => { });
154
157
  }
155
158
  }
156
- catch { /* ignore probe errors */ }
159
+ catch (err) {
160
+ /* ignore transient probe/context errors but record them */
161
+ this.log.write("debug", `mcp: health check error: ${this.asError(err).message}`);
162
+ }
157
163
  }, HEALTH_INTERVAL_MS);
158
164
  healthTimer.unref();
159
165
  /* await stdio to be closed */
@@ -165,7 +171,7 @@ export default class MCPCommand {
165
171
  /* shutdown services */
166
172
  clearInterval(healthTimer);
167
173
  await shutdown();
168
- return 0;
174
+ return 0; /* unreachable, kept only to satisfy the Promise<number> return type */
169
175
  }
170
176
  /* register commands */
171
177
  register(program) {
@@ -15,7 +15,7 @@ import prettyMs from "pretty-ms";
15
15
  import * as v from "valibot";
16
16
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
17
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
- import { Config, configSchema } from "./ase-config.js";
18
+ import { Config, configSchema, ConfigMCP } from "./ase-config.js";
19
19
  import { DiagramMCP } from "./ase-diagram.js";
20
20
  import { TaskMCP } from "./ase-task.js";
21
21
  import { KVMCP } from "./ase-kv.js";
@@ -241,6 +241,7 @@ export default class ServiceCommand {
241
241
  new TimestampMCP().register(mcp);
242
242
  new GetoptMCP().register(mcp);
243
243
  new SkillsMCP().register(mcp);
244
+ new ConfigMCP(this.log).register(mcp);
244
245
  return mcp;
245
246
  };
246
247
  /* listen to HTTP/REST endpoints */
@@ -257,7 +258,13 @@ export default class ServiceCommand {
257
258
  handler: (_request, h) => {
258
259
  this.log.write("info", "service: stop requested");
259
260
  setImmediate(async () => {
260
- await server.stop({ timeout: 1000 });
261
+ try {
262
+ await server.stop({ timeout: 1000 });
263
+ }
264
+ catch (err) {
265
+ const e = err;
266
+ this.log.write("error", `service: stop failed: ${e.message}`);
267
+ }
261
268
  process.exit(0);
262
269
  });
263
270
  return h.response({ ok: true }).code(200);
@@ -438,7 +445,6 @@ export default class ServiceCommand {
438
445
  lastErr = new Error(`${reason}${detail}`);
439
446
  }
440
447
  finally {
441
- child.removeListener("exit", onExit);
442
448
  if (!success && !exited) {
443
449
  child.kill("SIGTERM");
444
450
  await Promise.race([
@@ -454,6 +460,7 @@ export default class ServiceCommand {
454
460
  child.unref();
455
461
  }
456
462
  }
463
+ child.removeListener("exit", onExit);
457
464
  if (!success)
458
465
  Service.clearPort(ctx.svc);
459
466
  }