@rse/ase 2.0.6 → 2.0.8

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 (68) hide show
  1. package/dst/ase-agent-definition-api.d.ts +2 -0
  2. package/dst/ase-agent-definition-core.js +24 -3
  3. package/dst/ase-agent-fleet.js +2 -1
  4. package/dst/ase-agent-relay-client.js +3 -4
  5. package/dst/ase-agent-runtime-engine.js +22 -4
  6. package/dst/ase-agent-runtime-tool.js +10 -8
  7. package/dst/ase-agent-runtime.js +8 -4
  8. package/dst/ase-config-schema.js +4 -1
  9. package/dst/ase-lib-fetch.js +12 -0
  10. package/dst/ase-lib-server-cli.js +8 -5
  11. package/dst/ase-man-catalog.js +1 -1
  12. package/dst/ase-task-board-diff.js +120 -0
  13. package/dst/ase-task-board-tui-control.js +8 -5
  14. package/dst/ase-task-board-tui-model.js +54 -14
  15. package/dst/ase-task-board-tui-popup.js +41 -8
  16. package/dst/ase-task-board-tui.js +42 -27
  17. package/dst/ase-task-board-web-client.css +1 -1
  18. package/dst/ase-task-board-web-client.html +16 -0
  19. package/dst/ase-task-board-web-client.js +33 -33
  20. package/dst/ase-task-board-web-server.js +44 -9
  21. package/dst/ase-task-board.js +12 -4
  22. package/dst/ase-task-store-client.js +4 -5
  23. package/dst/ase-task-store-plugin-gitea.js +3 -3
  24. package/dst/man/ase-agent-adhoc.md +25 -6
  25. package/dst/man/ase-agent-condenser.md +7 -0
  26. package/dst/man/ase-agent-editor.md +7 -0
  27. package/dst/man/ase-agent-estimator.md +7 -0
  28. package/dst/man/ase-agent-fleet.md +7 -4
  29. package/dst/man/ase-agent-implementer.md +7 -0
  30. package/dst/man/ase-agent-integrator.md +7 -0
  31. package/dst/man/ase-agent-qualifier.md +7 -0
  32. package/dst/man/ase-agent-rebooter.md +7 -0
  33. package/dst/man/ase-agent-researcher.md +7 -0
  34. package/dst/man/ase-config-init.md +2 -1
  35. package/dst/man/ase-config.md +5 -0
  36. package/dst/man/ase-task-board.md +44 -2
  37. package/package.json +11 -10
  38. package/plugin/.claude-plugin/plugin.json +1 -1
  39. package/plugin/.codex-plugin/plugin.json +1 -1
  40. package/plugin/.github/plugin/plugin.json +1 -1
  41. package/plugin/meta/ase-common-task.md +33 -1
  42. package/plugin/meta/ase-control.md +14 -0
  43. package/plugin/meta/ase-format-specbook.yaml +9 -9
  44. package/plugin/meta/ase-skill.md +7 -0
  45. package/plugin/mods/ase-mods-common.ts +73 -42
  46. package/plugin/mods/ase-mods-help-hint.ts +1 -1
  47. package/plugin/mods/ase-mods-task-edit.ts +427 -0
  48. package/plugin/mods/ase-mods.ts +45 -14
  49. package/plugin/skills/ase-code-edit/SKILL.md +53 -16
  50. package/plugin/skills/ase-code-edit/help.md +26 -13
  51. package/plugin/skills/ase-code-lint/SKILL.md +7 -3
  52. package/plugin/skills/ase-help-intent/data.md +248 -36
  53. package/plugin/skills/ase-help-skill/catalog.md +1 -0
  54. package/plugin/skills/ase-meta-workflow/workflow.txt +2 -0
  55. package/plugin/skills/ase-repo-changelog/SKILL.md +29 -10
  56. package/plugin/skills/ase-repo-changelog/help.md +5 -1
  57. package/plugin/skills/ase-repo-commit/SKILL.md +4 -1
  58. package/plugin/skills/ase-repo-dissect/help.md +2 -1
  59. package/plugin/skills/ase-repo-stage/SKILL.md +459 -0
  60. package/plugin/skills/ase-repo-stage/help.md +120 -0
  61. package/plugin/skills/ase-spec-edit/SKILL.md +20 -12
  62. package/plugin/skills/ase-spec-edit/help.md +22 -14
  63. package/plugin/skills/ase-task-edit/SKILL.md +114 -9
  64. package/plugin/skills/ase-task-edit/help.md +60 -0
  65. package/plugin/skills/ase-task-grill/SKILL.md +35 -20
  66. package/plugin/skills/ase-task-grill/help.md +18 -8
  67. /package/plugin/mods/{ase-mods-evaluate.ts → ase-mods-meta-evaluate.ts} +0 -0
  68. /package/plugin/mods/{ase-mods-grill.ts → ase-mods-task-grill.ts} +0 -0
