@monster-orange/den 0.4.14 → 0.4.15

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 (54) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +144 -8
  4. package/package.json +1 -1
  5. package/src/shared/actor-token.ts +12 -0
  6. package/src/shared/agents/conversations.ts +33 -4
  7. package/src/shared/agents/den-claude.ts +13 -3
  8. package/src/shared/agents/records.ts +18 -0
  9. package/src/shared/agents/runtime.ts +6 -1
  10. package/src/shared/claim-code.ts +163 -0
  11. package/src/shared/cli-command.ts +31 -0
  12. package/src/shared/den-config.ts +12 -0
  13. package/src/shared/environment-manager/workspaces.ts +20 -0
  14. package/src/shared/web-session.ts +64 -5
  15. package/src/ui/cli/agent-ops.ts +75 -22
  16. package/src/ui/cli/agents-command.ts +4 -1
  17. package/src/ui/cli/doctor-command.ts +29 -3
  18. package/src/ui/cli/doctor-onboard.ts +26 -0
  19. package/src/ui/cli/flag-parser.ts +9 -1
  20. package/src/ui/cli/index.ts +29 -3
  21. package/src/ui/cli/onboard-command.ts +180 -0
  22. package/src/ui/cli/onboard-reads.ts +613 -0
  23. package/src/ui/cli/repo-command.ts +156 -10
  24. package/src/ui/cli/web-command.ts +92 -1
  25. package/src/ui/mcp/api.ts +14 -2
  26. package/src/ui/mcp/server.ts +12 -2
  27. package/src/ui/mcp/tools.ts +368 -1
  28. package/src/ui/server/agent-routes.ts +2 -5
  29. package/src/ui/server/approval-routes.ts +390 -0
  30. package/src/ui/server/approvals.ts +416 -0
  31. package/src/ui/server/github-routes.ts +12 -4
  32. package/src/ui/server/held-actions.ts +203 -0
  33. package/src/ui/server/http-helpers.ts +35 -0
  34. package/src/ui/server/index.ts +286 -16
  35. package/src/ui/server/lifecycle-exec.ts +70 -0
  36. package/src/ui/server/onboard-routes.ts +132 -0
  37. package/src/ui/server/pair-routes.ts +7 -4
  38. package/src/ui/server/public/agents-page.js +91 -4
  39. package/src/ui/server/public/error-page.js +27 -3
  40. package/src/ui/server/public/header.js +1 -1
  41. package/src/ui/server/public/i18n/es.js +29 -0
  42. package/src/ui/server/public/i18n/ja.js +31 -0
  43. package/src/ui/server/public/index.html +1 -1
  44. package/src/ui/server/public/local-dev.html +5 -4
  45. package/src/ui/server/public/persona-ui.css +21 -0
  46. package/src/ui/server/public/release.html +0 -1
  47. package/src/ui/server/public/settings-registry.js +3 -1
  48. package/src/ui/server/public/settings.html +9 -1
  49. package/src/ui/server/public/shell.js +313 -0
  50. package/src/ui/server/request-guard.ts +2 -0
  51. package/src/ui/server/route-classes.ts +28 -1
  52. package/src/ui/server/session-routes.ts +11 -3
  53. package/src/ui/server/workspace-routes.ts +21 -10
  54. package/themes/den/den-theme.json +6 -6
@@ -383,6 +383,8 @@
383
383
  "err.signin.noTerminal":
384
384
  "Don't have a terminal on {host}? From this computer, run:",
385
385
  "err.signin.openTerminal": "Open Terminal",
386
+ "err.signin.sshUserNote":
387
+ "Log in with your {host} account ({user}) and its password, or an SSH key.",
386
388
  "err.signin.sshNote":
387
389
  "Open Terminal opens ssh://{target} in your terminal app. It needs SSH access to {target}.",
388
390
  "err.signin.whyTitle": "Why?",
@@ -569,6 +571,11 @@
569
571
  "ag.new.name": "Name (optional)",
570
572
  "ag.new.as": "Starts as {id}",
571
573
  "ag.new.openRunning": "Open the running one",
574
+ "ag.new.isRunning": "{id} is already running",
575
+ "ag.new.openIt": "Open it",
576
+ "ag.new.isStopped": "{id} is stopped",
577
+ "ag.new.resumeIt": "Resume it",
578
+ "ag.new.orNext": "or Start makes {id}",
572
579
  "ag.new.taskPh":
