@davidbalzan/groundwork-console 0.1.5 → 0.1.7

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 (107) hide show
  1. package/README.md +3 -3
  2. package/bin/groundwork-console.mjs +21 -2
  3. package/dist/answered.js +16 -34
  4. package/dist/bus.js +11 -4
  5. package/dist/kitresolve.js +3 -2
  6. package/dist/mcp.js +2 -1
  7. package/dist/provision.js +94 -4
  8. package/dist/runs.js +34 -2
  9. package/dist/server.js +149 -41
  10. package/dist/skills.js +51 -7
  11. package/dist/tmux.js +32 -0
  12. package/dist/uploads.js +31 -1
  13. package/package.json +5 -2
  14. package/public/assets/{abnfDiagram-N423BO3Z-eACBdXEp.js → abnfDiagram-N423BO3Z-B0nns4cA.js} +1 -1
  15. package/public/assets/{arc-C8zPWxct.js → arc-DsDgWIU7.js} +1 -1
  16. package/public/assets/architecture-TIHT7OUA-okwBKksv.js +1 -0
  17. package/public/assets/{architectureDiagram-T3A2C74G-BZqtGnsC.js → architectureDiagram-T3A2C74G-Bqo7zIaB.js} +1 -1
  18. package/public/assets/{blockDiagram-VBNYF7ZC-D265Ytun.js → blockDiagram-VBNYF7ZC-CvNPqlbt.js} +1 -1
  19. package/public/assets/{c4Diagram-5PPSVZJV-CodzUBEt.js → c4Diagram-5PPSVZJV-BqjkNgv6.js} +1 -1
  20. package/public/assets/channel-CI1-SNMv.js +1 -0
  21. package/public/assets/{chunk-2GRJ4B5K-D59ECeXb.js → chunk-2GRJ4B5K-CWJJzbol.js} +1 -1
  22. package/public/assets/{chunk-4I5QYGJK-DlE6ad0r.js → chunk-4I5QYGJK-CuHGN-lT.js} +1 -1
  23. package/public/assets/{chunk-5RXB4S5H-Bw4SBOdN.js → chunk-5RXB4S5H-Cl92nmIs.js} +1 -1
  24. package/public/assets/{chunk-6Q2QTUOP-BD4S6iuB.js → chunk-6Q2QTUOP-C3vYg7rX.js} +1 -1
  25. package/public/assets/{chunk-7Z6QIM7H-DvwJZM4T.js → chunk-7Z6QIM7H-jkpbvAhi.js} +1 -1
  26. package/public/assets/{chunk-GF5L2VYU-BlzSHpI6.js → chunk-GF5L2VYU-Bo-zB54d.js} +1 -1
  27. package/public/assets/{chunk-I66GZJ75-DJurmtZ9.js → chunk-I66GZJ75-BD9h95Vq.js} +1 -1
  28. package/public/assets/{chunk-JQJVKLGR-t20LaGIq.js → chunk-JQJVKLGR-C6TRkyf7.js} +1 -1
  29. package/public/assets/{chunk-KBJHAD2P-Dgjyd4B5.js → chunk-KBJHAD2P-RgoL4Nto.js} +1 -1
  30. package/public/assets/{chunk-NSK5VX7P-Chchkdi8.js → chunk-NSK5VX7P-C6TsGnGA.js} +1 -1
  31. package/public/assets/{chunk-QR6OTTB3-CJK67frw.js → chunk-QR6OTTB3-Bq5akaxk.js} +1 -1
  32. package/public/assets/{chunk-RYQCIY6F-sGSUyT6_.js → chunk-RYQCIY6F-DtVCesJ4.js} +1 -1
  33. package/public/assets/{chunk-UBXNYLIW-CEzUmAZt.js → chunk-UBXNYLIW-BjnaZZH0.js} +1 -1
  34. package/public/assets/{chunk-W5SLKNZC-Spmh0y1r.js → chunk-W5SLKNZC-D1KW-lQn.js} +1 -1
  35. package/public/assets/{chunk-WRU74C26-DEsyhzZw.js → chunk-WRU74C26-YZFoWy6X.js} +1 -1
  36. package/public/assets/{chunk-XXDRQBXY-Bo1s3Uh5.js → chunk-XXDRQBXY-CyrI_Mox.js} +1 -1
  37. package/public/assets/classDiagram-JCYQIIEL-zdEoGtpz.js +1 -0
  38. package/public/assets/classDiagram-v2-OCEON4UE-zdEoGtpz.js +1 -0
  39. package/public/assets/{cose-bilkent-JH36ORCC-15pcvj-n.js → cose-bilkent-JH36ORCC-v6lDOu7m.js} +1 -1
  40. package/public/assets/{cynefin-VYW2F7L2-BZxWqC-g.js → cynefin-VYW2F7L2-BtzRQs_J.js} +1 -1
  41. package/public/assets/{cynefinDiagram-MW4NZA55-B_czkRTa.js → cynefinDiagram-MW4NZA55--R8S0KoS.js} +1 -1
  42. package/public/assets/{dagre-VZM6K2ZE-D8bvaK5F.js → dagre-VZM6K2ZE-D6mRFOfB.js} +1 -1
  43. package/public/assets/{diagram-7IWD3JNH-_vpWqUu0.js → diagram-7IWD3JNH-kfCoRMpW.js} +1 -1
  44. package/public/assets/{diagram-B4RE2ZJO-DPfWeSE2.js → diagram-B4RE2ZJO-sxUW6rzT.js} +1 -1
  45. package/public/assets/{diagram-LBJQPF4R-Cb7ustRg.js → diagram-LBJQPF4R-B926bgCA.js} +1 -1
  46. package/public/assets/{diagram-Q27KOJAE-CRIHo9aN.js → diagram-Q27KOJAE-CcVi6njJ.js} +1 -1
  47. package/public/assets/{diagram-UB23O5K3-CMyiQouk.js → diagram-UB23O5K3-BtW4XOuK.js} +1 -1
  48. package/public/assets/{dist-DVdstvf6.js → dist-BnkmMC7M.js} +1 -1
  49. package/public/assets/{ebnfDiagram-BXEA7PRR-UURYMfYm.js → ebnfDiagram-BXEA7PRR-BNGfWHYj.js} +1 -1
  50. package/public/assets/{erDiagram-JOGREHBK-Dio32huH.js → erDiagram-JOGREHBK-BGrhe741.js} +1 -1
  51. package/public/assets/eventmodeling-45OFAUF4-Cn6RDMEG.js +1 -0
  52. package/public/assets/flowDiagram-UKHOOZJN-DOMsEKL1.js +1 -0
  53. package/public/assets/{ganttDiagram-PKOTCBZU-62etRb4w.js → ganttDiagram-PKOTCBZU-CR5BNdXR.js} +1 -1
  54. package/public/assets/{gitGraph-TEB2WS4Q-CfIUb3Fs.js → gitGraph-TEB2WS4Q-CfvC-iZD.js} +1 -1
  55. package/public/assets/{gitGraphDiagram-DS77QQ5N-DuwZInBC.js → gitGraphDiagram-DS77QQ5N-D45EWdll.js} +1 -1
  56. package/public/assets/index-CxNe802C.css +2 -0
  57. package/public/assets/index-yHI4kvM-.js +41 -0
  58. package/public/assets/{info-DKCQHKI2-BfXJHEd1.js → info-DKCQHKI2-stahmZKW.js} +1 -1
  59. package/public/assets/{infoDiagram-6WML65LV-Bmh_7gih.js → infoDiagram-6WML65LV-nWu8ujif.js} +1 -1
  60. package/public/assets/{ishikawaDiagram-WSZJBQD7-i8-L2rAe.js → ishikawaDiagram-WSZJBQD7-Ccwzv-xZ.js} +1 -1
  61. package/public/assets/{journeyDiagram-NVQOT4AX-BtfEqljI.js → journeyDiagram-NVQOT4AX-R7gZw1Qw.js} +1 -1
  62. package/public/assets/{kanban-definition-27J2QSJJ-pH6QumbR.js → kanban-definition-27J2QSJJ-By8aTzkI.js} +1 -1
  63. package/public/assets/{line-DsGf4Aam.js → line-zJXbSVxf.js} +1 -1
  64. package/public/assets/{linear-BNSu_43k.js → linear-B8U-jWnO.js} +1 -1
  65. package/public/assets/{mermaid-parser.core-679COn57.js → mermaid-parser.core-B1iqIz20.js} +3 -3
  66. package/public/assets/{mermaid.core-D_f4AlrR.js → mermaid.core-CuFK5CuK.js} +4 -4
  67. package/public/assets/{mindmap-definition-FAOFIHXS-D1tM4SRn.js → mindmap-definition-FAOFIHXS-qJ2DuBku.js} +1 -1
  68. package/public/assets/{packet-7NZHBO7P-olELdMgx.js → packet-7NZHBO7P-CzQJdD0e.js} +1 -1
  69. package/public/assets/{pegDiagram-VL7TDLO6-DgAndH0A.js → pegDiagram-VL7TDLO6-CKmHw89b.js} +1 -1
  70. package/public/assets/{pie-RZYD4A2V-DWj8tY38.js → pie-RZYD4A2V-Dt3CA16n.js} +1 -1
  71. package/public/assets/{pieDiagram-7S7Q4E2Y-DK7c2EvE.js → pieDiagram-7S7Q4E2Y-D9vwefN9.js} +1 -1
  72. package/public/assets/{quadrantDiagram-CIZ2JOQS-DI-ZbPxK.js → quadrantDiagram-CIZ2JOQS-jtSAmOcp.js} +1 -1
  73. package/public/assets/{radar-I7S5WNFK-DLsdeAbl.js → radar-I7S5WNFK-BtDfUAqO.js} +1 -1
  74. package/public/assets/{railroad-3IZDKUUU-BFUnqJxV.js → railroad-3IZDKUUU-D5DNw8qB.js} +1 -1
  75. package/public/assets/railroad-abnf-AHOZXSZD-DuJUfLMt.js +1 -0
  76. package/public/assets/railroad-ebnf-EBAXGLYW-CiLIN42p.js +1 -0
  77. package/public/assets/railroad-peg-LSFZ7HO6-DKtIAsvs.js +1 -0
  78. package/public/assets/{railroadDiagram-AXF67PYL-BcCf-qnq.js → railroadDiagram-AXF67PYL-C-S9VT4Z.js} +1 -1
  79. package/public/assets/{requirementDiagram-LRYGKXZP-CSERzrsB.js → requirementDiagram-LRYGKXZP-Bmv6EbmT.js} +1 -1
  80. package/public/assets/{sankeyDiagram-W5VNT64P-BTHdyTAB.js → sankeyDiagram-W5VNT64P-Cm2dLWH9.js} +1 -1
  81. package/public/assets/{sequenceDiagram-SI44F4Z6-DCMbcZQ-.js → sequenceDiagram-SI44F4Z6-DPslkAXf.js} +1 -1
  82. package/public/assets/{src-CWXJqyrc.js → src-l8EnJwXz.js} +1 -1
  83. package/public/assets/{stateDiagram-OKZ733FA-BhzI_RZ_.js → stateDiagram-OKZ733FA-BBXMNQQ7.js} +1 -1
  84. package/public/assets/stateDiagram-v2-UEYNNEHI-BxOobbWc.js +1 -0
  85. package/public/assets/{swimlanes-SLNWSIFB-DPKSeErn.js → swimlanes-SLNWSIFB-BeiwtQg-.js} +1 -1
  86. package/public/assets/swimlanesDiagram-ULZ7WXOC-BEW9yZ9H.js +8 -0
  87. package/public/assets/{timeline-definition-Z64GVDOM-BnLvIO3-.js → timeline-definition-Z64GVDOM-Drd7Un_g.js} +1 -1
  88. package/public/assets/{treeView-QDETBFTQ-BgFXBEUi.js → treeView-QDETBFTQ-BT2KyIoB.js} +1 -1
  89. package/public/assets/{treemap-6X3UGDF4-KzlTv1ih.js → treemap-6X3UGDF4-gdZw2F6X.js} +1 -1
  90. package/public/assets/{vennDiagram-T6HMQDX7-gsHmzjh9.js → vennDiagram-T6HMQDX7-Bbk8OMR0.js} +1 -1
  91. package/public/assets/{wardley-OPB4EBWU-DyQkbq8g.js → wardley-OPB4EBWU-BI0xutNp.js} +1 -1
  92. package/public/assets/{wardleyDiagram-T6FBY63Y-DqwaVwnv.js → wardleyDiagram-T6FBY63Y-DQvEcd8r.js} +1 -1
  93. package/public/assets/{xychartDiagram-ELKLHX3M-DGZxfApD.js → xychartDiagram-ELKLHX3M-CRWoUpzf.js} +1 -1
  94. package/public/index.html +2 -2
  95. package/public/assets/architecture-TIHT7OUA-CtVIKkxG.js +0 -1
  96. package/public/assets/channel-CpTfWcC2.js +0 -1
  97. package/public/assets/classDiagram-JCYQIIEL-BSB0boKu.js +0 -1
  98. package/public/assets/classDiagram-v2-OCEON4UE-BSB0boKu.js +0 -1
  99. package/public/assets/eventmodeling-45OFAUF4-DP5rNNNm.js +0 -1
  100. package/public/assets/flowDiagram-UKHOOZJN-DjrqEV81.js +0 -1
  101. package/public/assets/index-ZvSJKDLe.css +0 -2
  102. package/public/assets/index-iKIFRUDL.js +0 -40
  103. package/public/assets/railroad-abnf-AHOZXSZD-B00TU1b8.js +0 -1
  104. package/public/assets/railroad-ebnf-EBAXGLYW-RrXgWJqj.js +0 -1
  105. package/public/assets/railroad-peg-LSFZ7HO6-CsiNSgst.js +0 -1
  106. package/public/assets/stateDiagram-v2-UEYNNEHI-DKG0b0eo.js +0 -1
  107. package/public/assets/swimlanesDiagram-ULZ7WXOC-D6CPDB9k.js +0 -8
