@rse/ase 2.0.0 → 2.0.2

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 (122) hide show
  1. package/dst/ase-config-schema.js +7 -1
  2. package/dst/ase-config.js +2 -1
  3. package/dst/ase-hook.js +2 -7
  4. package/dst/ase-lib-server-cli.js +6 -5
  5. package/dst/ase-lib-version.js +8 -4
  6. package/dst/ase-service.js +19 -6
  7. package/dst/ase-task-board-core.js +1 -1
  8. package/dst/ase-task-board-tui-control.js +10 -10
  9. package/dst/ase-task-board-tui-model.js +10 -14
  10. package/dst/ase-task-board-tui-view.js +14 -9
  11. package/dst/ase-task-board-web-client.css +1 -1
  12. package/dst/ase-task-board-web-client.js +2 -2
  13. package/dst/ase-task-board-web-server.js +2 -25
  14. package/dst/ase-task-store-issues.js +76 -0
  15. package/dst/ase-task-store-plugin-gitea.js +2 -65
  16. package/dst/ase-task-store-plugin-github.js +2 -65
  17. package/dst/ase-task-store-plugin-gitlab.js +2 -65
  18. package/dst/ase-util-diagram.js +3 -5
  19. package/dst/man/ase-agent-adhoc.1 +1 -1
  20. package/dst/man/ase-agent-condenser.1 +1 -1
  21. package/dst/man/ase-agent-editor.1 +1 -1
  22. package/dst/man/ase-agent-estimator.1 +1 -1
  23. package/dst/man/ase-agent-fleet.1 +1 -1
  24. package/dst/man/ase-agent-implementer.1 +1 -1
  25. package/dst/man/ase-agent-integrator.1 +1 -1
  26. package/dst/man/ase-agent-qualifier.1 +1 -1
  27. package/dst/man/ase-agent-rebooter.1 +1 -1
  28. package/dst/man/ase-agent-relay.1 +1 -1
  29. package/dst/man/ase-agent-researcher.1 +1 -1
  30. package/dst/man/ase-agent.1 +1 -1
  31. package/dst/man/ase-artifact-list.1 +1 -1
  32. package/dst/man/ase-artifact-name.1 +1 -1
  33. package/dst/man/ase-artifact.1 +1 -1
  34. package/dst/man/ase-config-delete.1 +1 -1
  35. package/dst/man/ase-config-edit.1 +1 -1
  36. package/dst/man/ase-config-get.1 +1 -1
  37. package/dst/man/ase-config-init.1 +1 -1
  38. package/dst/man/ase-config-list.1 +1 -1
  39. package/dst/man/ase-config-set.1 +1 -1
  40. package/dst/man/ase-config.1 +7 -1
  41. package/dst/man/ase-hook-permission-request.1 +1 -1
  42. package/dst/man/ase-hook-pre-tool-use.1 +1 -1
  43. package/dst/man/ase-hook-session-end.1 +1 -1
  44. package/dst/man/ase-hook-session-persona.1 +1 -1
  45. package/dst/man/ase-hook-session-start.1 +1 -1
  46. package/dst/man/ase-hook-stop.1 +1 -1
  47. package/dst/man/ase-hook-user-prompt-submit.1 +1 -1
  48. package/dst/man/ase-hook.1 +1 -1
  49. package/dst/man/ase-man.1 +1 -1
  50. package/dst/man/ase-mcp.1 +1 -1
  51. package/dst/man/ase-service-log.1 +1 -1
  52. package/dst/man/ase-service-send.1 +1 -1
  53. package/dst/man/ase-service-start.1 +4 -4
  54. package/dst/man/ase-service-status.1 +1 -1
  55. package/dst/man/ase-service-stop.1 +1 -1
  56. package/dst/man/ase-service.1 +2 -2
  57. package/dst/man/ase-setup-disable.1 +1 -1
  58. package/dst/man/ase-setup-enable.1 +1 -1
  59. package/dst/man/ase-setup-install.1 +1 -1
  60. package/dst/man/ase-setup-mcp.1 +1 -1
  61. package/dst/man/ase-setup-status.1 +1 -1
  62. package/dst/man/ase-setup-statusline.1 +1 -1
  63. package/dst/man/ase-setup-telemetry.1 +1 -1
  64. package/dst/man/ase-setup-uninstall.1 +1 -1
  65. package/dst/man/ase-setup-update.1 +1 -1
  66. package/dst/man/ase-setup.1 +1 -1
  67. package/dst/man/ase-spec-export.1 +1 -1
  68. package/dst/man/ase-spec-lint.1 +1 -1
  69. package/dst/man/ase-spec-preview.1 +1 -1
  70. package/dst/man/ase-spec.1 +1 -1
  71. package/dst/man/ase-statusline.1 +1 -1
  72. package/dst/man/ase-task-assign.1 +1 -1
  73. package/dst/man/ase-task-board.1 +3 -3
  74. package/dst/man/ase-task-delete.1 +1 -1
  75. package/dst/man/ase-task-edit.1 +1 -1
  76. package/dst/man/ase-task-idscheme.1 +1 -1
  77. package/dst/man/ase-task-lifecycle.1 +1 -1
  78. package/dst/man/ase-task-list.1 +1 -1
  79. package/dst/man/ase-task-load.1 +1 -1
  80. package/dst/man/ase-task-newid.1 +1 -1
  81. package/dst/man/ase-task-purge.1 +1 -1
  82. package/dst/man/ase-task-rename.1 +1 -1
  83. package/dst/man/ase-task-request.1 +1 -1
  84. package/dst/man/ase-task-save.1 +1 -1
  85. package/dst/man/ase-task-status.1 +1 -1
  86. package/dst/man/ase-task-store.1 +1 -1
  87. package/dst/man/ase-task-view.1 +1 -1
  88. package/dst/man/ase-task.1 +1 -1
  89. package/dst/man/ase-util-compat.1 +1 -1
  90. package/dst/man/ase-util-diagram.1 +1 -1
  91. package/dst/man/ase-util-meta.1 +1 -1
  92. package/dst/man/ase-util-metric.1 +1 -1
  93. package/dst/man/ase-util-mint.1 +1 -1
  94. package/dst/man/ase-util-worktree.1 +1 -1
  95. package/dst/man/ase-util.1 +1 -1
  96. package/dst/man/ase.1 +1 -1
  97. package/package.json +3 -1
  98. package/plugin/.claude-plugin/plugin.json +1 -1
  99. package/plugin/.codex-plugin/plugin.json +1 -1
  100. package/plugin/.github/plugin/plugin.json +1 -1
  101. package/plugin/meta/ase-common-resolve.md +2 -2
  102. package/plugin/mods/ase-mods.ts +6 -5
  103. package/plugin/skills/ase-arch-analyze/SKILL.md +3 -1
  104. package/plugin/skills/ase-code-analyze/SKILL.md +8 -0
  105. package/plugin/skills/ase-code-edit/SKILL.md +5 -4
  106. package/plugin/skills/ase-code-edit/help.md +1 -1
  107. package/plugin/skills/ase-code-lint/SKILL.md +15 -4
  108. package/plugin/skills/ase-help-intent/data.md +36 -18
  109. package/plugin/skills/ase-help-skill/help.md +3 -4
  110. package/plugin/skills/ase-meta-quorum/SKILL.md +9 -0
  111. package/plugin/skills/ase-meta-quorum/help.md +5 -3
  112. package/plugin/skills/ase-meta-workflow/sample.md +5 -3
  113. package/plugin/skills/ase-repo-changelog/SKILL.md +8 -9
  114. package/plugin/skills/ase-repo-changelog/help.md +2 -2
  115. package/plugin/skills/ase-spec-edit/SKILL.md +2 -1
  116. package/plugin/skills/ase-spec-edit/help.md +2 -1
  117. package/plugin/skills/ase-sync-reconcile/help.md +4 -4
  118. package/plugin/skills/ase-task-dissect/SKILL.md +10 -6
  119. package/plugin/skills/ase-task-grill/help.md +1 -1
  120. package/plugin/skills/ase-task-implement/SKILL.md +58 -1
  121. package/plugin/skills/ase-task-implement/help.md +16 -0
  122. package/plugin/skills/ase-task-list/help.md +2 -2