573
580
  "e.g. Take the empty-state story. Match the design and add a test.",
574
581
  "ag.new.adv": "Advanced: model and permissions",
@@ -598,6 +605,27 @@
598
605
  "ag.bring.now": "just now",
599
606
  "ag.bring.min": "{n} min ago",
600
607
  "ag.bring.h": "{n} h ago",
608
+ "ap.title": "{agent} wants to:",
609
+ "ap.agent": "The {name} agent",
610
+ "ap.noAgent": "Something on this machine (no agent identity)",
611
+ "ap.noAgentWarn": "If you didn't ask an agent to do this, Deny.",
612
+ "ap.signin.title": "Something is waiting for your approval.",
613
+ "ap.signin": "Sign in to see it: run {cmd} and open the link.",
614
+ "ap.do.workspace_create": 'Create the workspace "{name}"',
615
+ "ap.do.repo_add": 'Add the repo at {path} to workspace "{workspace}"',
616
+ "ap.do.repo_add.new":
617
+ 'Add the repo at {path} to a new workspace "{workspace}"',
618
+ "ap.do.service_try": "Start {service}",
619
+ "ap.says": "Agent says:",
620
+ "ap.review": "Review",
621
+ "ap.hide": "Hide details",
622
+ "ap.later": "Later",
623
+ "ap.deny": "Deny",
624
+ "ap.allow": "Allow",
625
+ "ap.waits": "Nothing changes until you allow it. It waits until {time}.",
626
+ "ap.more": "{n} more waiting",
627
+ "ap.failed": "It ran, and failed: {error}",
628
+ "ap.done": "Done: {what}",
601
629
  "ag.new.note":
602
630
  "Starts in the workspace folder. It keeps running if you close the pane.",
603
631
  "ag.new.started": "Started {p} in {w}",
@@ -3852,6 +3880,37 @@
3852
3880
  // server start: a 403 `csrf` (the server restarted) fetches a fresh one
3853
3881
  // and retries once. Never sent to another origin.
3854
3882
  var CSRF_METHODS = { POST: 1, PUT: 1, PATCH: 1, DELETE: 1 };
3883
+ // 0.4.15: the approval key (web-session.ts). A sign-in's 303 carries it
3884
+ // in the URL fragment (#ak=…), which never reaches a server: keep it in
3885
+ // this origin's localStorage (other localhost ports can't read it),
3886
+ // strip it from the address bar, and send it with every same-origin
3887
+ // change as X-Den-Approve-Key. Only approving needs it.
3888
+ var APPROVE_KEY_STORE = "den.approveKey";
3889
+ (function takeApproveKey() {
3890
+ var m = /(?:^#|&)ak=([A-Za-z0-9_-]{43})(?:&|$)/.exec(location.hash || "");
3891
+ if (!m) return;
3892
+ try {
3893
+ localStorage.setItem(APPROVE_KEY_STORE, m[1]);
3894
+ } catch (e) {
3895
+ /* storage blocked: this browser signs in again to approve */
3896
+ }
3897
+ try {
3898
+ history.replaceState(
3899
+ history.state,
3900
+ "",
3901
+ location.pathname + location.search,
3902
+ );
3903
+ } catch (e) {
3904
+ location.hash = "";
3905
+ }
3906
+ })();
3907
+ function approveKey() {
3908
+ try {
3909
+ return localStorage.getItem(APPROVE_KEY_STORE);
3910
+ } catch (e) {
3911
+ return null;
3912
+ }
3913
+ }
3855
3914
  var csrf = { token: null, pending: null };
3856
3915
  function csrfToken(fresh) {
3857
3916
  if (fresh) csrf.token = null;
@@ -3890,6 +3949,9 @@
3890
3949
  (init && init.headers) || (isRequest(input) ? input.headers : undefined),
3891
3950
  );
3892
3951
  headers.set("X-Den-CSRF", token);
3952
+ var ak = approveKey();
3953
+ if (ak && !headers.has("X-Den-Approve-Key"))
3954
+ headers.set("X-Den-Approve-Key", ak);
3893
3955
  return Object.assign({}, init || {}, { headers: headers });
3894
3956
  }
3895
3957
  function csrfFetch(input, init) {
@@ -3958,6 +4020,257 @@
3958
4020
  if (!apiOffPage) csrfToken(false);
3959
4021
  }
3960
4022
 