package/README.md CHANGED
@@ -12,7 +12,7 @@ npx @davidbalzan/groundwork-console # or: npm
12
12
  First run creates `~/.groundwork-console/config.json` (add projects from Home → **Add project**) and `~/.groundwork-console/token`, prints the URL and a sign-in link, and opens the browser already signed in. Everything runs on one port (`:8790`).
13
13
 
14
14
  ```
15
- groundwork-console [start] [--port 8790] [--host 127.0.0.1 | --tailscale] [--no-open] [--no-auth] [--config <file>]
15
+ groundwork-console [start] [--port 8790] [--host 127.0.0.1 | --tailscale | --lan[=<ip>]] [--no-open] [--no-auth] [--config <file>]
16
16
  groundwork-console doctor # kit requires + which bins/paths were resolved
17
17
  groundwork-console token # print the token
18
18
  groundwork-console config # print the config path
@@ -22,7 +22,7 @@ Requires on the host: Node ≥ 22, tmux (pane monitor / spawn), and either the t
22
22
 
23
23
  ## From another machine
24
24
 
25
- `groundwork-console --tailscale` binds this machine's Tailscale IP (found via `tailscale ip -4`) instead of loopback and prints a sign-in link you can open from any device on your tailnet; the token cookie does the rest (plain http — keep it on the tailnet, or put TLS in front). Without Tailscale: `ssh -N -L 8790:127.0.0.1:8790 <host>` and open `http://localhost:8790`.
25
+ `groundwork-console --tailscale` binds this machine's Tailscale IP (found via `tailscale ip -4`) instead of loopback and prints a sign-in link you can open from any device on your tailnet; the token cookie does the rest (plain http — keep it on the tailnet, or put TLS in front). On the same home network without Tailscale: `groundwork-console --lan` binds the primary private LAN IPv4 (or `--lan=<ip>` to pin one) — same sign-in link flow, plain http, trusted networks only. Otherwise: `ssh -N -L 8790:127.0.0.1:8790 <host>` and open `http://localhost:8790`. `--no-auth` is refused together with `--lan`/`--tailscale` (token auth must front any non-loopback bind).
26
26
 