@@ -18,6 +18,7 @@ export interface AgentDefinition {
18
18
  lifecycle?: Many<string>;
19
19
  serial?: boolean;
20
20
  unclaimed?: boolean;
21
+ model?: string;
21
22
  state?: {
22
23
  incoming?: Many<string>;
23
24
  activity?: string;
@@ -175,6 +176,7 @@ export interface AgentHooks {
175
176
  ready?: (task: AgentHookTask, ctx: AgentHookContext) => Async<boolean | string>;
176
177
  prepare?: (task: AgentHookTask, job: AgentHookJob, ctx: AgentHookContext) => Async<void>;
177
178
  prompt?: (task: AgentHookTask, prompt: AgentHookPrompt, job: AgentHookJob, ctx: AgentHookContext) => Async<AgentHookPrompt | string | undefined>;
179
+ model?: (task: AgentHookTask, model: string, job: AgentHookJob, ctx: AgentHookContext) => Async<string | undefined>;
178
180
  verdict?: (task: AgentHookTask, result: AgentHookResult, ctx: AgentHookContext) => Async<string | undefined>;
179
181
  success?: (task: AgentHookTask, result: AgentHookResult, ctx: AgentHookContext) => Async<boolean | string>;
180
182
  settle?: (task: AgentHookTask, outcome: AgentHookOutcome, ctx: AgentHookContext) => Async<AgentHookSettlement | undefined>;
@@ -92,6 +92,9 @@ export const definitionOptions = [
92
92
  opt("unclaimed", "flag", "", "process the tasks of the incoming states (each adapted onto the " +
93
93
  "task lifecycle model) without claiming them, independent of their assignee and predecessors, " +
94
94
  "and without ever changing their status and assignee (for \"phase\" only)"),
95
+ opt("model", "value", "model", "model of the agent tool: a model for all tasks, or comma-separated " +
96
+ "\"<size>=<model>\" pairs per \"Effort:\" size (\"XS\", \"S\", \"M\", \"L\", \"XL\", or \"*\" for all other tasks) " +
97
+ "(default: the default model of the agent tool)"),
95
98
  opt("params", "list", "name=value", "value of a parameter declared by the definition (repeatable)"),
96
99
  opt("state-incoming", "list", "state", "state of the tasks to claim (mandatory for \"phase\", " +
97
100
  "repeatable with \"--unclaimed\" or \"--state-activity\" only)"),
@@ -272,7 +275,7 @@ export const flattenDefinition = (data, where) => {
272
275
  return flat;
273
276
  };
274
277
  /* the names of the hooks of a definition (see "AgentHooks") */
275
- const hookNames = ["start", "order", "passes", "ready", "prepare", "prompt", "verdict", "success", "settle", "finish", "stop"];
278
+ const hookNames = ["start", "order", "passes", "ready", "prepare", "prompt", "model", "verdict", "success", "settle", "finish", "stop"];
276
279
  /* normalize the parameter declarations <spec> of a definition (see "AgentParam"),
277
280
  where <where> names the definition in errors */
278
281
  const definitionParams = (spec, where) => {
@@ -741,6 +744,24 @@ export const resolveDefinition = async (opts, name) => {
741
744
  if (loaded.hooks.passes !== undefined)
742
745
  fail("definition", "hook \"passes\" is not for \"--unclaimed\" (as an unclaimed task never waits for its predecessors)");
743
746
  }
747
+ /* the model of the agent tool: a plain model (for all tasks, as size "*"),
748
+ or the models per "Effort:" size, where none means the default of the tool */
749
+ const modelSpec = str("model").trim();
750
+ const sizes = {};
751
+ if (modelSpec !== "" && !modelSpec.includes("="))
752
+ sizes["*"] = modelSpec;
753
+ else if (modelSpec !== "") {
754
+ for (const pair of modelSpec.split(",").map((item) => item.trim())) {
755
+ const m = /^(XS|S|M|L|XL|\*)=(\S+)$/.exec(pair);
756
+ if (m === null)
757
+ fail("model", `invalid pair "${pair}" (expected "<size>=<model>" with size "XS", "S", "M", "L", "XL", or "*")`);
758
+ else if (Object.hasOwn(sizes, m[1]))
759
+ fail("model", `size "${m[1]}" given twice`);
760
+ else
761
+ sizes[m[1]] = m[2];
762
+ }
763
+ }
764
+ const model = { spec: modelSpec, sizes };
744
765
  /* whether a verdict is determined (by "--verdict-label" or the hook "verdict") */
745
766
  const verdicting = has("verdict-label") || loaded.hooks.verdict !== undefined;
746
767
  /* the states (see "resolveStates") */
@@ -821,10 +842,10 @@ export const resolveDefinition = async (opts, name) => {
821
842
  hence the prompt of the request), resp. uses them optionally by "{prompt}" used only
822
843
  within the conditional "{?prompt:...}", else ignores them */
823
844
  const consumes = type === "request" && (template.required.has("prompt")
824
- || ["prepare", "prompt", "finish"].some((hook) => loaded.hooks[hook] !== undefined)) ? "required" :
845
+ || ["prepare", "prompt", "model", "finish"].some((hook) => loaded.hooks[hook] !== undefined)) ? "required" :
825
846
  type === "request" && template.names.has("prompt") ? "optional" : "ignored";
826
847
  return {
827
- name, title, description, type, workdir, lifecycles, unclaimed,
848
+ name, title, description, type, workdir, lifecycles, unclaimed, model,
828
849
  serial: flag("serial"),
829
850
  state: { incoming, activity, success, failure, phase, passed, verdict: verdicts },
830
851
  tag: {
@@ -45,7 +45,7 @@ const logLevelRe = /^([^|]*\| [^:]+: \[[^\]]+\]: )\[(ERROR|WARNING|INFO|DEBUG)\]
45
45
  agents, and applied to an agent only if it supports them) */
46
46
  const fleetKeys = ["foreground"];
47
47
  const relayKeys = ["address", "port", "token", "cors", "tls-cert", "tls-key"];
48
- const defaultKeys = ["tool", "parallel", "timeout", "repo", "tempdir", "agent-relay"];
48
+ const defaultKeys = ["tool", "model", "parallel", "timeout", "repo", "tempdir", "agent-relay"];
49
49
  /* the options not allowed in the section of an agent, as the fleet decides on them */
50
50
  const deniedKeys = ["name", "one-shot", "foreground", "help"];
51
51
  /* the state of the planned tasks per task lifecycle model, as estimated by the estimator */
@@ -76,6 +76,7 @@ const initConfig = (tool, lifecycle) => [
76
76
  # (each applied only to the agents supporting it, where an empty value omits it)
77
77
  default:
78
78
  tool: ${tool.padEnd(16)}# agent tool ("claude", "copilot", or "codex")
79
+ #model: <model> # model of the agent tool (or "<size>=<model>" pairs per "Effort:" size)
79
80
  parallel: 1 # maximum number of tasks processed in parallel
80
81
  timeout: 60m # maximum duration of processing a task (<number><unit> with unit "s", "m", or "h")
81
82
  #repo: <url> # Git repository to clone temporarily for the tasks without "worktree"
@@ -4,8 +4,6 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import os from "node:os";
7
- import { ofetch } from "ofetch";
8
- import { Agent } from "undici";
9
7
  import * as v from "valibot";
10
8
  import { WebSocket } from "ws";
11
9
  import { isScalar } from "yaml";
@@ -14,6 +12,7 @@ import { configSchema } from "./ase-config-schema.js";
14
12
  import { parseScope } from "./ase-config-scope.js";
15
13
  import * as TaskFormat from "./ase-task-format.js";
16
14
  import { urlHost, isLoopback } from "./ase-lib-server-bind.js";
15
+ import { dispatchFetch, insecureAgent } from "./ase-lib-fetch.js";
17
16
  import { storeSchema } from "./ase-task-store-server-cli.js";
18
17
  import { relaySchema } from "./ase-agent-relay-server-cli.js";
19
18
  import * as Relay from "./ase-agent-relay-core.js";
@@ -181,7 +180,7 @@ export class AgentRelayClient {
181
180
  constructor(log, spec) {
182
181
  this.log = log;
183
182
  this.spec = spec;
184
- this.dispatcher = spec.insecure ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined;
183
+ this.dispatcher = spec.insecure ? insecureAgent() : undefined;
185
184
  }
186
185
  /* the base URL of the agent relay */
187
186
  get base() {
@@ -189,7 +188,7 @@ export class AgentRelayClient {
189
188
  }
190
189
  /* perform a request, raising an error response or a failed connection */
191
190
  async request(method, url, body) {
192
- const r = await ofetch.raw(`${this.spec.base}/projects/${this.spec.prjId}/agents${url}`, {
191
+ const r = await dispatchFetch.raw(`${this.spec.base}/projects/${this.spec.prjId}/agents${url}`, {
193
192
  method,
194
193
  headers: {
195
194
  Authorization: `Bearer ${this.spec.token}`,
@@ -45,6 +45,15 @@ const promptOf = (value, configured) => {
45
45
  }
46
46
  throw new Error("invalid prompt (expected a string, \"{ text }\", or \"{ skill, args }\")");
47
47
  };
48
+ /* the model returned by the hook "model", validated, where undefined
49
+ keeps the configured model <configured> */
50
+ const modelOf = (value, configured) => {
51
+ if (value === undefined)
52
+ return configured;
53
+ else if (typeof value !== "string")
54
+ throw new Error("invalid model (expected a string)");
55
+ return value.trim();
56
+ };
48
57
  /* whether a record of an agent of a definition succeeded (where a missing one counts as failure) */
49
58
  const recordSuccess = (record) => record !== undefined && record.fixed === "" && record.checks.every((check) => check.ok);
50
59
  /* escape a text for its literal use in a regular expression */
@@ -331,7 +340,7 @@ class DefinedCore {
331
340
  }
332
341
  /* prepare the job of a task in its work <work>: render the prompt (with the request
333
342
  prompt <prompt> of a request agent), run the preparing commands, and run the hooks
334
- "prepare" and "prompt", where the work is cleaned up again on a failure, and where
343
+ "prepare", "prompt", and "model", where the work is cleaned up again on a failure, and where
335
344
  <changed> decides preliminarily whether the plan content of a request agent changed */
336
345
  async prepare(task, log, work, prompt, changed) {
337
346
  try {
@@ -374,7 +383,7 @@ class DefinedCore {
374
383
  throw err;
375
384
  }
376
385
  }
377
- /* run the hooks "prepare" and "prompt", where a failed hook fails the processing */
386
+ /* run the hooks "prepare", "prompt", and "model", where a failed hook fails the processing */
378
387
  const job = Object.freeze({
379
388
  dir: work.cwd,
380
389
  dirs: Object.freeze([...work.dirs]),
@@ -392,7 +401,7 @@ class DefinedCore {
392
401
  throw new Error(`agent: hook "${name}" failed: ${reason(err)}`, { cause: err });
393
402
  }
394
403
  };
395
- const { prepare: prepareHook, prompt: promptHook } = this.def.hooks;
404
+ const { prepare: prepareHook, prompt: promptHook, model: modelHook } = this.def.hooks;
396
405
  if (prepareHook !== undefined)
397
406
  await hooked("prepare", async () => prepareHook(this.task(task), job, ctx));
398
407
  let spec = this.def.prompt.skill !== "" ?
@@ -401,10 +410,17 @@ class DefinedCore {
401
410
  const configured = Object.freeze(spec);
402
411
  spec = await hooked("prompt", async () => promptOf(await promptHook(this.task(task), configured, job, ctx), configured));
403
412
  }
413
+ const { sizes } = this.def.model;
414
+ let model = sizes[TaskFormat.parseEffort(task.keys.get("Effort") ?? "")?.size ?? "*"] ?? sizes["*"] ?? "";
415
+ if (modelHook !== undefined) {
416
+ const configured = model;
417
+ model = await hooked("model", async () => modelOf(await modelHook(this.task(task), configured, job, ctx), configured));
418
+ }
404
419
  return {
405
420
  ...("skill" in spec ?
406
421
  { cmd: `${spec.skill}${spec.args !== "" ? ` ${spec.args}` : ""}` } :
407
422
  { prompt: spec.text }),
423
+ model,
408
424
  cwd: work.cwd,
409
425
  dirs: work.dirs,
410
426
  finish: (log, result) => this.finish(task, log, result, work, job, env, before, changed),
@@ -714,6 +730,7 @@ class DefinedPhaseAgent extends PhaseAgent {
714
730
  };
715
731
  this.presenceTitle = def.title;
716
732
  this.presenceDesc = def.description;
733
+ this.modelSpec = def.model.spec;
717
734
  this.assigned = def.assign.required;
718
735
  this.finishing = def.finish.commit || def.finish.success !== "" || def.workdir === "target"
719
736
  || def.hooks.finish !== undefined;
@@ -858,6 +875,7 @@ class DefinedRequestAgent extends RequestAgent {
858
875
  this.presencePrompt = def.request.prompt;
859
876
  this.presenceTitle = def.title;
860
877
  this.presenceDesc = def.description;
878
+ this.modelSpec = def.model.spec;
861
879
  this.serial = def.serial;
862
880
  this.worktree = def.workdir !== "project";
863
881
  this.lifecycles = def.lifecycles;
@@ -950,7 +968,7 @@ export class DefinedAgent extends Agent {
950
968
  for (const option of definitionOptions) {
951
969
  if (option.key === "definition" && !definition)
952
970
  continue;
953
- const flags = (option.key === "definition" ? "-D, " : "") + `--${option.key}` +
971
+ const flags = (option.key === "definition" ? "-D, " : option.key === "model" ? "-M, " : "") + `--${option.key}` +
954
972
  (option.kind !== "flag" ? ` <${option.arg}>` : "");
955
973
  if (option.kind === "list")
956
974
  cmd.option(flags, option.desc, (value, prev) => [...(prev ?? []), value]);
@@ -8,20 +8,22 @@ import { stripVTControlCharacters } from "node:util";
8
8
  of the task (as an agent message can be huge, e.g. the rendering of the whole task plan,
9
9
  which would otherwise be fed back into the next rendering of the task plan) */
10
10
  const TEXT_MAX = 20;
11
+ /* the "--model" option of all tools for the model <model> (none for the default model) */
12
+ const modelArgs = (model) => model !== "" ? ["--model", model] : [];
11
13
  /* the per-tool headless invocation of the rendered prompt <prompt> with access to the
12
- additional directories <add> (the "--add-dir" options), streaming JSONL events,
13
- where risky actions are denied or automatically reviewed instead of being prompted for
14
+ additional directories <add> (the "--add-dir" options) and the model <model>, streaming JSONL
15
+ events, where risky actions are denied or automatically reviewed instead of being prompted for
14
16
  (the variadic "--add-dir" of Claude has to precede the other options to not consume the prompt) */
15
17
  export const toolArgs = {
16
- "claude": (prompt, add) => [
17
- ...add, "-p", "--output-format", "stream-json", "--verbose", "--disallowedTools", "AskUserQuestion",
18
+ "claude": (prompt, add, model) => [
19
+ ...add, ...modelArgs(model), "-p", "--output-format", "stream-json", "--verbose", "--disallowedTools", "AskUserQuestion",
18
20
  "--permission-mode", "auto", "--permission-prompts", "none", prompt
19
21
  ],
20
- "copilot": (prompt, add) => [
21
- ...add, "--allow-all-tools", "--no-ask-user", "--no-color", "--output-format", "json", "-p", prompt
22
+ "copilot": (prompt, add, model) => [
23
+ ...add, ...modelArgs(model), "--allow-all-tools", "--no-ask-user", "--no-color", "--output-format", "json", "-p", prompt
22
24
  ],
23
- "codex": (prompt, add) => [
24
- "exec", ...add, "--json", "--approve-for-me", "--color", "never", prompt
25
+ "codex": (prompt, add, model) => [
26
+ "exec", ...add, ...modelArgs(model), "--json", "--approve-for-me", "--color", "never", prompt
25
27
  ]
26
28
  };
27
29
  /* the per-tool rendering of the invocation of the skill <skill> with the arguments <args>:
@@ -145,6 +145,8 @@ export class Agent {
145
145
  /* the title and the description of the operation of the agent, as announced in its presence (default: none) */
146
146
  presenceTitle = "";
147
147
  presenceDesc = "";
148
+ /* the configured model of the agent tool, as shown in the start log line (default: none) */
149
+ modelSpec = "";
148
150
  /* whether tasks are processed serially only (clamping "--parallel" to 1, and
149
151
  refusing multiple agents of the kind in a fleet, see "ase agent fleet") */
150
152
  serial = false;
@@ -840,7 +842,8 @@ export class Agent {
840
842
  const cli = toolSpecs[tool].cli;
841
843
  const cmd = job.cmd ?? "";
842
844
  const i = cmd.indexOf(" ");
843
- const args = toolArgs[tool](job.prompt ?? skillPrompt(tool, i < 0 ? cmd : cmd.slice(0, i), i < 0 ? "" : cmd.slice(i + 1)), [...new Set(job.dirs ?? [])].filter((dir) => dir !== cwd).flatMap((dir) => ["--add-dir", dir]));
845
+ const model = job.model ?? "";
846
+ const args = toolArgs[tool](job.prompt ?? skillPrompt(tool, i < 0 ? cmd : cmd.slice(0, i), i < 0 ? "" : cmd.slice(i + 1)), [...new Set(job.dirs ?? [])].filter((dir) => dir !== cwd).flatMap((dir) => ["--add-dir", dir]), model);
844
847
  await log("info", `running: ${cli} ${args.map((arg) => arg.includes(" ") ? `"${arg}"` : arg).join(" ")}`);
845
848
  const started = Date.now();
846
849
  const costs = { costToken: null, costUSD: null, cread: 0, cwrite: 0, input: 0, apiMs: null };
@@ -881,7 +884,7 @@ export class Agent {
881
884
  this.presence?.used(usage);
882
885
  /* book the usage onto the task, if the AI usage accounting is enabled ("telemetry.enabled"),
883
886
  (the AI request time falling back to the wall time and an unreported cost to the price
884
- table entry of the tool, as its model is unknown), without any human time and costs,
887
+ table entry of the given model, else of the tool), without any human time and costs,
885
888
  as the headless agent tool runs unattended; the times are booked in rounded minutes,
886
889
  with the (signed) rounding remainder carried over to the next booking onto the task,
887
890
  where a failed booking is only reported */
@@ -894,7 +897,8 @@ export class Agent {
894
897
  if (cost === null) {
895
898
  const cfg = new Config("config", configSchema, this.log);
896
899
  cfg.read();
897
- cost = tokenCost(modelPrice(parsePrices(String(cfg.get("telemetry.prices.ai") ?? "")), tool), tokens);
900
+ const prices = parsePrices(String(cfg.get("telemetry.prices.ai") ?? ""));
901
+ cost = tokenCost(modelPrice(prices, model !== "" && prices.has(model) ? model : tool), tokens);
898
902
  }
899
903
  const carry = this.unbooked.get(taskId) ?? { aiMs: 0, realMs: 0 };
900
904
  const aiMs = carry.aiMs + (costs.apiMs ?? walltimeMs);
@@ -1250,7 +1254,7 @@ export class Agent {
1250
1254
  if (this.repo !== "")
1251
1255
  await this.cloneRepo();
1252
1256
  await this.write("info", this.name, `observing ${this.observing()} ` +
1253
- `(tool: ${tool}, parallel: ${parallel}, timeout: ${this.timeoutSpec}${this.oneShot ? ", one-shot" : ""})`);
1257
+ `(tool: ${tool}${this.modelSpec !== "" ? `, model: ${this.modelSpec}` : ""}, parallel: ${parallel}, timeout: ${this.timeoutSpec}${this.oneShot ? ", one-shot" : ""})`);
1254
1258
  let finished = false;
1255
1259
  let failure = null;
1256
1260
  while (!abort.signal.aborted && !finished) {
@@ -78,6 +78,7 @@ export const projectClassificationPresets = {
78
78
  "board.web.color.accent": webColorDefaults.accent,
79
79
  "board.web.color.signal": webColorDefaults.signal,
80
80
  "board.web.editor.keymap": "default",
81
+ "board.web.theme": "auto",
81
82
  "telemetry.enabled": "false",
82
83
  "telemetry.implicit": "true",
83
84
  "telemetry.prices.ai": "*=4.00/20.00/0.20/5.00",
@@ -131,6 +132,7 @@ export const configWritableScopes = {
131
132
  "board.web.color.accent": ["user", "project"],
132
133
  "board.web.color.signal": ["user", "project"],
133
134
  "board.web.editor.keymap": ["user", "project"],
135
+ "board.web.theme": ["user", "project"],
134
136
  "telemetry.enabled": ["user", "project"],
135
137
  "telemetry.implicit": ["user", "project"],
136
138
  "telemetry.service.port": ["user"],
@@ -337,7 +339,8 @@ export const configSchema = v.nullish(v.strictObject({
337
339
  })),
338
340
  editor: v.optional(v.strictObject({
339
341
  keymap: v.optional(v.picklist(["default", "vim", "emacs"]))
340
- }))
342
+ })),
343
+ theme: v.optional(v.picklist(["auto", "light", "dark"]))
341
344
  }))
342
345
  }))
343
346
  }));
@@ -0,0 +1,12 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import { ofetch } from "ofetch";
7
+ import { Agent, fetch as undiciFetch } from "undici";
8
+ /* the fetch for requests with a dispatcher of the npm "undici": its own fetch, as the
9
+ built-in fetch of Node (its bundled, possibly older undici) rejects a foreign dispatcher */
10
+ export const dispatchFetch = ofetch.create({}, { fetch: undiciFetch });
11
+ /* create a dispatcher skipping the TLS certificate verification */
12
+ export const insecureAgent = () => new Agent({ connect: { rejectUnauthorized: false } });
@@ -7,20 +7,19 @@ import path from "node:path";
7
7
  import fs from "node:fs";
8
8
  import crypto from "node:crypto";
9
9
  import os from "node:os";
10
- import { ofetch } from "ofetch";
11
- import { Agent } from "undici";
12
10
  import { isSeq, isScalar } from "yaml";
13
11
  import { Config } from "./ase-config-core.js";
14
12
  import { parseScope } from "./ase-config-scope.js";
15
13
  import { isLogLevel } from "./ase-lib-log.js";
16
14
  import { writeStdout } from "./ase-lib-stdio.js";
15
+ import { dispatchFetch, insecureAgent } from "./ase-lib-fetch.js";
17
16
  import { Daemon, DaemonCommand } from "./ase-lib-daemon-cli.js";
18
17
  import { urlHost, isLoopback, serverHeader } from "./ase-lib-server-bind.js";
19
18
  /* the defaults */
20
19
  export const DEFAULT_ADDRESS = "127.0.0.1";
21
20
  /* the dispatcher of the host-local probe, skipping the certificate verification,
22
21
  as a certificate rarely covers a loopback or local interface address */
23
- const probeAgent = new Agent({ connect: { rejectUnauthorized: false } });
22
+ const probeAgent = insecureAgent();
24
23
  /* whether an address belongs to this host (loopback or a local network interface) */
25
24
  export const isLocal = (address) => isLoopback(address) || Object.values(os.networkInterfaces())
26
25
  .some((ifaces) => (ifaces ?? []).some((iface) => iface.address === address));
@@ -146,7 +145,7 @@ export class ServerCommand extends DaemonCommand {
146
145
  /* skip the certificate verification for host-local addresses only,
147
146
  as elsewhere a man-in-the-middle could capture the token */
148
147
  const lax = isLocal(host.replace(/^\[|\]$/g, ""));
149
- const request = (auth) => ofetch.raw(url, {
148
+ const request = (auth) => dispatchFetch.raw(url, {
150
149
  method: "GET",
151
150
  headers: auth !== null ? { Authorization: `Bearer ${auth}` } : {},
152
151
  dispatcher: lax ? probeAgent : undefined,
@@ -170,8 +169,12 @@ export class ServerCommand extends DaemonCommand {
170
169
  return "running";
171
170
  return "foreign";
172
171
  }
173
- catch {
172
+ catch (err) {
174
173
  /* treat an unreachable or unresponsive server as not running */
174
+ let cause = err;
175
+ while (cause instanceof Error && cause.cause instanceof Error)
176
+ cause = cause.cause;
177
+ this.log.write("debug", `${this.label}: probe of ${url} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
175
178
  return null;
176
179
  }
177
180
  }
@@ -24,7 +24,7 @@ const skillGroups = [
24
24
  ["SPEC", ["ase-spec-lint", "ase-spec-edit", "ase-spec-activate"]],
25
25
  ["CODE", ["ase-code-edit", "ase-code-analyze", "ase-code-insight", "ase-code-explain", "ase-code-lint"]],
26
26
  ["REPO", ["ase-repo-review", "ase-repo-diff", "ase-repo-changelog", "ase-repo-commit",
27
- "ase-repo-dissect", "ase-repo-merge", "ase-repo-resolve"]],
27
+ "ase-repo-dissect", "ase-repo-stage", "ase-repo-merge", "ase-repo-resolve"]],
28
28
  ["SYNC", ["ase-sync-reconcile", "ase-sync-import", "ase-sync-export"]],
29
29
  ["TASK: MANAGE", ["ase-task-list", "ase-task-rename", "ase-task-delete"]],
30
30
  ["TASK: HANDLE", ["ase-task-id", "ase-task-status", "ase-task-assign"]],
@@ -0,0 +1,120 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+ import { diffArrays } from "diff";
7
+ /* the changed share of the words up to which they are emphasized, the Markdown
8
+ syntax characters never being emphasized (as this would break their constructs),
9
+ the closing placeholder of a change mark, and the leading syntax of a Markdown
10
+ line (block quote, heading, or list marker) and its checkbox, staying outside of
11
+ its mark resp. of its emphasized words */
12
+ const ALIKE = 0.4;
13
+ const SYNTAX = /^[*`~|\\[\]()<>#!]$/;
14
+ const CLOSE = "⟦/chg⟧";
15
+ const HEAD = /^\s*(?:>\s*)*(?:#{1,6}[ \t]+|(?:[-*+]|\d+[.)])[ \t]+)?/;
16
+ const BOX = /^\[.\][ \t]+/;
17
+ /* split two lists of items, compared by their keys, into the parts of their diff */
18
+ export const diffHunks = (before, after, key) => {
19
+ const hunks = [];
20
+ let i = 0;
21
+ let j = 0;
22
+ for (const { added, removed, count } of diffArrays(before.map(key), after.map(key))) {
23
+ if (!added && !removed) {
24
+ hunks.push({ kept: after.slice(j, j + count), removed: [], added: [] });
25
+ i += count;
26
+ j += count;
27
+ continue;
28
+ }
29
+ let hunk = hunks.at(-1);
30
+ if (hunk === undefined || hunk.kept.length > 0) {
31
+ hunk = { kept: [], removed: [], added: [] };
32
+ hunks.push(hunk);
33
+ }
34
+ if (removed) {
35
+ hunk.removed.push(...before.slice(i, i + count));
36
+ i += count;
37
+ }
38
+ else {
39
+ hunk.added.push(...after.slice(j, j + count));
40
+ j += count;
41
+ }
42
+ }
43
+ return hunks;
44
+ };
45
+ const tokens = (lines) => lines.flatMap((line, row) => [...line.matchAll(/[\p{L}\p{N}_]+|\S/gu)].map((m) => ({ text: m[0], row, from: m.index, to: m.index + m[0].length })));
46
+ /* determine the changed words of the removed and added lines of a change (joined with
47
+ the blanks between adjacent ones), unless too many of the words changed, where the
48
+ words alone decide, so a change of the wrapping only emphasizes nothing */
49
+ export const changedWords = (removed, added) => {
50
+ const a = tokens(removed);
51
+ const b = tokens(added);
52
+ const del = removed.map(() => []);
53
+ const add = added.map(() => []);
54
+ const changes = diffArrays(a.map((token) => token.text), b.map((token) => token.text));
55
+ const changed = changes.reduce((n, change) => n + (change.added || change.removed ? change.count : 0), 0);
56
+ if (changed > (a.length + b.length) * ALIKE)
57
+ return [del, add];
58
+ const mark = (list, spans) => {
59
+ let last;
60
+ for (const token of list) {
61
+ const row = spans[token.row];
62
+ if (SYNTAX.test(token.text))
63
+ last = undefined;
64
+ else {
65
+ if (last?.row === token.row)
66
+ row[row.length - 1].to = token.to;
67
+ else
68
+ row.push({ from: token.from, to: token.to });
69
+ last = token;
70
+ }
71
+ }
72
+ };
73
+ let i = 0;
74
+ let j = 0;
75
+ for (const { added: plus, removed: minus, count } of changes) {
76
+ if (minus)
77
+ mark(a.slice(i, i + count), del);
78
+ else if (plus)
79
+ mark(b.slice(j, j + count), add);
80
+ i += plus ? 0 : count;
81
+ j += minus ? 0 : count;
82
+ }
83
+ return [del, add];
84
+ };
85
+ /* mark a changed Markdown line and its changed words through placeholders surviving
86
+ the Markdown rendering: fences and lines of pure syntax stay unmarked (as a
87
+ mark would break them), and the cells of a table row are marked individually */
88
+ const markLine = (line, kind, spans) => {
89
+ if (!/[\p{L}\p{N}]/u.test(line) || /^\s*(?:```|~~~)/.test(line))
90
+ return line;
91
+ if (/^\s*\|/.test(line))
92
+ return line.split(/(?<!\\)\|/).map((cell) => cell.replace(/\S(?:.*\S)?/, (text) => `⟦chg:${kind}⟧${text}${CLOSE}`)).join("|");
93
+ const head = HEAD.exec(line)[0];
94
+ const rest = line.slice(head.length);
95
+ const box = BOX.exec(rest)?.[0] ?? "";
96
+ let text = rest.slice(box.length);
97
+ for (const { from, to } of [...spans].reverse())
98
+ text = `${text.slice(0, from)}⟦chg:${kind}-word⟧${text.slice(from, to)}${CLOSE}${text.slice(to)}`;
99
+ return `${head}⟦chg:${kind}⟧${box}${text}${CLOSE}`;
100
+ };
101
+ /* the text of a Markdown line, without its leading syntax and its checkbox */
102
+ const lineText = (line) => {
103
+ const rest = line.slice(HEAD.exec(line)[0].length);
104
+ return rest.slice(BOX.exec(rest)?.[0].length ?? 0);
105
+ };
106
+ /* merge two Markdown texts into one, with the removed before the added lines of a
107
+ change and with both marked through placeholders (resolved by "diffMarksToHTML") */
108
+ export const diffMarkdown = (before, after) => diffHunks(before.split(/\r?\n/), after.split(/\r?\n/), (line) => line).flatMap(({ kept, removed, added }) => {
109
+ const [del, add] = changedWords(removed.map(lineText), added.map(lineText));
110
+ return [
111
+ ...kept,
112
+ ...removed.map((line, k) => markLine(line, "del", del[k])),
113
+ ...added.map((line, k) => markLine(line, "add", add[k]))
114
+ ];
115
+ }).join("\n");
116
+ /* resolve the placeholders of the change marks in rendered HTML, where
117
+ the ones which ended up inside a tag (like in a link target) are dropped */
118
+ export const diffMarksToHTML = (html) => html.replace(/<[^>]*⟦[^>]*>/g, (tag) => tag.replace(/⟦\/?chg[^⟧]*⟧/g, ""))
119
+ .replace(/⟦chg:([a-z-]+)⟧/g, (_m, kinds) => `<span class="chg ${kinds.replace(/-/g, " ")}">`)
120
+ .replace(/⟦\/chg⟧/g, "</span>");
@@ -12,7 +12,7 @@ import { refSegs, tabFirst, tabLayout, confirmBox, transferBox, assignBox, promp
12
12
  /* handle a mouse press onto the deletion confirmation, the transfer popup, the assign
13
13
  popup, the prompt popup, or the read dialog, returning whether one of them was open */
14
14
  const handlePopupMouse = (ctx, btn, mx, my) => {
15
- const { columns, rows, all, dialog, setDialog, setNotice, confirm, setConfirm, transfer, setTransfer, assign, setAssign, assignTo, prompt, setPrompt, cancelPrompt, submitPrompt, dialogW, tabLabels, dialogSel, dialogScroll, transferList, remove, transferTo } = ctx;
15
+ const { columns, rows, all, dialog, setDialog, setNotice, confirm, setConfirm, transfer, setTransfer, assign, setAssign, assignTo, prompt, setPrompt, cancelPrompt, submitPrompt, dialogW, tabLabels, dialogSel, dialogScroll, transferList, remove, transferTo, watched } = ctx;
16
16
  if (prompt !== null) {
17
17
  /* a click onto a suggested request name of the drop-down picks it (and switches to the
18
18
  prompt, resp. to the request button if the prompt is ignored), a click onto the request
@@ -85,10 +85,11 @@ const handlePopupMouse = (ctx, btn, mx, my) => {
85
85
  return true;
86
86
  }
87
87
  if (dialog !== null) {
88
- /* a click onto the " X " of the header (the second dialog row) closes */
88
+ /* a click onto the " X " of the header (the second dialog row) closes
89
+ (the task viewer has no " X " at all, as only "q" quits it) */
89
90
  const barX = Math.floor((columns - dialogW) / 2) + 1;
90
91
  const closeX = barX - 1 + dialogW - DIALOG_CLOSE;
91
- if (btn === 0 && my === 1 && mx >= closeX && mx < closeX + 3)
92
+ if (btn === 0 && my === 1 && mx >= closeX && mx < closeX + 3 && watched === undefined)
92
93
  setDialog(null);
93
94
  else if (btn === 0 && my === 3 && mx >= barX && mx < barX + dialogW - 2) {
94
95
  /* a click onto a predecessor or successor id (in the fourth dialog row) jumps to its task view */
@@ -457,12 +458,14 @@ const handlePopupKey = (ctx, input, key) => {
457
458
  };
458
459
  /* handle a key press within the read dialog */
459
460
  const handleDialogKey = (ctx, dialog, input, key) => {
460
- const { rows, setDialog, dialogW, tabLabels, dialogSel, dialogScroll, startEdit } = ctx;
461
+ const { rows, setDialog, dialogW, tabLabels, dialogSel, dialogScroll, dialogDiff, clearDiff, startEdit, watched } = ctx;
461
462
  const page = Math.max(1, rows - DIALOG_CHROME - 2);
462
- if (key.escape || key.return)
463
+ if (watched !== undefined ? input === "q" : key.escape || key.return)
463
464
  setDialog(null);
464
465
  else if (input === "e")
465
466
  startEdit(dialog.id);
467
+ else if (input === "#" && dialogDiff)
468
+ clearDiff();
466
469
  else if (key.leftArrow || key.rightArrow || key.tab) {
467
470
  const tab = Math.max(0, Math.min(tabLabels.length - 1, dialogSel + (key.leftArrow || (key.tab && key.shift) ? -1 : 1)));
468
471
  setDialog({ ...dialog, tab, first: tabFirst(tabLabels, dialog.first, tab, dialogW - 4) });