4023
+ // ── ON-3: an agent's held write waits for the person ─────────────────
4024
+ // (approval-routes.ts) Every page asks GET /api/approvals every few
4025
+ // seconds while it is in view, and opens the oldest request: Den's own
4026
+ // words for it (the agent's note only quoted), Review (what it will do),
4027
+ // Later, Deny, Allow. Allow and Deny go through the wrapped fetch above,
4028
+ // so they carry the CSRF token and the approval key; the server decides
4029
+ // who may approve (a browser that isn't signed in is told to sign in).
4030
+ // The frame is the Start dialog's (persona-ui.css's .pe-dlg).
4031
+ var ap = { dlg: null, shown: null, later: {}, busy: false, css: null };
4032
+ function apCss() {
4033
+ if (ap.css) return ap.css;
4034
+ ap.css = new Promise(function (done) {
4035
+ if (document.querySelector('link[href="/persona-ui.css"]')) return done();
4036
+ var l = document.createElement("link");
4037
+ l.rel = "stylesheet";
4038
+ l.href = "/persona-ui.css";
4039
+ l.onload = l.onerror = function () {
4040
+ done();
4041
+ };
4042
+ document.head.appendChild(l);
4043
+ });
4044
+ return ap.css;
4045
+ }
4046
+ function apWhat(a) {
4047
+ var p = a.params || {};
4048
+ var key = "ap.do." + a.action + (p.isNew ? ".new" : "");
4049
+ var s = t(key, p);
4050
+ if (s !== key) return s;
4051
+ s = String(a.summary || a.action);
4052
+ return s.charAt(0).toUpperCase() + s.slice(1);
4053
+ }
4054
+ function apDialog() {
4055
+ if (ap.dlg) return ap.dlg;
4056
+ var d = document.createElement("dialog");
4057
+ d.className = "pe-dlg den-ap-dlg";
4058
+ d.id = "den-ap-dlg";
4059
+ d.setAttribute("aria-labelledby", "den-ap-h");
4060
+ d.innerHTML =
4061
+ '<form method="dialog">' +
4062
+ '<h2 id="den-ap-h"></h2>' +
4063
+ '<p class="help den-ap-who" id="den-ap-who" hidden></p>' +
4064
+ '<p class="den-ap-what" id="den-ap-what"></p>' +
4065
+ '<p class="help" id="den-ap-note" hidden></p>' +
4066
+ '<pre class="den-ap-det" id="den-ap-det" hidden></pre>' +
4067
+ '<p class="help" id="den-ap-waits"></p>' +
4068
+ '<p class="err" id="den-ap-err" role="alert" hidden></p>' +
4069
+ '<div class="btns">' +
4070
+ '<button type="button" class="den-btn" id="den-ap-review" aria-expanded="false" aria-controls="den-ap-det"></button>' +
4071
+ '<span class="sp help" id="den-ap-more"></span>' +
4072
+ '<button type="button" class="den-btn" id="den-ap-later"></button>' +
4073
+ '<button type="button" class="den-btn" id="den-ap-deny"></button>' +
4074
+ '<button type="button" class="den-btn primary" id="den-ap-allow"></button>' +
4075
+ "</div></form>";
4076
+ document.body.appendChild(d);
4077
+ var $ = function (id) {
4078
+ return d.querySelector("#" + id);
4079
+ };
4080
+ $("den-ap-review").addEventListener("click", function () {
4081
+ var det = $("den-ap-det");
4082
+ det.hidden = !det.hidden;
4083
+ $("den-ap-review").textContent = t(det.hidden ? "ap.review" : "ap.hide");
4084
+ $("den-ap-review").setAttribute("aria-expanded", String(!det.hidden));
4085
+ });
4086
+ $("den-ap-later").addEventListener("click", function () {
4087
+ apLater();
4088
+ });
4089
+ // Esc is Later: the request keeps waiting (and the agent with it).
4090
+ d.addEventListener("cancel", function (e) {
4091
+ e.preventDefault();
4092
+ apLater();
4093
+ });
4094
+ $("den-ap-deny").addEventListener("click", function () {
4095
+ apDecide("deny");
4096
+ });
4097
+ $("den-ap-allow").addEventListener("click", function () {
4098
+ apDecide("allow");
4099
+ });
4100
+ ap.dlg = d;
4101
+ return d;
4102
+ }
4103
+ function apLater() {
4104
+ if (ap.shown) ap.later[ap.shown.id] = 1;
4105
+ apClose();
4106
+ }
4107
+ function apClose() {
4108
+ ap.shown = null;
4109
+ if (ap.dlg && ap.dlg.open) ap.dlg.close();
4110
+ }
4111
+ function apShow(a, more) {
4112
+ var d = apDialog();
4113
+ var $ = function (id) {
4114
+ return d.querySelector("#" + id);
4115
+ };
4116
+ var same = ap.shown && ap.shown.id === a.id;
4117
+ ap.shown = a;
4118
+ // No agent token: say so, and what to do if no agent was asked.
4119
+ $("den-ap-h").textContent = t("ap.title", {
4120
+ agent: a.agent ? t("ap.agent", { name: a.agent }) : t("ap.noAgent"),
4121
+ });
4122
+ $("den-ap-who").hidden = !!a.agent;
4123
+ $("den-ap-who").textContent = a.agent ? "" : t("ap.noAgentWarn");
4124
+ $("den-ap-what").textContent = apWhat(a);
4125
+ var note = $("den-ap-note");
4126
+ note.hidden = !a.note;
4127
+ note.textContent = a.note ? t("ap.says") + " “" + a.note + "”" : "";
4128
+ $("den-ap-det").textContent = (a.details || []).join("\n");
4129
+ var time = new Date(a.expiresAt);
4130
+ $("den-ap-waits").textContent = t("ap.waits", {
4131
+ time: isNaN(time)
4132
+ ? ""
4133
+ : time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }),
4134
+ });
4135
+ $("den-ap-more").textContent = more ? t("ap.more", { n: more }) : "";
4136
+ $("den-ap-later").textContent = t("ap.later");
4137
+ $("den-ap-deny").textContent = t("ap.deny");
4138
+ $("den-ap-allow").textContent = t("ap.allow");
4139
+ if (!same) {
4140
+ $("den-ap-det").hidden = true;
4141
+ $("den-ap-review").textContent = t("ap.review");
4142
+ $("den-ap-review").setAttribute("aria-expanded", "false");
4143
+ $("den-ap-err").hidden = true;
4144
+ }
4145
+ if (!d.open)
4146
+ apCss().then(function () {
4147
+ if (ap.shown && ap.shown.id === a.id && !d.open) d.showModal();
4148
+ });
4149
+ }
4150
+ function apError(msg) {
4151
+ var e = apDialog().querySelector("#den-ap-err");
4152
+ e.textContent = msg;
4153
+ e.hidden = false;
4154
+ }
4155
+ function apDecide(decision) {
4156
+ var a = ap.shown;
4157
+ if (!a || ap.busy) return;
4158
+ ap.busy = true;
4159
+ var btns = ap.dlg.querySelectorAll("button");
4160
+ for (var i = 0; i < btns.length; i++) btns[i].disabled = true;
4161
+ fetch("/api/approvals/" + encodeURIComponent(a.id) + "/decide", {
4162
+ method: "POST",
4163
+ headers: { "Content-Type": "application/json" },
4164
+ body: JSON.stringify({ decision: decision }),
4165
+ })
4166
+ .then(function (r) {
4167
+ return r.json().then(
4168
+ function (b) {
4169
+ return { ok: r.ok, b: b || {} };
4170
+ },
4171
+ function () {
4172
+ return { ok: false, b: {} };
4173
+ },
4174
+ );
4175
+ })
4176
+ .then(
4177
+ function (x) {
4178
+ if (!x.ok) {
4179
+ apError(x.b.error || t("ap.failed", { error: x.b.code || "?" }));
4180
+ return;
4181
+ }
4182
+ var res = x.b.result;
4183
+ if (decision === "allow" && res && res.ok === false)
4184
+ toast(t("ap.failed", { error: res.error || "?" }));
4185
+ else if (decision === "allow")
4186
+ toast(t("ap.done", { what: apWhat(a) }));
4187
+ apClose();
4188
+ apPoll();
4189
+ },
4190
+ function (e) {
4191
+ apError(String((e && e.message) || e));
4192
+ },
4193
+ )
4194
+ .then(function () {
4195
+ ap.busy = false;
4196
+ for (var j = 0; j < btns.length; j++) btns[j].disabled = false;
4197
+ });
4198
+ }
4199
+ // Reading what waits needs what approving needs (approvals.ts): the
4200
+ // session's CSRF token and the approval key, on a GET too. A browser
4201
+ // without them is told only that something waits, and to sign in.
4202
+ function apList(fresh) {
4203
+ var ak = approveKey();
4204
+ // No approval key: only the count, to say "sign in to see it".
4205
+ if (!ak)
4206
+ return rawFetch("/api/approvals?count=1", {
4207
+ cache: "no-store",
4208
+ credentials: "same-origin",
4209
+ }).then(function (r) {
4210
+ return r.ok
4211
+ ? r.json().then(function (b) {
4212
+ return { code: "signin_to_approve", waiting: b && b.waiting };
4213
+ })
4214
+ : null;
4215
+ });
4216
+ return csrfToken(fresh).then(function (token) {
4217
+ var h = { "X-Den-Approve-Key": ak };
4218
+ if (token) h["X-Den-CSRF"] = token;
4219
+ return rawFetch("/api/approvals", {
4220
+ cache: "no-store",
4221
+ credentials: "same-origin",
4222
+ headers: h,
4223
+ }).then(function (r) {
4224
+ return r.json().then(
4225
+ function (b) {
4226
+ if (!fresh && r.status === 403 && b && b.code === "csrf")
4227
+ return apList(true);
4228
+ return b;
4229
+ },
4230
+ function () {
4231
+ return null;
4232
+ },
4233
+ );
4234
+ });
4235
+ });
4236
+ }
4237
+ function apSignin(b) {
4238
+ if (b && b.code === "signin_to_approve" && b.waiting > 0)
4239
+ showBanner("approvals", {
4240
+ title: t("ap.signin.title"),
4241
+ text: t("ap.signin", { cmd: "__DEN_CLI__ web login" }),
4242
+ });
4243
+ else hideBanner("approvals");
4244
+ }
4245
+ function apPoll() {
4246
+ if (document.hidden || ap.busy) return;
4247
+ apList(false).then(
4248
+ function (b) {
4249
+ apSignin(b);
4250
+ var list = (b && b.ok && b.pending) || [];
4251
+ var open = list.filter(function (a) {
4252
+ return !ap.later[a.id];
4253
+ });
4254
+ if (!open.length) return apClose();
4255
+ var cur =
4256
+ (ap.shown &&
4257
+ open.filter(function (a) {
4258
+ return a.id === ap.shown.id;
4259
+ })[0]) ||
4260
+ open[0];
4261
+ apShow(cur, open.length - 1);
4262
+ },
4263
+ function () {},
4264
+ );
4265
+ }
4266
+ if (!apiOffPage && !configBad && rawFetch) {
4267
+ setInterval(apPoll, 3000);
4268
+ document.addEventListener("visibilitychange", function () {
4269
+ if (!document.hidden) apPoll();
4270
+ });
4271
+ setTimeout(apPoll, 300);
4272
+ }
4273
+
3961
4274
  // ── the UI language (R19): a user pref ───────────────────────────────