27
27
  ## What it does
28
28
 
@@ -39,6 +39,6 @@ Writes are behind two one-click header switches (bus writes · seam & docs write
39
39
  ## Config & env
40
40
 
41
41
  `~/.groundwork-console/config.json`: `{ "kitPath": "~/workspace/groundwork-kit", "coordDir": "~/agent-coord", "projects": [{ "name", "path", "room" }] }`.
42
- Env: `GROUNDWORK_CONSOLE_HOST` (bind; default `127.0.0.1` — put the token auth in front before exposing), `GROUNDWORK_CONSOLE_TOKEN`, `GROUNDWORK_CONSOLE_AUTH=off`, `GROUNDWORK_CONSOLE_CONFIG`, `GROUNDWORK_CONSOLE_RULES`, `AGENT_COORD_DIR`, `PORT`. (`KIT_CONSOLE_*` from the pre-rename days is still honoured.)
42
+ Env: `GROUNDWORK_CONSOLE_HOST` (bind; default `127.0.0.1` — put the token auth in front before exposing), `GROUNDWORK_CONSOLE_TOKEN`, `GROUNDWORK_CONSOLE_UPLOAD_TTL_HOURS` (pasted images are purged after this many hours; default 6), `GROUNDWORK_CONSOLE_AUTH=off`, `GROUNDWORK_CONSOLE_CONFIG`, `GROUNDWORK_CONSOLE_RULES`, `AGENT_COORD_DIR`, `PORT`. (`KIT_CONSOLE_*` from the pre-rename days is still honoured.)
43
43
 
44
44
  Source, issues, design notes: <https://github.com/davidbalzan/groundwork-console>. MIT.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  /* groundwork-console — one command: starts the API + serves the built UI on one port, prints the sign-in link, opens the browser.
3
- Flags: --port 8790 · --host 127.0.0.1 · --tailscale (bind the tailnet IP, print the remote sign-in link) · --no-open · --no-auth · --config <file>
3
+ Flags: --port 8790 · --host 127.0.0.1 · --tailscale (bind the tailnet IP) · --lan[=<ip>] (bind the primary private LAN IPv4) · --no-open · --no-auth · --config <file>
4
+ Both remote flags print the remote sign-in link; token auth must front any non-loopback bind (--no-auth + --lan/--tailscale refuses unless GROUNDWORK_CONSOLE_UNSAFE_LAN=1).
4
5
  Subcommands: token (print the token) · doctor (kit requires + paths) · config (print config path) */
5
6
  import { readFileSync, existsSync } from "node:fs";
6
7
  import { execFileSync } from "node:child_process";
@@ -23,7 +24,23 @@ function tailscaleIp() {
23
24
  for (const addrs of Object.values(os.networkInterfaces())) for (const a of addrs ?? []) if (a.family === "IPv4" && /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a.address)) return a.address;
24
25
  return null;
25
26
  }