@@ -114,6 +114,7 @@ export const configWritableScopes = {
114
114
  "project.relay.token": ["user"],
115
115
  "project.relay.crt": ["user", "project"],
116
116
  "project.relay.key": ["user", "project"],
117
+ "project.service.port": ["project"],
117
118
  "project.artifact.spec.basedir": ["user", "project"],
118
119
  "project.artifact.spec.files": ["user", "project"],
119
120
  "project.artifact.spec.schema": ["user", "project"],
@@ -255,6 +256,8 @@ const flowSchema = (forms) => v.optional(v.pipe(v.string(), v.check((value) => {
255
256
  const minutesSchema = v.optional(v.pipe(v.union([v.number(), v.string()]), v.check((s) => /^\d+(?:\.\d+)?$/.test(String(s)) && Number(s) > 0, "expected a positive number of minutes")));
256
257
  /* schema for a multiplier of the effort estimation */
257
258
  const multiplierSchema = v.optional(v.pipe(v.union([v.number(), v.string()]), v.check((s) => /^\d+(?:\.\d+)?$/.test(String(s)) && Number(s) >= 1, "expected a multiplier of at least 1")));
259
+ /* schema for an unprivileged TCP port number */
260
+ const portSchema = v.optional(v.pipe(v.union([v.number(), v.string()]), v.check((s) => /^\d+$/.test(String(s)) && Number(s) >= 1024 && Number(s) <= 65535, "expected a port number (1024-65535)")));
258
261
  /* schema for ".ase/config.yaml" */
259
262
  export const configSchema = v.nullish(v.strictObject({
260
263
  project: v.optional(v.strictObject({
@@ -290,6 +293,9 @@ export const configSchema = v.nullish(v.strictObject({
290
293
  crt: v.optional(v.pipe(v.string(), v.minLength(1))),
291
294
  key: v.optional(v.pipe(v.string(), v.minLength(1)))
292
295
  })),
296
+ service: v.optional(v.strictObject({
297
+ port: portSchema
298
+ })),
293
299
  artifact: v.optional(v.strictObject({
294
300
  spec: artifactSpecSchema,
295
301
  code: artifactSchema,
@@ -305,7 +311,7 @@ export const configSchema = v.nullish(v.strictObject({
305
311
  enabled: v.optional(v.union([v.boolean(), v.picklist(["true", "false"])])),
306
312
  implicit: v.optional(v.union([v.boolean(), v.picklist(["true", "false"])])),
307
313
  service: v.optional(v.strictObject({
308
- port: v.optional(v.pipe(v.union([v.number(), v.string()]), v.check((s) => /^\d+$/.test(String(s)) && Number(s) >= 1024 && Number(s) <= 65535, "expected a port number (1024-65535)"))),
314
+ port: portSchema,
309
315
  token: v.optional(v.pipe(v.string(), v.minLength(1))),
310
316
  interval: v.optional(v.pipe(v.union([v.number(), v.string()]), v.check((s) => /^\d+$/.test(String(s)), "expected a number of minutes (0 disables)"))),
311
317
  idle: v.optional(v.pipe(v.union([v.number(), v.string()]), v.check((s) => /^\d+$/.test(String(s)), "expected a number of minutes (0 disables)")))
package/dst/ase-config.js CHANGED
@@ -191,7 +191,8 @@ export class ConfigMCP {
191
191
  const cfg = new Config("config", configSchema, this.log, scope);
192
192
  cfg.read();
193
193
  const val = cfg.get(args.key);
194
- const text = val === undefined ? "" : JSON.stringify(val);
194
+ const text = val === undefined ? "" :
195
+ JSON.stringify(configSecretKeys.includes(cfg.resolveKey(args.key)) ? "***" : val);
195
196
  return { content: [{ type: "text", text }] };
196
197
  }
197
198
  catch (err) {
package/dst/ase-hook.js CHANGED
@@ -16,6 +16,7 @@ import { parseScope, userStateDir } from "./ase-config-scope.js";
16
16
  import { readStdin, writeStdout } from "./ase-lib-stdio.js";
17
17
  import { Task } from "./ase-task.js";
18
18
  import * as TaskFormat from "./ase-task-format.js";
19
+ import { parseTool } from "./ase-setup-common.js";
19
20
  const addonMcpServers = [
20
21
  "chat-alibaba-qwen",
21
22
  "chat-deepseek",
@@ -706,12 +707,6 @@ export default class HookCommand {
706
707
  }
707
708
  return 0;
708
709
  }
709
- /* parse and validate the --tool option */
710
- parseTool(value) {
711
- if (value !== "claude" && value !== "copilot" && value !== "codex")
712
- throw new Error(`invalid --tool value: "${value}" (expected "claude", "copilot", or "codex")`);
713
- return value;
714
- }
715
710
  /* register commands */
716
711
  register(program) {
717
712
  /* default for --tool derived from ASE_TOOL environment variable
@@ -743,7 +738,7 @@ export default class HookCommand {
743
738
  .description(desc)
744
739
  .option("-t, --tool <tool>", "target tool (\"claude\", \"copilot\", or \"codex\")", toolDflt)
745
740
  .action(async (opts) => {
746
- process.exitCode = await handler(this.parseTool(opts.tool));
741
+ process.exitCode = await handler(parseTool(opts.tool));
747
742
  });
748
743
  }
749
744
  }
@@ -13,6 +13,7 @@ import { isSeq, isScalar } from "yaml";
13
13
  import { Config } from "./ase-config-core.js";
14
14
  import { parseScope } from "./ase-config-scope.js";
15
15
  import { isLogLevel } from "./ase-lib-log.js";
16
+ import { writeStdout } from "./ase-lib-stdio.js";
16
17
  import { Service } from "./ase-service.js";
17
18
  import { DaemonCommand } from "./ase-lib-daemon-cli.js";
18
19
  import { urlHost, isLoopback, serverHeader } from "./ase-lib-server-bind.js";
@@ -381,26 +382,26 @@ export class ServerCommand extends DaemonCommand {
381
382
  async doStatus() {
382
383
  const ctx = this.loadContext();
383
384
  if (ctx.port === null) {
384
- process.stdout.write(`${this.label}: not running (no port configured)\n`);
385
+ await writeStdout(`${this.label}: not running (no port configured)\n`);
385
386
  return 1;
386
387
  }
387
388
  const address = ctx.address ?? DEFAULT_ADDRESS;
388
389
  const scheme = ctx.tlsCert !== null ? "https" : "http";
389
390
  const state = await this.probe(address, ctx.port, ctx.token, ctx.tlsCert !== null);
390
391
  if (state === "running") {
391
- process.stdout.write(`${this.label}: running on ${scheme}://${urlHost(address)}:${ctx.port}` +
392
+ await writeStdout(`${this.label}: running on ${scheme}://${urlHost(address)}:${ctx.port}` +
392
393
  (ctx.pid !== null ? ` (pid ${ctx.pid})` : "") + this.running(ctx) + "\n");
393
394
  return 0;
394
395
  }
395
396
  if (state === "unauthorized") {
396
- process.stdout.write(`${this.label}: running on ${scheme}://${urlHost(address)}:${ctx.port}, but the configured token is rejected\n`);
397
+ await writeStdout(`${this.label}: running on ${scheme}://${urlHost(address)}:${ctx.port}, but the configured token is rejected\n`);
397
398
  return 1;
398
399
  }
399
400
  if (state === "foreign") {
400
- process.stdout.write(`${this.label}: not running (port ${ctx.port} in use by foreign service)\n`);
401
+ await writeStdout(`${this.label}: not running (port ${ctx.port} in use by foreign service)\n`);
401
402
  return 1;
402
403
  }
403
- process.stdout.write(`${this.label}: not running (port ${ctx.port} not responding)\n`);
404
+ await writeStdout(`${this.label}: not running (port ${ctx.port} not responding)\n`);
404
405
  return 1;
405
406
  }
406
407
  /* stop flow: terminate the server process and wait until it is gone */
@@ -4,6 +4,7 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import updateNotifier from "update-notifier";
7
+ import semver from "semver";
7
8
  import pkg from "../package.json" with { type: "json" };
8
9
  /* determination of current and available ASE versions */
9
10
  export default class Version {
@@ -18,9 +19,12 @@ export default class Version {
18
19
  pkg,
19
20
  updateCheckInterval: 1000 * 60 * 60
20
21
  });
21
- /* re-persist the cached update information, as update-notifier consumes it on read */
22
- if (notifier.update !== undefined)
23
- notifier.config?.set("update", notifier.update);
24
- return notifier.update?.latest ?? Version.current();
22
+ /* re-persist the cached update information, as update-notifier consumes it on read,
23
+ but only while it still denotes a newer version (it is never cleared otherwise) */
24
+ const latest = notifier.update?.latest;
25
+ if (latest === undefined || !semver.valid(latest) || !semver.gt(latest, Version.current()))
26
+ return Version.current();
27
+ notifier.config?.set("update", notifier.update);
28
+ return latest;
25
29
  }
26
30
  }
@@ -86,7 +86,7 @@ const LOG_MAX_SIZE = 1024 * 1024;
86
86
  const LOG_KEEP_LINES = 2000;
87
87
  const LOG_ARGS_MAX = 200;
88
88
  /* load the optional "config.yaml" and "service.yaml" files and derive
89
- the service identity context (project id, port, service config) */
89
+ the service identity context (project id, port, preferred port, service config) */
90
90
  export const loadServiceContext = (log) => {
91
91
  /* load files */
92
92
  const cfg = new Config("config", configSchema, log);
@@ -97,10 +97,13 @@ export const loadServiceContext = (log) => {
97
97
  const projectId = cfg.get("project.id") || Task.projectIdOf(Task.projectRoot());
98
98
  /* determine service port */
99
99
  const port = svc.get("port") ?? null;
100
+ /* determine preferred service port */
101
+ const pref = cfg.get("project.service.port");
102
+ const portPref = pref !== null && pref !== undefined ? Number(pref) : null;
100
103
  /* determine path to ".ase" directory */
101
104
  const aseDir = path.dirname(svc.filename);
102
105
  /* return context information */
103
- return { projectId, port, svc, aseDir };
106
+ return { projectId, port, portPref, svc, aseDir };
104
107
  };
105
108
  /* reusable functionality: port allocation, persistence, and process spawning */
106
109
  export class Service {
@@ -117,8 +120,11 @@ export class Service {
117
120
  s.listen(port, HOST);
118
121
  });
119
122
  }
120
- /* allocate a fresh random port in PORT_MIN..PORT_MAX */
121
- static async allocatePort() {
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;
122
128
  for (let i = 0; i < PORT_TRIES; i++) {
123
129
  const p = PORT_MIN + Math.floor(Math.random() * (PORT_MAX - PORT_MIN + 1));
124
130
  if (await Service.tryBind(p))
@@ -588,6 +594,13 @@ export default class ServiceCommand {
588
594
  }
589
595
  }, TICK_MS).unref();
590
596
  }
597
+ /* allocate the service port, preferring the configured one, else falling back to a random one */
598
+ async allocatePort(ctx) {
599
+ const port = await Service.allocatePort(ctx.portPref);
600
+ if (ctx.portPref !== null && port !== ctx.portPref)
601
+ this.log.write("warning", `service: preferred port ${ctx.portPref} unavailable, falling back to port ${port}`);
602
+ return port;
603
+ }
591
604
  /* start flow: ensure port, probe, optionally detach (or run in the foreground) */
592
605
  async doStart(opts = {}) {
593
606
  const ctx = this.loadContext();
@@ -614,7 +627,7 @@ export default class ServiceCommand {
614
627
  }
615
628
  /* run the service in the foreground, logging to the terminal and without idle timeout */
616
629
  if (opts.foreground) {
617
- port = await Service.allocatePort();
630
+ port = await this.allocatePort(ctx);
618
631
  await this.runService({ ...ctx, port }, true);
619
632
  return await new Promise(() => { });
620
633
  }
@@ -622,7 +635,7 @@ export default class ServiceCommand {
622
635
  re-allocate, re-spawn; early-break on foreign listener */
623
636
  let lastErr = new Error("service failed to start within timeout");
624
637
  for (let attempt = 0; attempt < 3; attempt++) {
625
- port = await Service.allocatePort();
638
+ port = await this.allocatePort(ctx);
626
639
  const { child, logFile } = Service.spawnDetached(ctx.aseDir, port, this.log.logLevel());
627
640
  let exited = false;
628
641
  let exitCode = null;
@@ -187,7 +187,7 @@ export class BoardState {
187
187
  state[surface][flag] = !state[surface][flag];
188
188
  });
189
189
  }
190
- /* set the shown view (lanes, graph, stats, or agents) */
190
+ /* set the shown view (lanes, graph, stats, agents, or flow) */
191
191
  static setView(surface, view) {
192
192
  return BoardState.update((state) => {
193
193
  state[surface].view = view;
@@ -310,16 +310,6 @@ const handlePopupKey = (ctx, input, key) => {
310
310
  setPrompt({ ...prompt, focus: prompt.focus === "request" ? "cancel" : "request" });
311
311
  return;
312
312
  }
313
- /* type the prompt of an edit request: RETURN requests the edit, Shift+RETURN
314
- (under the kitty keyboard protocol only), Alt+RETURN (under the kitty keyboard
315
- protocol, or where the terminal sends it as ESC+RETURN, e.g. with "Option as Meta"
316
- on macOS), and Ctrl+J (in every terminal, as a plain linefeed without the kitty
317
- keyboard protocol) insert a newline,
318
- the left/right arrows move the cursor by one character, HOME/END move it
319
- to the start/end, BACKSPACE removes the character before the cursor, DELETE
320
- removes the character at the cursor, and all other input (also pasted text,
321
- with its newlines kept and its other control characters turned into spaces)
322
- is inserted at the cursor */
323
313
  if (prompt !== null && prompt.request !== null && prompt.focus === "name") {
324
314
  /* type the request name of a request by prompt: RETURN switches to the prompt (resp. to
325
315
  the request button if the prompt is ignored), the
@@ -342,6 +332,16 @@ const handlePopupKey = (ctx, input, key) => {
342
332
  name(request.name + input.toLowerCase());
343
333
  return;
344
334
  }
335
+ /* type the prompt of an edit request: RETURN requests the edit, Shift+RETURN
336
+ (under the kitty keyboard protocol only), Alt+RETURN (under the kitty keyboard
337
+ protocol, or where the terminal sends it as ESC+RETURN, e.g. with "Option as Meta"
338
+ on macOS), and Ctrl+J (in every terminal, as a plain linefeed without the kitty
339
+ keyboard protocol) insert a newline,
340
+ the left/right arrows move the cursor by one character, HOME/END move it
341
+ to the start/end, BACKSPACE removes the character before the cursor, DELETE
342
+ removes the character at the cursor, and all other input (also pasted text,
343
+ with its newlines kept and its other control characters turned into spaces)
344
+ is inserted at the cursor */
345
345
  if (prompt !== null) {
346
346
  const chars = [...prompt.text];
347
347
  const pos = Math.max(0, Math.min(prompt.pos, chars.length));
@@ -161,6 +161,14 @@ const boxAt = (map, mx, my) => {
161
161
  }
162
162
  return undefined;
163
163
  };
164
+ /* the box of every node of a laid out graph, for the spatial navigation and the scrolling */
165
+ const placesOf = (graph) => {
166
+ const map = new Map();
167
+ if (graph !== undefined)
168
+ for (const n of graph.nodes.values())
169
+ map.set(n.id, { r: n.y + Math.floor(n.h / 2), c: n.x + Math.floor(n.w / 2), top: n.y, bottom: n.y + n.h - 1, bl: n.x, br: n.x + n.w - 1 });
170
+ return map;
171
+ };
164
172
  /* the state of the agents view: the agents of the master list, the selected one (followed by its id,
165
173
  else, until explicitly selected again, the one at the index of the previously selected one),
166
174
  the lines of its operation pane, and the lines of its recent processing log in the detail view (within its frame), scrolled
@@ -343,13 +351,7 @@ export const useBoardState = (log, initial, relay) => {
343
351
  }
344
352
  }, [all, prompt]);
345
353
  /* remember the box of every graph node for the spatial navigation and the scrolling */
346
- const places = React.useMemo(() => {
347
- const map = new Map();
348
- if (layout !== null)
349
- for (const n of layout.graph.nodes.values())
350
- map.set(n.id, { r: n.y + Math.floor(n.h / 2), c: n.x + Math.floor(n.w / 2), top: n.y, bottom: n.y + n.h - 1, bl: n.x, br: n.x + n.w - 1 });
351
- return map;
352
- }, [layout]);
354
+ const places = React.useMemo(() => placesOf(layout?.graph), [layout]);
353
355
  /* follow changes of the task storage and of the lifecycle mode,
354
356
  and the kind and connection state of the task store
355
357
  (in the background, i.e. without the busy popup) */
@@ -416,13 +418,7 @@ export const useBoardState = (log, initial, relay) => {
416
418
  if (!flow.cards.has(flowSel) && !flow.states.some((s) => flowState(s) === flowSel) && flow.cards.size > 0)
417
419
  setFlowSel(flow.cards.keys().next().value);
418
420
  }, [flow, flowSel]);
419
- const flowPlaces = React.useMemo(() => {
420
- const map = new Map();
421
- if (flowLayout !== null)
422
- for (const n of flowLayout.graph.nodes.values())
423
- map.set(n.id, { r: n.y + Math.floor(n.h / 2), c: n.x + Math.floor(n.w / 2), top: n.y, bottom: n.y + n.h - 1, bl: n.x, br: n.x + n.w - 1 });
424
- return map;
425
- }, [flowLayout]);
421
+ const flowPlaces = React.useMemo(() => placesOf(flowLayout?.graph), [flowLayout]);
426
422
  /* fetch the AI usage of all tasks whenever the statistics view is shown or the board
427
423
  changes, but only while the statistics view is shown (as each fetching loads all
428
424
  plans), and once known in the background, i.e. without the busy popup */
@@ -21,6 +21,10 @@ const GRID_MIN = 30;
21
21
  const GRID_GAP = 1;
22
22
  /* make a text of the agent relay safe for the terminal (strip control characters) */
23
23
  const clean = (text) => text.replace(/\p{Cc}/gu, "");
24
+ /* append to the content of an info line (" " if empty) the hint how to show the
25
+ hidden key hints again, so it need not be remembered while the key hints are hidden */
26
+ const keysHint = (surface, info) => surface.keys ? info : info === " " ? "?: show key hints" :
27
+ [...(Array.isArray(info) ? info : [info]), " · ?: show key hints"];
24
28
  /* compute the visible group range starting at a first group, with the
25
29
  width of each visible group (collapsed groups stay narrow, expanded
26
30
  groups share the remaining width but never drop below the minimum) */
@@ -331,7 +335,8 @@ export const renderLanes = (ctx) => {
331
335
  const arrows = shown < total && !grown;
332
336
  const group = board.groups[sel.g];
333
337
  const info = grown ? `lane ${group.lanes[sel.l].status} of group ${group.title} grown · g shrinks` :
334
- `groups ${fit.first + 1}–${fit.last + 1} of ${total} · ${arrows ? "←/→ scrolls" : "all visible"}`;
338
+ `groups ${fit.first + 1}–${fit.last + 1} of ${total} · ${arrows ? "←/→ scrolls" : "all visible"}` +
339
+ (surface.keys ? "" : " · ?: show key hints");
335
340
  const view = { board, surface, sel, dim, carry, titles: surface.titles, scroll: { left: -1, right: -1 }, pulse, cardRef, laneRef, groupRef, agents, working };
336
341
  /* the scroll bar consumes the entire width remaining besides the info */
337
342
  const track = Math.max(1, columns - 2 - info.length - 3);
@@ -390,8 +395,8 @@ export const renderGraph = (ctx) => {
390
395
  })))))),
391
396
  /* overlay the graph statistics centered onto the top border */
392
397
  h(Box, { position: "absolute", top: 0, left: 0, width: "100%", justifyContent: "center" }, h(Text, { ...cx("dim", dim && "dimmed"), wrap: "truncate" }, `[ ${board.cards.size} tasks, ${deps} dependencies ]`))),
393
- h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), card === undefined ? " " :
394
- ["task: ", h(Text, { key: "id", ...cx("bold") }, card.id), " · status: ", h(Text, { key: "status", ...cx("bold") }, card.status)])),
398
+ h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), keysHint(surface, card === undefined ? " " :
399
+ ["task: ", h(Text, { key: "id", ...cx("bold") }, card.id), " · status: ", h(Text, { key: "status", ...cx("bold") }, card.status)]))),
395
400
  h(Box, { key: "keys1", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), "↑/↓/←/→/Left-Click: select task · ⏎/Double-Click: view task · e: edit task · T: transition task")),
396
401
  h(Box, { key: "keys2", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `D: delete task · A: assign task · E: edit by prompt · R: request by prompt · N: new task · t: ${graphTitles ? "collapse" : "expand"} titles · ` +
397
402
  `s: ${surface.standalone ? "hide" : "show"} standalone tasks`)),
@@ -434,7 +439,7 @@ export const renderFlow = (ctx) => {
434
439
  })))))),
435
440
  /* overlay the graph statistics centered onto the top border */
436
441
  h(Box, { position: "absolute", top: 0, left: 0, width: "100%", justifyContent: "center" }, h(Text, { ...cx("dim", dim && "dimmed"), wrap: "truncate" }, `[ ${agents?.agents.length ?? 0} agents, ${flow.states.length} states ]`))),
437
- h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), rel !== null ? [
442
+ h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), keysHint(ctx.surface, rel !== null ? [
438
443
  "state: ", h(Text, { key: "state", ...cx("bold") }, rel.state),
439
444
  " · observed by: ", h(Text, { key: "in", ...cx("bold") }, names(rel.input)),
440
445
  " · written by: ", h(Text, { key: "out", ...cx("bold") }, names(rel.output)),
@@ -448,7 +453,7 @@ export const renderFlow = (ctx) => {
448
453
  " · output: ", h(Text, { key: "out", ...cx("bold") }, states.output.join(", ") || "none"),
449
454
  ...(states.gate !== undefined ? [" · gate: ", h(Text, { key: "gate", ...cx("bold") }, states.gate)] : [])
450
455
  ])
451
- ])),
456
+ ]))),
452
457
  h(Box, { key: "keys1", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), "↑/↓/←/→/Left-Click: select agent/state · ⏎/Double-Click: view agent · " +
453
458
  "⇈/⇊/⇤/⇥/Wheel: scroll canvas")),