3962
4275
  // /api/settings `language` (user.yml), then a reload: the server serves
3963
4276
  // every page with that language's catalog from its first paint.
@@ -110,6 +110,8 @@ export interface GuardSession {
110
110
  key: string;
111
111
  /** The session's own CSRF token. */
112
112
  csrf: string;
113
+ /** sha256 of its approval key (approvals.ts); unset before 0.4.15. */
114
+ approveKeyHash?: string;
113
115
  }
114
116
 
115
117
  /** How a request reached Den. `edge` and `lan` are beyond loopback. */
@@ -95,7 +95,8 @@ export const ROUTES: readonly RouteEntry[] = [
95
95
  "/theme-favicon",
96
96
  "/theme-favicon/*",
97
97
  "/vendor/*",
98
- "/report*",
98
+ "/report",
99
+ "/report/*",
99
100
  "/reports/security/*",
100
101
  ].map((p) => R(p)),
101
102
  R("/_den/throw", "a test-only route (DEN_DEBUG_ROUTES): it throws"),
@@ -174,6 +175,30 @@ export const ROUTES: readonly RouteEntry[] = [
174
175
  R("/api/health", "runs the manifests' health probes: read-only checks"),
175
176
  R("/api/test-sources", "git rev-parse in each repo: read-only"),
176
177
  R("/api/manifest/validate", "GET validates a file at a path: read-only"),
178
+ // ON-3 (onboard-routes.ts): reads only; nothing written, no repo tool run.
179
+ R("/api/onboard/status", "what's set up and the next step (file reads)"),
180
+ R(
181
+ "/api/onboard/repos",
182
+ "lists the git repos in a folder in home; never follows a symlink",
183
+ ),
184
+ R(
185
+ "/api/onboard/manifest",
186
+ "drafts and validates a side-car manifest; writes nothing",
187
+ ),
188
+ // ON-3's held writes (approval-routes.ts): asking runs nothing; Allow is
189
+ // the person's signed-in browser only (approvals.ts approverAllowed).
190
+ R("/api/approvals", "what waits for the person (never an agent's token)"),
191
+ R("/api/approvals/:id", "one held request: the asker's own, or the person's"),
192
+ M(
193
+ ["POST"],
194
+ "/api/approvals",
195
+ "holds an agent's write for the person; nothing runs until they allow it",
196
+ ),
197
+ M(
198
+ ["POST"],
199
+ "/api/approvals/:id/decide",
200
+ "the person's Allow runs the held write once; Deny runs nothing",
201
+ ),
177
202
  R("/api/service/logs", "reads (and follows) a service's log"),
178
203
  R("/api/service/taillog", "tails a service's log (tail, docker logs)"),
179
204
  R(
@@ -456,6 +481,8 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
456
481
  "github-routes.ts:LINK_PATH": ["/api/web/owner/link/:id"],
457
482
  "pair-routes.ts:POLL_PATH": ["/api/signin/code/:id"],
458
483
  "index.ts:LOGIN_WAIT_PATH": ["/api/web/logins/:id"],
484
+ "approval-routes.ts:ONE_PATH": ["/api/approvals/:id"],
485
+ "approval-routes.ts:DECIDE_PATH": ["/api/approvals/:id/decide"],
459
486
  };
460
487
 
461
488
  /** Whether `pathname` matches a table path. */
@@ -9,8 +9,9 @@
9
9
  * `<cli> agents list` does (agent-ops.ts agentRows), and adds the plain
10
10
  * sessions (`sessions start`), labelled kind "session". An agent whose
11
11
  * actor token `web logout --all` revoked is marked (tokenRevoked). The
12
- * host knows nothing of approvals yet, so `waiting` is never set here
13
- * (AG-6a adds it). Asking never starts a session host.
12
+ * agent with one of Den's held writes waiting for the person (ON-3,
13
+ * approval-routes.ts) is `waiting` (AG-6a); a runtime's own permission
14
+ * prompts are not seen here yet. Asking never starts a session host.
14
15
  *