27
+ /** Primary private LAN IPv4: prefer en0/eth0, else the first non-internal 10/8, 172.16/12, 192.168/16 address. */
28
+ function lanIp() {
29
+ const ifs = os.networkInterfaces();
30
+ const priv = (a) => /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a);
31
+ const pick = (names) => { for (const n of names) for (const a of ifs[n] ?? []) if (a.family === "IPv4" && !a.internal && priv(a.address)) return a.address; return null; };
32
+ return pick(["en0", "eth0", "en1", "wlan0"]) ?? pick(Object.keys(ifs).sort());
33
+ }
26
34
  let host = String(flag("--host", process.env.GROUNDWORK_CONSOLE_HOST ?? "127.0.0.1"));
35
+ const lanArg = args.find((a) => a === "--lan" || a.startsWith("--lan="));
36
+ if (lanArg && has("--tailscale")) { console.error("--lan and --tailscale are exclusive — pick one bind"); process.exit(1); }
37
+ if ((lanArg || has("--tailscale")) && has("--no-auth") && process.env.GROUNDWORK_CONSOLE_UNSAFE_LAN !== "1") { console.error("refusing a non-loopback bind with auth off — drop --no-auth (token auth fronts the LAN/tailnet), or set GROUNDWORK_CONSOLE_UNSAFE_LAN=1 if you really mean it"); process.exit(1); }
38
+ if (lanArg) {
39
+ const pinned = lanArg.includes("=") ? lanArg.split("=")[1] : null;
40
+ const ip = pinned ?? lanIp();
41
+ if (!ip) { console.error("--lan: no private LAN IPv4 found (interfaces: " + Object.keys(os.networkInterfaces()).join(", ") + ") — pass --lan=<ip>"); process.exit(1); }
42
+ host = ip; if (!process.env.GROUNDWORK_CONSOLE_PUBLIC_API) process.env.GROUNDWORK_CONSOLE_PUBLIC_API = `http://${ip}:${port}/api`;
43
+ }
27
44
  if (has("--tailscale")) { const ip = tailscaleIp(); if (!ip) { console.error("--tailscale: no tailnet address found (is Tailscale running? try `tailscale ip -4`)"); process.exit(1); } host = ip; if (!process.env.GROUNDWORK_CONSOLE_PUBLIC_API) process.env.GROUNDWORK_CONSOLE_PUBLIC_API = `http://${ip}:${port}/api`; }
28
45
  if (flag("--config")) process.env.GROUNDWORK_CONSOLE_CONFIG = String(flag("--config"));
29
46
  process.env.PORT = port; process.env.GROUNDWORK_CONSOLE_HOST = host;
@@ -41,6 +58,8 @@ if (sub === "doctor") {
41
58
  console.log(`groundwork-console ${pkg.version} · config ${CONFIG_FILE} · store ${cfg.coordDir}`);
42
59
  for (const k of ["seam", "mcp", "groundwork", "playbook"]) console.log(` ${r[k].ok === false ? "✗" : r[k].ok ? "✓" : "?"} ${k.padEnd(10)} ${String(r[k].installed ?? "missing").padEnd(8)} needs ${r[k].required}${r.paths[k] ? ` ← ${r.paths[k]}` : ""}`);
43
60
  console.log(` kit checkout: ${r.paths.kitCheckout ?? "none (using installed packages)"}`);
61
+ // Capability probe: connect once to the MCP (as groundwork-console, read-only) and check send_message.inReplyTo — the bus feature the console's answered-decisions logic relies on.
62
+ try { const { BusClient } = await import("../dist/mcp.js"); const cl = new BusClient(cfg.kitPath, cfg.coordDir, "groundwork-console"); await cl.connect(); const ok = cl.supports("send_message", "inReplyTo"); await cl.close(); console.log(` ${ok ? "✓" : "⚠"} mcp capability inReplyTo ${ok ? "present" : "missing — decision replies will not be linked/cleared; upgrade agent-coord-mcp"}`); } catch (e) { console.log(` ? mcp capability probe failed: ${e.message}`); }
44
63
  if (!r.ok) { console.log("\nfix: npm i -g agent-coord-mcp @davidbalzan/groundwork agent-coordination"); process.exit(1); }
45
64
  process.exit(0);
46
65
  }