454
459
  h(Box, { key: "keys2", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `v/V: ${viewCycleHint("flow", agents !== null)} · M: ${mouse ? "disable" : "enable"} mouse · ?: hide key hints · q: quit`))
@@ -554,12 +559,12 @@ export const renderAgents = (ctx) => {
554
559
  const detail = h(Box, { key: "detail", flexGrow: 1, height: boardH, marginLeft: 1, flexDirection: "column" }, op, info, log);
555
560
  return [
556
561
  h(Box, { key: "agents", height: boardH, paddingX: 1 }, master, detail),
557
- h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", "truncate", dim && "dimmed"), agent === undefined ? " " : [
562
+ h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", "truncate", dim && "dimmed"), keysHint(ctx.surface, agent === undefined ? " " : [
558
563
  "agent: ", h(Text, { key: "agent", ...cx("bold") }, clean(agentLabel(agent))),
559
564
  ...(agentOp.title !== "" ? [
560
565
  " · operation: ", h(Text, { key: "op", ...cx("bold") }, agentOp.title)
561
566
  ] : [])
562
- ])),
567
+ ]))),
563
568
  h(Box, { key: "keys1", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), "↑/↓/Left-Click: select agent · ⇈/⇊/⇤/⇥: page/top/bottom agent log · Mouse-Wheel: scroll agent log")),