15
16
  * The io route bridges one WebSocket to one attachment on the session host
16
17
  * (its own connection to the host, so a browser and the CLI, or two
@@ -78,6 +79,8 @@ export interface SessionRouteDeps {
78
79
  /** Whether workspaces.yml names `ws` (active or not). */
79
80
  workspaceExists?: (ws: string) => boolean;
80
81
  pty?: () => PtyLoad;
82
+ /** The agent ids with an approval waiting for the person (AG-6a, ON-3). */
83
+ waitingAgents?: () => ReadonlySet<string>;
81
84
  }
82
85
 
83
86
  interface Resolved {
@@ -87,6 +90,7 @@ interface Resolved {
87
90
  activeWorkspaces: () => string[];
88
91
  workspaceExists: (ws: string) => boolean;
89
92
  pty: () => PtyLoad;
93
+ waitingAgents: () => ReadonlySet<string>;
90
94
  }
91
95
 
92
96
  /** A workspace name as `?ws=` may give it. */
@@ -116,6 +120,7 @@ function resolve(d: SessionRouteDeps = {}): Resolved {
116
120
  ws,
117
121
  )),
118
122
  pty: d.pty ?? (() => loadPty()),
123
+ waitingAgents: d.waitingAgents ?? (() => new Set<string>()),
119
124
  };