@@ -52,5 +71,5 @@ const { CONFIG_FILE } = await import("../dist/config.js");
52
71
  const base = `http://${host === "0.0.0.0" ? "localhost" : host}:${port}`;
53
72
  const token = authEnabled ? loadToken() : null;
54
73
  const url = token ? `${base}/api/auth/login?token=${encodeURIComponent(token)}&next=/` : `${base}/`;
55
- console.log(`\n groundwork-console ${pkg.version}\n UI ${base}/\n config ${CONFIG_FILE}\n token ${authEnabled ? tokenFile : "auth off"}\n sign in ${url}${has("--tailscale") ? `\n remote open the sign-in link above from any device on your tailnet (token cookie, 30 d). Plain http — keep it on the tailnet.` : ""}\n`);
74
+ console.log(`\n groundwork-console ${pkg.version}\n UI ${base}/\n config ${CONFIG_FILE}\n token ${authEnabled ? tokenFile : "auth off"}\n sign in ${url}${has("--tailscale") ? `\n remote open the sign-in link above from any device on your tailnet (token cookie, 30 d). Plain http — keep it on the tailnet.` : lanArg ? `\n remote open the sign-in link above from any device on this LAN (token cookie, 30 d). Plain http on your local network — home/trusted networks only, never a shared or public one.` : ""}\n`);
56
75
  if (!has("--no-open") && host !== "0.0.0.0") { try { const { default: open } = await import("open"); await open(url); } catch { /* headless: link is printed */ } }
package/dist/answered.js CHANGED
@@ -1,40 +1,11 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, readdirSync, unlinkSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
- /* Console-owned "answered" set for DAVID_DECISION packets (room posts + DMs), keyed by message id.
5
- Until the bus has inReplyTo (queued P3), this is how the UI stops showing a packet as open once David replied from the console.
6
- Persisted in ~/.groundwork-console/answered.json — never touches ~/agent-coord. */
7
- const FILE = path.join(homedir(), ".groundwork-console", "answered.json");
8
- let cache = null;
9
- export function answered() {
10
- if (cache)
11
- return cache;
12
- try {
13
- cache = existsSync(FILE) ? JSON.parse(readFileSync(FILE, "utf8")) : {};
14
- }
15
- catch {
16
- cache = {};
17
- }
18
- return cache;
19
- }
20
- export function markAnswered(ids, meta) {
21
- const a = answered();
22
- let n = 0;
23
- for (const id of ids)
24
- if (id && !a[id]) {
25
- a[id] = { ts: Date.now(), ...meta };
26
- n++;
27
- }
28
- if (n) {
29
- mkdirSync(path.dirname(FILE), { recursive: true });
30
- writeFileSync(FILE, JSON.stringify(a, null, 2));
31
- }
32
- return n;
33
- }
34
- /* Bus-native answered set (kit ≥ 18ffbf4): any message FROM david carrying inReplyTo:<id> answers <id>. Scanned from ~/agent-coord inbox/*.jsonl + rooms/*.jsonl, cached 10 s. Unioned with the console-owned set above. */
4
+ /* Answered DAVID_DECISION packets bus-native only (kit 18ffbf4): a message FROM david carrying inReplyTo:<id> answers <id>.
5
+ Derived by scanning ~/agent-coord inbox/*.jsonl + rooms/*.jsonl (cached 10 s). The console-owned answered.json store was retired 2026-08-19. */
35
6
  let busCache = null;
36
7
  let coordRoot = null;