564
569
  h(Box, { key: "keys2", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `v/V: ${viewCycleHint("agents", agents !== null)} · M: ${mouse ? "disable" : "enable"} mouse · ?: hide key hints · q: quit`))
565
570
  ];
@@ -839,8 +844,8 @@ export const renderStats = (ctx) => {
839
844
  const shown = stats.states.reduce((n, s) => n + s.count, 0);
840
845
  return [
841
846
  h(Box, { key: "stats", height: boardH, paddingX: 1, flexDirection: "column", overflow: "hidden" }, ranges, chart, progress, usages, tops),
842
- h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), ["statistics of ", h(Text, { key: "n", ...cx("bold") }, String(shown)),
843
- ` ${filter !== "" ? "filtered " : ""}tasks`, ...(range !== "total" ? [" ", h(Text, { key: "r", ...cx("bold") }, statsRangeLabels[range])] : [])])),
847
+ h(Box, { key: "info", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), keysHint(ctx.surface, ["statistics of ", h(Text, { key: "n", ...cx("bold") }, String(shown)),
848
+ ` ${filter !== "" ? "filtered " : ""}tasks`, ...(range !== "total" ? [" ", h(Text, { key: "r", ...cx("bold") }, statsRangeLabels[range])] : [])]))),
844
849
  h(Box, { key: "keys1", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `←/→: select time range · /: filter tasks · v/V: ${viewCycleHint("stats", agents !== null)}`)),
845
850
  h(Box, { key: "keys2", ...cx("bar") }, h(Text, cx("hint", dim && "dimmed"), `M: ${mouse ? "disable" : "enable"} mouse · ?: hide key hints · q: quit`))
846
851
  ];