120
125
  }
121
126
 
@@ -189,7 +194,8 @@ export interface ListedSession {
189
194
  /** The list, from the host's sessions and the agent records. */
190
195
  export function listedSessions(
191
196
  sessions: SessionInfo[],
192
- d: Pick<Resolved, "stateDir" | "dist" | "activeWorkspaces">,
197
+ d: Pick<Resolved, "stateDir" | "dist" | "activeWorkspaces"> &
198
+ Partial<Pick<Resolved, "waitingAgents">>,
193
199
  /** A workspace asked for by name (`?ws=`), listed even when inactive. */
194
200
  also?: string,
195
201
  ): ListedSession[] {
@@ -204,6 +210,7 @@ export function listedSessions(
204
210
  active.has(r.workspace),
205
211
  );
206
212
  const revoked = revokedAgentIds(d.stateDir());
213
+ const waiting = d.waitingAgents?.() ?? new Set<string>();
207
214
  const out: ListedSession[] = [];
208
215
  const agentSessions = new Set<string>();
209
216
  noteEnded(d.stateDir(), mine, records);
@@ -242,6 +249,7 @@ export function listedSessions(
242
249
  ...(r.interruptedAt ? { interruptedAt: r.interruptedAt } : {}),
243
250
  ...(r.rotation?.due ? { rotation: r.rotation } : {}),
244
251
  ...(revoked.has(rec.id) && s && !s.exited ? { tokenRevoked: true } : {}),
252
+ ...(waiting.has(rec.id) && s && !s.exited ? { waiting: true } : {}),
245
253
  });
246
254
  }