37
- export function setCoordDir(dir) { coordRoot = dir; }
8
+ export function setCoordDir(dir) { coordRoot = dir; busCache = null; }
38
9
  function busAnswered() {
39
10
  if (!coordRoot)
40
11
  return new Set();
@@ -64,4 +35,15 @@ function busAnswered() {
64
35
  busCache = { at: Date.now(), ids };
65
36
  return ids;
66
37
  }
67
- export const isAnswered = (id) => typeof id === "string" && (!!answered()[id] || busAnswered().has(id));
38
+ export const isAnswered = (id) => typeof id === "string" && busAnswered().has(id);
39
+ /** Forget the cache (after the console itself sends a reply) so the next read reflects it immediately. */
40
+ export const invalidateAnswered = () => { busCache = null; };
41
+ /** One-time cleanup of the retired console-owned store. Returns true if a file was removed. */
42
+ const LEGACY_FILE = path.join(homedir(), ".groundwork-console", "answered.json");
43
+ export function removeLegacyAnsweredFile() { try {
44
+ if (existsSync(LEGACY_FILE)) {
45
+ unlinkSync(LEGACY_FILE);
46
+ return true;
47
+ }
48
+ }
49
+ catch { /* leave it */ } return false; }
package/dist/bus.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /* Read-only views over the agent-coord store (~/agent-coord). Mirrors what the MCP tools return, without going through the MCP. */
2
2
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
3
  import path from "node:path";
4
- import { listPanes } from "./tmux.js";
4
+ import { listPanes, contentActivity } from "./tmux.js";
5
5
  const readJson = (p, fallback) => { try {
6
6
  return JSON.parse(readFileSync(p, "utf8"));
7
7
  }
@@ -21,12 +21,19 @@ const tailJsonl = (p, limit) => {
21
21
  };
22
22
  export async function agentsAsync(root) {
23
23
  const panes = await listPanes();
24
- return agentsSync(root).map((a) => {
24
+ const base = agentsSync(root);
25
+ // Content-based activity for live panes only (one capture per pane per poll); window_activity is the fallback.
26
+ const content = new Map();
27
+ await Promise.all(base.map(async (a) => { const pane = a.pane ? panes.find((p) => p.paneId === a.pane || p.target === a.pane) : undefined; if (pane && !pane.dead)
28
+ content.set(a.agentId, await contentActivity(pane.paneId)); }));
29
+ return base.map((a) => {
25
30
  const pane = a.pane ? panes.find((p) => p.paneId === a.pane || p.target === a.pane) : undefined;
26
- const paneActivity = pane?.activityTs ?? null;
31
+ const windowActivity = pane?.activityTs ?? null;
32
+ const ct = content.get(a.agentId);
33
+ const paneActivity = ct === undefined || ct === null ? windowActivity : ct || null; // 0 = seeded, no change observed yet → treated as unknown, not as "now"
27
34
  const lastSeen = Math.max(a.lastSeen ?? 0, paneActivity ?? 0);
28
35
  const age = lastSeen ? Date.now() - lastSeen : Infinity;
29
- return { ...a, paneActivity, paneCommand: pane?.command ?? null, paneAlive: pane ? !pane.dead : null, lastSeen: lastSeen || null, heartbeatAgeMs: Number.isFinite(age) ? age : null, liveness: age < 15 * 60_000 ? "live" : age < 24 * 3_600_000 ? "idle" : "dead", active: paneActivity ? Date.now() - paneActivity < 60_000 : false };
36
+ return { ...a, paneActivity, windowActivity, paneCommand: pane?.command ?? null, paneAlive: pane ? !pane.dead : null, lastSeen: lastSeen || null, heartbeatAgeMs: Number.isFinite(age) ? age : null, liveness: age < 15 * 60_000 ? "live" : age < 24 * 3_600_000 ? "idle" : "dead", active: paneActivity ? Date.now() - paneActivity < 60_000 : false };
30
37
  }).sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
31
38
  }
32
39
  export const agents = agentsSync;
@@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, readFileSync, realpathSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ // TODO(kit publish): bump mcp to the version that ships send_message.inReplyTo (kit main 18ffbf4 is still tagged 0.23.0) — until then the capability is detected at runtime (BusClient.supports) and surfaced in /health.requires.mcp.capabilities.
5
6
  export const REQUIRES = { seam: "^0.1.0", mcp: "^0.23.0", groundwork: "^0.3.0", playbook: "^0.5.0" };
6
7
  const which = (bin) => { try {
7
8
  return execFileSync("which", [bin], { encoding: "utf8" }).trim() || null;
@@ -71,10 +72,10 @@ export function satisfies(v, range) {
71
72
  return false;
72
73
  return a > x || b > y || (b === y && c >= z);
73
74
  }
74
- export function requiresReport(kitPath) {
75
+ export function requiresReport(kitPath, caps) {
75
76
  const inst = installedVersions(kitPath);
76
77
  const check = (k) => ({ required: REQUIRES[k], installed: inst[k], ok: satisfies(inst[k], REQUIRES[k]) });
77
- const r = { seam: check("seam"), mcp: check("mcp"), groundwork: check("groundwork"), playbook: check("playbook") };
78
+ const r = { seam: check("seam"), mcp: { ...check("mcp"), capabilities: caps ?? { inReplyTo: null }, warnings: caps?.inReplyTo === false ? ["inReplyTo missing — decision replies will not be linked/cleared; upgrade agent-coord-mcp"] : [] }, groundwork: check("groundwork"), playbook: check("playbook") };
78
79
  return { ...r, paths: inst.paths, ok: Object.values(r).every((x) => x.ok !== false) };
79
80
  }
80
81
  /** Package root dir for a kit package: the checkout's packages/<dir> if present, else the installed npm package (found from its bin). Null when neither exists. */
package/dist/mcp.js CHANGED
@@ -14,7 +14,7 @@ const IDENTITIES = {
14
14
  };
15
15
  export const AGENT_ID = "groundwork-console";
16
16
  export const READ_ONLY_TOOLS = new Set(["doctor", "list_agents", "list_rooms", "list_scopes", "status", "ping", "read_messages", "retrieve_room_history", "retrieve_message", "list_work"]);
17
- export const WRITE_TOOLS_GATED = new Set(["send_message", "post_status", "prune", "force_unregister", "unregister", "join_room", "leave_room", "delete_room", "set_room_topic", "set_room_motd", "import_work", "export_work"]);
17
+ export const WRITE_TOOLS_GATED = new Set(["send_message", "send_command", "post_status", "prune", "force_unregister", "unregister", "join_room", "leave_room", "delete_room", "set_room_topic", "set_room_motd", "import_work", "export_work"]);
18
18
  const SWITCH_FILE = path.join(homedir(), ".groundwork-console", "switches.json");
19
19
  function loadSwitches() {
20
20
  let saved = {};
@@ -93,6 +93,7 @@ export class BusClient {
93
93
  toolArgs = new Map();
94
94
  /** True when the connected server's tool schema lists this argument (false when unknown or not connected yet). */
95
95
  supports(tool, arg) { return this.toolArgs.get(tool)?.has(arg) ?? false; }
96
+ get connected() { return !!this.client; }
96
97
  async call(name, args, log) {
97
98
  const isWrite = WRITE_TOOLS_GATED.has(name);
98
99
  if (!READ_ONLY_TOOLS.has(name) && !isWrite)
package/dist/provision.js CHANGED
@@ -25,6 +25,30 @@ async function fleetWindow(project, startDir, log) {
25
25
  log(`created window ${win} in session ${session} (first pane ${pane} reused for the first agent — no spare shell)`);
26
26
  return { target: `${session}:${win}`, freshPane: pane };
27
27
  }
28
+ const PROMPT_GLYPH = { claude: /^\s*[❯>]\s?/, grok: /^\s*[❯>›]\s?/, codex: /^\s*[❯>›]\s?/, opencode: /^\s*[❯>›]\s?/ };
29
+ /** Was `line` submitted? Decided from the INPUT BOX only — the last prompt-glyph line framed by a rule above it (Claude Code always frames the box; other harnesses: within the last 4 lines) —
30
+ never from transcript echoes ("❯ <submitted text>" followed by output). Stuck ⇔ the input still holds the typed text, or a slash popup is open under it. Pure; unit-tested in scripts/test-inject.mjs. */
31
+ export function inputSubmitted(captureText, harness, line) {
32
+ const lines = captureText.split("\n").map((l) => l.replace(/\u00a0/g, " ").replace(/\s+$/, ""));
33
+ const isRule = (l) => !!l && /^\s*[─━-]{10,}\s*$/.test(l);
34
+ const needle = line.slice(0, Math.min(line.length, 40));
35
+ let idx = -1;
36
+ for (let i = lines.length - 1; i >= 0; i--) {
37
+ if (!PROMPT_GLYPH[harness].test(lines[i]))
38
+ continue;
39
+ if (isRule(lines[i - 1]) || (harness !== "claude" && i >= lines.length - 4)) {
40
+ idx = i;
41
+ break;
42
+ }
43
+ else
44
+ break;
45
+ } // Claude Code always frames its box with a rule; an unframed ❯ line is a transcript echo
46
+ if (idx < 0)
47
+ return true; // no input box visible → nothing is sitting in the input
48
+ const inputBlock = lines.slice(idx, Math.min(lines.length, idx + 3)).filter((l) => !isRule(l)).join(" "); // the box can wrap
49
+ const popup = /\/[a-z][\w-]*\s{2,}\S/.test(lines.slice(idx + 1, idx + 7).join(" ")); // slash popup rows: "/coord-worker Act as…"
50
+ return !inputBlock.includes(needle) && !popup;
51
+ }
28
52
  export async function spawnAgent(req, isRegistered, log, isAttached = () => true, roleOf = () => null) {
29
53
  if (!/^[a-z0-9][a-z0-9-_]*$/i.test(req.agentId))
30
54
  throw new Error("agentId: letters, digits, - _");
@@ -44,14 +68,78 @@ export async function spawnAgent(req, isRegistered, log, isAttached = () => true
44
68
  await tmux("send-keys", "-t", paneId, "C-m");
45
69
  log(`launched: ${cmd}`);
46
70
  const wait = Math.min(Math.max(req.waitSeconds ?? 8, 3), 60);
47
- log(`waiting ${wait}s for the harness to start…`);
48
- await sleep(wait * 1000);
49
- const inject = async (line) => { await tmux("send-keys", "-t", paneId, "-l", line); await sleep(400); await tmux("send-keys", "-t", paneId, "C-m"); log(`injected: ${line}`); await sleep(600); };
71
+ const capture = async () => { try {
72
+ return await tmux("capture-pane", "-p", "-t", paneId, "-S", "-40");
73
+ }
74
+ catch {
75
+ return "";
76
+ } };
77
+ const paneAlive = async () => { try {
78
+ const out = await tmux("list-panes", "-t", target, "-F", "#{pane_id}\t#{pane_dead}");
79
+ return out.split("\n").some((l) => l.startsWith(paneId + "\t0"));
80
+ }
81
+ catch {
82
+ return false;
83
+ } };
84
+ // (1) Ready-prompt wait: poll the pane until the harness input prompt is visible (bounded by waitSeconds). Claude Code: "? for shortcuts" / the "›" input box; others: a prompt glyph on the last lines.
85
+ const READY = { claude: /\? for shortcuts|bypass permissions|esc to interrupt|Welcome to Claude Code|Claude Code v\d/i, grok: /grok|›/i, codex: /codex|›/i, opencode: /opencode|›/i }; // never the bare shell prompt: an early inject would type into zsh
86
+ const t0 = Date.now();
87
+ let ready = false;
88
+ while (Date.now() - t0 < wait * 1000) {
89
+ if (!(await paneAlive()))
90
+ break;
91
+ if (READY[req.harness].test(await capture())) {
92
+ ready = true;
93
+ break;
94
+ }
95
+ await sleep(700);
96
+ }
97
+ log(ready ? `harness prompt visible after ${((Date.now() - t0) / 1000).toFixed(1)}s` : `⚠ no harness prompt seen within ${wait}s — injecting anyway`);
98
+ if (!(await paneAlive()))
99
+ throw new Error(`pane ${paneId} died before the seed (harness failed to start?) — check the window ${target}`);
100
+ // (2) Verified injection: type the line, settle, C-m, then confirm the typed text left the input; on a stuck slash popup (Claude Code eats the first C-m while the command menu is open) → Escape + C-m, up to 3 tries.
101
+ // "Submitted" is decided from the INPUT LINE only (the line starting with the harness prompt glyph), never from transcript echoes: Claude Code echoes the
102
+ // submitted command in the transcript, and an Escape sent on a false "stuck" would interrupt the running turn (skill boot / registration).
103
+ const submitted = async (line) => inputSubmitted(await capture(), req.harness, line);
104
+ const inject = async (line) => {
105
+ for (let attempt = 1; attempt <= 3; attempt++) {
106
+ if (!(await paneAlive()))
107
+ throw new Error(`✗ inject failed: pane died — "${line}"`);
108
+ if (attempt === 1) {
109
+ await tmux("send-keys", "-t", paneId, "-l", line);
110
+ await sleep(450);
111
+ }
112
+ else {
113
+ await tmux("send-keys", "-t", paneId, "Escape");
114
+ await sleep(250);
115
+ }
116
+ await tmux("send-keys", "-t", paneId, "C-m");
117
+ const d = Date.now() + 5_000;
118
+ let ok = false;
119
+ while (Date.now() < d) {
120
+ await sleep(400);
121
+ if (await submitted(line)) {
122
+ ok = true;
123
+ break;
124
+ }
125
+ }
126
+ if (ok) {
127
+ log(`injected${attempt > 1 ? ` (attempt ${attempt})` : ""}: ${line}`);
128
+ await sleep(500);
129
+ return;
130
+ }
131
+ log(`… "${line.slice(0, 50)}" still in the input after C-m (attempt ${attempt}) — ${attempt < 3 ? "Escape + retry" : "giving up"}`);
132
+ }
133
+ throw new Error(`✗ inject failed after 3 tries: "${line}" — the harness did not accept the line; seed aborted (registration wait skipped)`);
134
+ };
50
135
  if (req.model && CLAUDE_HARNESS_ONLY.has(req.harness))
51
136
  await inject(`/model ${req.model}`);
52
137
  if (CLAUDE_HARNESS_ONLY.has(req.harness))
53
138
  await inject(`/color ${ROLE_COLOR[req.role]}`);
54
- const skill = `${ROLE_SKILL[req.role]} --id=${req.agentId}${req.lane ? ` --lane=${req.lane}` : ""}${req.room ? ` --room=${req.room}` : ""}`;
139
+ // Skill line: the common coord-* signature (kit b84e14c): --id --role --project --room [--rooms] --repo [--lane]. Cards accept it once the machine's playbook is updated from the kit; older cards ignore unknown flags.
140
+ const roleId = `coord-${req.role === "coordinator" ? "coordinator" : req.role}`;
141
+ const skill = `${ROLE_SKILL[req.role]} --id=${req.agentId} --role=${roleId} --project=${req.project} --room=${req.room ?? req.project} --repo=${req.repoPath}${req.lane ? ` --lane=${req.lane}` : ""}`;
142
+ log(`seed (common coord-* signature, kit b84e14c): ${skill}`);
55
143
  await inject(CLAUDE_HARNESS_ONLY.has(req.harness) ? skill : `Act per the ${ROLE_SKILL[req.role].slice(1)} playbook card: ${skill}${req.note ? ". " + req.note : ""}`);
56
144
  if (req.note && CLAUDE_HARNESS_ONLY.has(req.harness))
57
145
  await inject(req.note);
@@ -63,6 +151,8 @@ export async function spawnAgent(req, isRegistered, log, isAttached = () => true
63
151
  registered = true;
64
152
  break;
65
153
  }
154
+ if (!(await paneAlive()))
155
+ throw new Error(`✗ pane ${paneId} died while waiting for ${req.agentId} to register — seed aborted`);
66
156
  await sleep(2_000);
67
157
  }
68
158
  log(registered ? `✓ ${req.agentId} registered on the bus` : `⚠ ${req.agentId} not registered after 90s — check the pane`);
package/dist/runs.js CHANGED
@@ -1,6 +1,38 @@
1
- const RING = 200, LOG_MAX = 400;
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ const RING = 200, LOG_MAX = 400, PERSIST_LOG = 60;
5
+ /* Persistence: ~/.groundwork-console/runs.json (state dir only — never ~/agent-coord). Write-behind (debounced 300 ms) on every change; loaded on construct.
6
+ Runs still "running" at load are marked failed ("api restarted") so nothing looks alive that isn't. Per-run logs are truncated to PERSIST_LOG lines on disk. */
7
+ const RUNS_FILE = path.join(homedir(), ".groundwork-console", "runs.json");
2
8
  export class Runs {
3
9
  runs = [];
10
+ saveTimer = null;
11
+ constructor() {
12
+ try {
13
+ if (existsSync(RUNS_FILE)) {
14
+ const j = JSON.parse(readFileSync(RUNS_FILE, "utf8"));
15
+ this.runs = (j.runs ?? []).slice(-RING).map((r) => (r.status === "running" || r.status === "queued" ? { ...r, status: "failed", endedAt: r.endedAt ?? Date.now(), error: r.error ?? "api restarted while running" } : r));
16
+ this.seq = j.seq ?? 0;
17
+ }
18
+ }
19
+ catch {
20
+ this.runs = [];
21
+ } // corrupt file: start empty, next save overwrites
22
+ }
23
+ save() {
24
+ if (this.saveTimer)
25
+ return;
26
+ this.saveTimer = setTimeout(() => {
27
+ this.saveTimer = null;
28
+ try {
29
+ mkdirSync(path.dirname(RUNS_FILE), { recursive: true });
30
+ const slim = this.runs.map((r) => ({ ...r, log: r.log.length > PERSIST_LOG ? [`… ${r.log.length - PERSIST_LOG} earlier lines`, ...r.log.slice(-PERSIST_LOG)] : r.log }));
31
+ writeFileSync(RUNS_FILE, JSON.stringify({ seq: this.seq, savedAt: Date.now(), runs: slim }));
32
+ }
33
+ catch { /* disk unavailable: in-memory ring still works */ }
34
+ }, 300);
35
+ }
4
36
  listeners = new Set();
5
37
  seq = 0;
6
38
  list(limit = 50) { return this.runs.slice(-limit).reverse(); }
@@ -8,7 +40,7 @@ export class Runs {
8
40
  running() { return this.runs.filter((r) => r.status === "running" || r.status === "queued"); }
9
41
  subscribe(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
10
42
  emit(r) { for (const l of this.listeners)
11
- l(r); }
43
+ l(r); this.save(); }
12
44
  create(init) {
13
45
  const r = { id: `run-${Date.now()}-${++this.seq}`, status: "queued", startedAt: Date.now(), log: [], ...init };
14
46
  this.runs.push(r);