247
255
  for (const s of mine) {
@@ -344,6 +344,25 @@ const availableReposCache = singleFlight<AvailableRepos>(2_000);
344
344
  * Same contract as the original inline handlers: uses return sendJson/send400
345
345
  * to end the response. Caller checks res.writableEnded to know if handled.
346
346
  */
347
+
348
+ /**
349
+ * Why a workspace can't be created with this name, or null: the Workspaces
350
+ * page's create and ON-3's held workspace_create (approval-routes.ts) both
351
+ * ask this.
352
+ */
353
+ export function workspaceCreateRefusal(
354
+ name: string | undefined,
355
+ ): string | null {
356
+ if (!name) return "name required";
357
+ // eslint-disable-next-line no-control-regex
358
+ if (/["'\\/<>\x00-\x1f]/.test(name))
359
+ return "name must not contain quotes, slashes, or control characters";
360
+ if (name.length > 64) return "name must be 64 characters or fewer";
361
+ if (readWorkspacesFile().workspaces[name])
362
+ return `workspace "${name}" already exists`;
363
+ return null;
364
+ }
365
+
347
366
  export async function handleWorkspaceRoutes(
348
367
  req: http.IncomingMessage,
349
368
  res: http.ServerResponse,
@@ -639,16 +658,8 @@ export async function handleWorkspaceRoutes(
639
658
  try {
640
659
  const body = await readBody(req);
641
660
  const { name } = JSON.parse(body) as { name?: string };
642
- if (!name) return send400(res, "name required");
643
- // eslint-disable-next-line no-control-regex
644
- if (/["'\\/<>\x00-\x1f]/.test(name))
645
- return send400(
646
- res,
647
- "name must not contain quotes, slashes, or control characters",
648
- );
649
- const file = readWorkspacesFile();
650
- if (file.workspaces[name])
651
- return send400(res, `workspace "${name}" already exists`);
661
+ const refusal = workspaceCreateRefusal(name);
662
+ if (refusal || !name) return send400(res, refusal ?? "name required");
652
663
  createWorkspace(name, { services: {} });
653
664
  return sendJson(res, { ok: true, name });
654
665
  } catch (e) {
@@ -3,7 +3,7 @@
3
3
  "schema": "den.theme/v1",
4
4
  "id": "den",
5
5
  "name": "Den",
6
- "version": "2.0.0",
6
+ "version": "2.0.1",
7
7
  "description": "Den's default: a sheltered workspace in walnut and umber neutrals with a moss-green accent. System fonts.",
8
8
  "author": "Monster Orange",
9
9
  "license": "MIT",
@@ -37,13 +37,13 @@
37
37
  "border": "#dcd3c7",
38
38
  "border-strong": "#8d7f6d",
39
39
  "accent": "#557a2a",
40
- "accent-text": "#4a6b23",
40
+ "accent-text": "#415e1f",
41
41
  "accent-fill": "#4a6b23",
42
42
  "on-accent": "#ffffff",
43
- "ok": "#2e7246",
44
- "warn": "#8c5a10",
45
- "bad": "#b0392a",
46
- "remote": "#23677a",
43
+ "ok": "#27613b",
44
+ "warn": "#774c0e",
45
+ "bad": "#983124",
46
+ "remote": "#205d6e",
47
47
  "shadow": "0 8px 24px rgb(48 36 24 / 0.14)",
48
48
  "bar": "linear-gradient(90deg, #557a2a 0%, #8a6a48 100%)"
49
49
  },