@monster-orange/den 0.4.0 → 0.4.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 (58) hide show
  1. package/CHANGELOG.md +457 -0
  2. package/README.md +88 -158
  3. package/docs/distributions.md +216 -16
  4. package/docs/reference.md +177 -0
  5. package/package.json +3 -1
  6. package/src/shared/actor-token.ts +275 -0
  7. package/src/shared/agent-actor.ts +12 -2
  8. package/src/shared/agents/den-claude.ts +99 -1
  9. package/src/shared/agents/runtime.ts +7 -0
  10. package/src/shared/agents/runtimes.ts +9 -2
  11. package/src/shared/den-config.ts +52 -3
  12. package/src/shared/environment-manager/scope.ts +22 -0
  13. package/src/shared/environment-manager/service-runner.ts +94 -2
  14. package/src/shared/profile.ts +15 -4
  15. package/src/shared/sessions/client.ts +16 -2
  16. package/src/shared/sessions/den-pty.ts +10 -2
  17. package/src/shared/sessions/env.ts +13 -3
  18. package/src/shared/sessions/host.ts +56 -8
  19. package/src/shared/sessions/types.ts +8 -0
  20. package/src/shared/web-secret.ts +181 -0
  21. package/src/shared/web-session.ts +491 -0
  22. package/src/ui/cli/agent-ops.ts +843 -0
  23. package/src/ui/cli/agents-command.ts +97 -615
  24. package/src/ui/cli/flag-parser.ts +21 -0
  25. package/src/ui/cli/index.ts +127 -11
  26. package/src/ui/cli/sessions-command.ts +49 -5
  27. package/src/ui/cli/target-workspace.ts +105 -0
  28. package/src/ui/cli/web-command.ts +542 -14
  29. package/src/ui/mcp/api.ts +7 -0
  30. package/src/ui/mcp/server.ts +74 -6
  31. package/src/ui/server/agent-routes.ts +324 -0
  32. package/src/ui/server/audit.ts +81 -27
  33. package/src/ui/server/http-errors.ts +21 -3
  34. package/src/ui/server/index.ts +821 -66
  35. package/src/ui/server/public/agents-page.js +654 -0
  36. package/src/ui/server/public/agents-section.js +299 -0
  37. package/src/ui/server/public/agents.html +439 -0
  38. package/src/ui/server/public/appearance.js +3 -0
  39. package/src/ui/server/public/error-page.js +103 -0
  40. package/src/ui/server/public/header.js +45 -11
  41. package/src/ui/server/public/i18n/es.js +90 -1
  42. package/src/ui/server/public/i18n/ja.js +86 -1
  43. package/src/ui/server/public/local-dev.html +53 -0
  44. package/src/ui/server/public/logs.html +3 -0
  45. package/src/ui/server/public/panel.js +1 -1
  46. package/src/ui/server/public/release.html +8 -0
  47. package/src/ui/server/public/settings-page.js +5 -0
  48. package/src/ui/server/public/settings-registry.js +33 -0
  49. package/src/ui/server/public/shell.js +248 -5
  50. package/src/ui/server/public/topbar.js +51 -2
  51. package/src/ui/server/request-guard.ts +670 -0
  52. package/src/ui/server/route-classes.ts +317 -0
  53. package/src/ui/server/session-routes.ts +445 -0
  54. package/src/ui/server/shutdown-guard.ts +91 -0
  55. package/src/ui/server/sudo-store.ts +29 -3
  56. package/src/ui/server/ui-assets.ts +10 -1
  57. package/src/ui/server/websocket.ts +326 -0
  58. package/src/ui/server/workspace-routes.ts +13 -7
@@ -90,10 +90,13 @@ export function envReady() {
90
90
  if (_envReady) return _envReady;
91
91
  _envReady = (async () => {
92
92
  const sel = document.getElementById("env-sel");
93
- const envs = await fetch("/api/environments")
94
- .then((r) => r.json())
95
- .then((v) => (Array.isArray(v) ? v : ["local"]))
96
- .catch(() => ["local"]);
93
+ // A refusal page (SEC-1b, shell.js apiOff): /api answers the refusal.
94
+ const envs = apiOff()
95
+ ? ["local"]
96
+ : await fetch("/api/environments")
97
+ .then((r) => r.json())
98
+ .then((v) => (Array.isArray(v) ? v : ["local"]))
99
+ .catch(() => ["local"]);
97
100
  if (!sel) return envs;
98
101
  const esc = (s) =>
99
102
  String(s).replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`);
@@ -632,6 +635,7 @@ window.closeChangelog = function () {
632
635
  * Call once per page from each page's init function.
633
636
  */
634
637
  export async function initChangelog() {
638
+ if (apiOff()) return;
635
639
  _injectChangelogStyles();
636
640
  try {
637
641
  _changelogEntries = await fetch("/api/changelog").then((r) => r.json());
@@ -668,8 +672,25 @@ export async function initChangelog() {
668
672
 
669
673
  let ___DEN_CLI__Config = null;
670
674
 
675
+ /**
676
+ * 0.4.1: true once the shell knows the config fails validation (shell.js
677
+ * configInvalid): the polls and streams below that read it don't start.
678
+ */
679
+ function configInvalid() {
680
+ return window.denShell?.configInvalid?.() === true;
681
+ }
682
+
683
+ /**
684
+ * SEC-1b: a refusal page (421, signed out; shell.js apiOff): every /api
685
+ * call would answer the same refusal, so none is made.
686
+ */
687
+ function apiOff() {
688
+ return window.denShell?.apiOff?.() === true;
689
+ }
690
+
671
691
  async function getDenConfig() {
672
692
  if (___DEN_CLI__Config) return ___DEN_CLI__Config;
693
+ if (configInvalid()) return { environments: ["local"] };
673
694
  try {
674
695
  ___DEN_CLI__Config = await fetch("/api/__DEN_CLI__/config").then((r) =>
675
696
  r.json(),
@@ -731,6 +752,7 @@ let _apps = null;
731
752
  let _appsAt = 0;
732
753
  async function _loadApps() {
733
754
  if (_apps && Date.now() - _appsAt < 30_000) return _apps;
755
+ if (configInvalid()) return _apps ?? [];
734
756
  try {
735
757
  const r = await fetch("/api/applications", {
736
758
  signal: AbortSignal.timeout(5_000),
@@ -790,7 +812,7 @@ export async function refreshWorkspacePill() {
790
812
 
791
813
  async function _renderScopeMenu() {
792
814
  const sh = window.denShell;
793
- if (!sh?.setWorkspaces) return;
815
+ if (!sh?.setWorkspaces || configInvalid()) return;
794
816
  try {
795
817
  const [data, scope] = await Promise.all([
796
818
  fetch("/api/workspaces").then((r) => r.json()),
@@ -808,15 +830,19 @@ async function _renderScopeMenu() {
808
830
  }
809
831
  }
810
832
 
833
+ /** The pages whose ?ws= wins over the stored view (Graph, Agents). */
834
+ const WS_URL_PAGES = new Set(["/graph", "/agents"]);
835
+
811
836
  /**
812
837
  * The Graph page draws what its ?ws= names (it wins over the stored view,
813
838
  * 0.3.0-dev.33), so its pill shows that workspace too: the pill, the URL,
814
839
  * the header's line and the graph agree (0.3.0-dev.34, a consumer's
815
- * finding). Elsewhere, and for a ?ws= that names no active workspace,
816
- * the stored view.
840
+ * finding). The Agents page's ?ws= (the Workspaces page's "Open in
841
+ * Agents") is the same. Elsewhere, and for a ?ws= that names no active
842
+ * workspace, the stored view.
817
843
  */
818
844
  function pageScope(scope, data) {
819
- if (location.pathname !== "/graph") return scope;
845
+ if (!WS_URL_PAGES.has(location.pathname)) return scope;
820
846
  const ws = new URLSearchParams(location.search).get("ws");
821
847
  const active = Array.isArray(data?.active)
822
848
  ? data.active
@@ -833,7 +859,7 @@ function pageScope(scope, data) {
833
859
  * pushed: Back does not return to a view the picker has left.
834
860
  */
835
861
  function followScopeInUrl(value) {
836
- if (location.pathname !== "/graph") return;
862
+ if (!WS_URL_PAGES.has(location.pathname)) return;
837
863
  const u = new URL(location.href);
838
864
  if (value === "all") u.searchParams.delete("ws");
839
865
  else u.searchParams.set("ws", value);
@@ -885,6 +911,7 @@ async function _setFocus(name) {
885
911
  /** Call once on page load. Does NOT manage env changes — caller handles that. */
886
912
  export async function initHealth() {
887
913
  await envReady();
914
+ if (configInvalid()) return;
888
915
  const env = healthEnv();
889
916
  const gen = ++pollGen;
890
917
  await renderHealth(env, gen);
@@ -895,6 +922,7 @@ export async function initHealth() {
895
922
  export async function refreshHealth(env, { force = false } = {}) {
896
923
  const gen = ++pollGen;
897
924
  clearTimeout(pollTimer);
925
+ if (configInvalid()) return;
898
926
  // The chip goes neutral while the new env is checked: never red from
899
927
  // the old env's answers, never a partial count.
900
928
  if (env !== _healthEnvPainted) paintChecking();
@@ -913,6 +941,7 @@ document.addEventListener("visibilitychange", () => {
913
941
 
914
942
  function schedulePoll(env, gen) {
915
943
  if (gen !== pollGen) return; // superseded by an env switch/re-init
944
+ if (configInvalid()) return; // a reload once fixed starts it again
916
945
  clearTimeout(pollTimer);
917
946
  pollTimer = setTimeout(async () => {
918
947
  try {
@@ -1040,9 +1069,13 @@ function _streamHealth(env, force, onResult) {
1040
1069
  * ones never do.
1041
1070
  */
1042
1071
  function _paintHealth(env, results) {
1043
- const repos = [...new Set(results.map((r) => r.repo))];
1072
+ // WS-6.4: the server checks every active workspace; the chip and the
1073
+ // drawer count only what the view scope shows (`inView`). The pages get
1074
+ // every row, so the Workspaces page knows any workspace's health.
1075
+ const shown = results.filter((r) => r.inView !== false);
1076
+ const repos = [...new Set(shown.map((r) => r.repo))];
1044
1077
  const groups = groupReposByApplication(repos, _apps);
1045
- const row = buildRow(results, groups);
1078
+ const row = buildRow(shown, groups);
1046
1079
  window.denShell?.setState(shellHealth(row));
1047
1080
  _healthEnvPainted = env;
1048
1081
  paintStatus({ row, groups, env });
@@ -1203,6 +1236,7 @@ async function _fetchServerInfo() {
1203
1236
  }
1204
1237
 
1205
1238
  async function _staleTick() {
1239
+ if (apiOff()) return;
1206
1240
  const info = await _fetchServerInfo();
1207
1241
  if (info && info.pid != null)
1208
1242
  _lastServerInfo = { pid: info.pid, startedAt: info.startedAt };
@@ -144,7 +144,8 @@
144
144
  "dock.resize": "Arrastra para cambiar el tamaño",
145
145
  "lock.locked": "Bloqueado",
146
146
  "lock.unlocked": "Desbloqueado",
147
- "lock.title": "Desbloquea para iniciar, detener y reiniciar servicios",
147
+ "lock.title":
148
+ "Desbloquea para iniciar, detener y reiniciar servicios y agentes",
148
149
  "lock.nopw":
149
150
  "sudo sin contraseña (NOPASSWD): no hace falta la contraseña de administrador",
150
151
  "lock.session": "Desbloqueado durante esta sesión",
@@ -241,6 +242,24 @@
241
242
  "err.500.time": "Hora",
242
243
  "err.500.id": "Id de solicitud",
243
244
  "err.500.version": "Versión",
245
+ "err.421.title": "Dirección incorrecta",
246
+ "err.421.lead":
247
+ "Este servidor solo responde en sus propias direcciones, así que una página con otro nombre no puede alcanzarlo.",
248
+ "err.421.open": "Abrir en localhost",
249
+ "err.refused.title": "Solicitud rechazada",
250
+ "err.signin.title": "Inicia sesión para usar {name}",
251
+ "err.signin.terminal": "Inicia sesión para usar la terminal",
252
+ "err.signin.agents": "Inicia sesión para iniciar agentes",
253
+ "err.signin.sub": "Este navegador no ha iniciado sesión",
254
+ "err.signin.lead": "Ejecuta {cmd} y abre el enlace.",
255
+ "err.signin.expiredTitle": "El enlace de inicio de sesión caducó",
256
+ "err.signin.expired":
257
+ "Ese enlace de inicio de sesión caducó o ya se usó. Ejecuta {cmd} para obtener uno nuevo.",
258
+ "err.signin.local":
259
+ "Un inicio de sesión pertenece a una sola dirección. Esta página está en {host}: abre la misma página en localhost, donde este navegador tiene la sesión iniciada.",
260
+ "err.signin.why":
261
+ "No hay contraseña: el enlace sale de un archivo que solo tu cuenta de usuario puede leer.",
262
+ "err.signin.copy": "Copiar comando",
244
263
  "err.422.title": "La configuración no es válida",
245
264
  "err.422.sub": "La configuración de la distribución no pasa la validación",
246
265
  "err.422.lead":
@@ -274,6 +293,7 @@
274
293
  "set.cli.cli_tabs": "Pestañas de terminal",
275
294
  "set.cli.log_window_lines": "Líneas del panel de registros",
276
295
  "set.cli.web_port": "Tu puerto web",
296
+ "set.cli.signin": "Inicio de sesión del navegador",
277
297
  "set.distribution.brand": "Nombre del producto",
278
298
  "set.distribution.github_org": "Org de GitHub",
279
299
  "set.distribution.orgs": "Etiquetas de org",
@@ -283,5 +303,74 @@
283
303
  "err.retry": "Reintentar",
284
304
  "err.copy": "Copiar detalles",
285
305
  "err.copied": "Copiado",
306
+ "ag.title": "Agentes",
307
+ "ag.caption": "Agentes en {ws}",
308
+ "ag.count": "{n} agentes",
309
+ "ag.count.one": "{n} agente",
310
+ "ag.col.agent": "Agente",
311
+ "ag.col.runtime": "Runtime",
312
+ "ag.col.state": "Estado",
313
+ "ag.col.cwd": "Directorio",
314
+ "ag.col.started": "Inicio",
315
+ "ag.col.actions": "Acciones",
316
+ "ag.loading": "Cargando agentes…",
317
+ "ag.none": "Aún no hay agentes en este workspace",
318
+ "ag.personaOf": "persona {persona}",
319
+ "ag.state.running": "en marcha",
320
+ "ag.state.stopping": "deteniéndose…",
321
+ "ag.state.stopped": "detenido",
322
+ "ag.state.ended": "terminado",
323
+ "ag.state.exited": "salió",
324
+ "ag.state.exitedCode": "salió ({code})",
325
+ "ag.stop": "Detener",
326
+ "ag.resume": "Reanudar",
327
+ "ag.stopName": "Detener {id}",
328
+ "ag.resumeName": "Reanudar {id}",
329
+ "ag.start": "+ Iniciar agente",
330
+ "ag.persona": "Persona",
331
+ "ag.runtime": "Runtime",
332
+ "ag.runtime.auto": "La de la persona",
333
+ "ag.unavailable": "no disponible",
334
+ "ag.go": "Iniciar",
335
+ "ag.starting": "Iniciando…",
336
+ "ag.cancel": "Cancelar",
337
+ "ag.started": "Agente {id} iniciado",
338
+ "ag.stopped": "Agente {id} detenido",
339
+ "ag.resumed": "Agente {id} reanudado",
340
+ // The Agents tab (AG-4a-term, agents-page.js).
341
+ "n.agents": "Agentes",
342
+ "kind.agent": "Agente",
343
+ "ag.sub": "{n} sesiones · {w} en espera · {scope}",
344
+ "ag.sub.one": "{n} sesión · {w} en espera · {scope}",
345
+ "ag.all": "todos los espacios",
346
+ "ag.waiting": "en espera",
347
+ "ag.session": "sesión",
348
+ "ag.empty":
349
+ "Elige una sesión a la izquierda para abrir su terminal. Abre más para verlas en mosaico (hasta 4).",
350
+ "ag.none.scope": "Aún no hay sesiones ni agentes en {scope}",
351
+ "ag.detach": "Cerrar panel",
352
+ "ag.detachName": "Cerrar el panel de {id} (sigue en marcha)",
353
+ "ag.detached": "Panel de {id} cerrado: sigue en marcha",
354
+ "ag.open": "Abrir",
355
+ "ag.openName": "Abrir {id} en Agentes",
356
+ "ws.openAgents": "Abrir en Agentes →",
357
+ "ag.signin": "Inicia sesión para abrir terminales: ejecuta {cli} web login",
358
+ "ag.revoked": "token revocado",
359
+ "ag.revokedTip":
360
+ "Su token fue revocado (web logout --all): detenlo y reanúdalo, o inícialo de nuevo, para darle uno nuevo",
361
+ "ag.ended":
362
+ "Esta sesión ha terminado. Su registro se queda hasta que se reanude o se elimine.",
363
+ "ag.exitedLine": "[la sesión terminó{code}]",
364
+ "ag.lost": "[desconectado: {why}]",
365
+ "ag.reconnect": "Reconectar",
366
+ "ag.noBackend":
367
+ "Los terminales necesitan el módulo PTY opcional (den-pty no está disponible): {cli} doctor explica por qué",
368
+ "ag.noHost":
369
+ "No hay ningún host de sesiones en marcha: aún no se ha iniciado nada",
370
+ "ag.ws": "Espacio de trabajo",
371
+ "ag.pal": "Agentes › {ws} / {name}",
372
+ "ag.palStart": "Iniciar agente…",
373
+ "ag.cap":
374
+ "Hasta 4 paneles: se cerró el de {id} para hacer sitio (sigue en marcha)",
286
375
  };
287
376
  })((window.denMessages = window.denMessages || {}));
@@ -133,7 +133,8 @@
133
133
  "dock.resize": "ドラッグしてサイズを変更",
134
134
  "lock.locked": "ロック中",
135
135
  "lock.unlocked": "ロック解除",
136
- "lock.title": "ロックを解除するとサービスの起動・停止・再起動ができます",
136
+ "lock.title":
137
+ "ロックを解除するとサービスとエージェントの起動・停止・再起動ができます",
137
138
  "lock.nopw":
138
139
  "パスワード不要の sudo (NOPASSWD): 管理者パスワードは必要ありません",
139
140
  "lock.session": "このセッションの間ロックを解除しました",
@@ -230,6 +231,24 @@
230
231
  "err.500.time": "時刻",
231
232
  "err.500.id": "リクエスト ID",
232
233
  "err.500.version": "バージョン",
234
+ "err.421.title": "アドレスが違います",
235
+ "err.421.lead":
236
+ "このサーバーは自身のアドレスでのみ応答するため、別の名前のページからは届きません。",
237
+ "err.421.open": "localhost で開く",
238
+ "err.refused.title": "リクエストが拒否されました",
239
+ "err.signin.title": "{name} を使うにはサインインしてください",
240
+ "err.signin.terminal": "ターミナルを使うにはサインインしてください",
241
+ "err.signin.agents": "エージェントを起動するにはサインインしてください",
242
+ "err.signin.sub": "このブラウザはサインインしていません",
243
+ "err.signin.lead": "{cmd} を実行し、表示されたリンクを開いてください。",
244
+ "err.signin.expiredTitle": "サインインリンクの期限切れ",
245
+ "err.signin.expired":
246
+ "このサインインリンクは期限切れか、使用済みです。{cmd} を実行して新しいリンクを取得してください。",
247
+ "err.signin.local":
248
+ "サインインはアドレスごとです。このページは {host} にあります。サインイン済みの localhost で同じページを開いてください。",
249
+ "err.signin.why":
250
+ "パスワードはありません。リンクは、あなたのユーザーアカウントだけが読めるファイルから作られます。",
251
+ "err.signin.copy": "コマンドをコピー",
233
252
  "err.422.title": "設定が無効です",
234
253
  "err.422.sub": "ディストリビューションの設定が検証に失敗しました",
235
254
  "err.422.lead":
@@ -263,6 +282,7 @@
263
282
  "set.cli.cli_tabs": "ターミナルのタブ",
264
283
  "set.cli.log_window_lines": "ログ欄の行数",
265
284
  "set.cli.web_port": "あなたの web ポート",
285
+ "set.cli.signin": "ブラウザのサインイン",
266
286
  "set.distribution.brand": "製品名",
267
287
  "set.distribution.github_org": "GitHub の組織",
268
288
  "set.distribution.orgs": "組織のラベル",
@@ -272,5 +292,70 @@
272
292
  "err.retry": "再試行",
273
293
  "err.copy": "詳細をコピー",
274
294
  "err.copied": "コピーしました",
295
+ "ag.title": "エージェント",
296
+ "ag.caption": "{ws} のエージェント",
297
+ "ag.count": "エージェント {n} 件",
298
+ "ag.col.agent": "エージェント",
299
+ "ag.col.runtime": "ランタイム",
300
+ "ag.col.state": "状態",
301
+ "ag.col.cwd": "ディレクトリ",
302
+ "ag.col.started": "開始",
303
+ "ag.col.actions": "操作",
304
+ "ag.loading": "エージェントを読み込み中…",
305
+ "ag.none": "このワークスペースにはまだエージェントがありません",
306
+ "ag.personaOf": "ペルソナ {persona}",
307
+ "ag.state.running": "実行中",
308
+ "ag.state.stopping": "停止中…",
309
+ "ag.state.stopped": "停止済み",
310
+ "ag.state.ended": "終了",
311
+ "ag.state.exited": "終了済み",
312
+ "ag.state.exitedCode": "終了済み ({code})",
313
+ "ag.stop": "停止",
314
+ "ag.resume": "再開",
315
+ "ag.stopName": "{id} を停止",
316
+ "ag.resumeName": "{id} を再開",
317
+ "ag.start": "+ エージェントを開始",
318
+ "ag.persona": "ペルソナ",
319
+ "ag.runtime": "ランタイム",
320
+ "ag.runtime.auto": "ペルソナの既定",
321
+ "ag.unavailable": "利用不可",
322
+ "ag.go": "開始",
323
+ "ag.starting": "開始中…",
324
+ "ag.cancel": "キャンセル",
325
+ "ag.started": "エージェント {id} を開始しました",
326
+ "ag.stopped": "エージェント {id} を停止しました",
327
+ "ag.resumed": "エージェント {id} を再開しました",
328
+ // The Agents tab (AG-4a-term, agents-page.js).
329
+ "n.agents": "エージェント",
330
+ "kind.agent": "エージェント",
331
+ "ag.sub": "{n} セッション · 承認待ち {w} · {scope}",
332
+ "ag.all": "すべてのワークスペース",
333
+ "ag.waiting": "承認待ち",
334
+ "ag.session": "セッション",
335
+ "ag.empty":
336
+ "左のセッションを選ぶとターミナルが開きます。複数開くと並べて表示します(最大 4)。",
337
+ "ag.none.scope": "{scope} にはまだセッションもエージェントもありません",
338
+ "ag.detach": "ペインを閉じる",
339
+ "ag.detachName": "{id} のペインを閉じる(実行は続きます)",
340
+ "ag.detached": "{id} のペインを閉じました。実行は続いています",
341
+ "ag.open": "開く",
342
+ "ag.openName": "{id} をエージェントで開く",
343
+ "ws.openAgents": "エージェントで開く →",
344
+ "ag.signin": "ターミナルを開くにはサインイン: {cli} web login を実行",
345
+ "ag.revoked": "トークン失効",
346
+ "ag.revokedTip":
347
+ "トークンが失効しました(web logout --all)。新しいトークンを渡すには停止して再開するか、もう一度開始してください",
348
+ "ag.ended": "このセッションは終了しました。記録は再開か削除まで残ります。",
349
+ "ag.exitedLine": "[セッションが終了しました{code}]",
350
+ "ag.lost": "[切断: {why}]",
351
+ "ag.reconnect": "再接続",
352
+ "ag.noBackend":
353
+ "ターミナルにはオプションの PTY モジュールが必要です(den-pty は使えません)。理由は {cli} doctor で確認できます",
354
+ "ag.noHost": "セッションホストは動いていません。まだ何も開始されていません",
355
+ "ag.ws": "ワークスペース",
356
+ "ag.pal": "エージェント › {ws} / {name}",
357
+ "ag.palStart": "エージェントを開始…",
358
+ "ag.cap":
359
+ "ペインは最大 4 つです。{id} のペインを閉じました(実行は続きます)",
275
360
  };
276
361
  })((window.denMessages = window.denMessages || {}));
@@ -1336,6 +1336,50 @@
1336
1336
  align-items: center;
1337
1337
  gap: 10px;
1338
1338
  }
1339
+ /* ── Workspaces: the Agents section (AG-4a, §5c) ─────────────────────
1340
+ The repo table's look (table.svc, .tbl-*) under the shell's section
1341
+ heading (.den-sec); the start form sits in the table's foot. */
1342
+ .agents .tbl-foot {
1343
+ margin-top: 10px;
1344
+ flex-wrap: wrap;
1345
+ }
1346
+ .agents .ag-head {
1347
+ display: flex;
1348
+ align-items: baseline;
1349
+ justify-content: space-between;
1350
+ gap: 12px;
1351
+ flex-wrap: wrap;
1352
+ }
1353
+ .agents .ag-head a {
1354
+ font-size: 12.5px;
1355
+ }
1356
+ .agent-form {
1357
+ display: flex;
1358
+ align-items: center;
1359
+ gap: 10px;
1360
+ flex-wrap: wrap;
1361
+ }
1362
+ .agent-form[hidden] {
1363
+ display: none;
1364
+ }
1365
+ .agent-form label {
1366
+ display: inline-flex;
1367
+ align-items: center;
1368
+ gap: 6px;
1369
+ color: var(--muted);
1370
+ font-size: 12px;
1371
+ }
1372
+ .agent-form select {
1373
+ max-width: 320px;
1374
+ }
1375
+ .agent-cwd {
1376
+ max-width: 280px;
1377
+ overflow: hidden;
1378
+ text-overflow: ellipsis;
1379
+ }
1380
+ .ws-empty-state + .ws-agents-only {
1381
+ flex: none;
1382
+ }
1339
1383
  .validate-details > summary {
1340
1384
  cursor: pointer;
1341
1385
  color: var(--muted);
@@ -1423,6 +1467,7 @@
1423
1467
  buildRow,
1424
1468
  } from "/header.js";
1425
1469
  import { checkLine } from "/topbar.js";
1470
+ import { agentsSectionHtml, mountAgents } from "/agents-section.js";
1426
1471
  import { mountGraph, graphHealth, graphMaximized } from "/graph.js";
1427
1472
  import {
1428
1473
  mergeWorkspaceViews,
@@ -1956,6 +2001,7 @@
1956
2001
  <button class="switch-btn" data-action="switch-ws">activate + focus</button>`
1957
2002
  }
1958
2003
  ${stackNsChip}
2004
+ <span class="badge ws-agents" id="ws-agents-count" hidden></span>
1959
2005
  <a class="ws-head-link" id="ws-open-graph" href="/graph?ws=${enc}">Open in Graph →</a>
1960
2006
  <div class="action-menu-wrap ws-more">
1961
2007
  <button type="button" class="switch-btn" data-action="toggle-svc-menu" data-menu-id="ws-more-menu" aria-haspopup="menu" aria-expanded="false" aria-label="More for ${escHtml(wsName)}" title="More for this workspace">⋯</button>
@@ -1986,6 +2032,9 @@
1986
2032
  <button class="add-svc-open-btn ws-empty-add-btn" onclick="openAddSvcModal('${escHtml(wsName)}')">+ Add Service</button>
1987
2033
  <span class="form-msg" id="add-msg"></span>
1988
2034
  </div>
2035
+ <div class="content-body ws-agents-only">
2036
+ ${agentsSectionHtml(wsName)}
2037
+ </div>
1989
2038
  `
1990
2039
  : `
1991
2040
  <div class="content-body">
@@ -2023,6 +2072,9 @@
2023
2072
  <span class="form-msg" id="add-msg"></span>
2024
2073
  </div>
2025
2074
 
2075
+ <!-- Agents (AG-4a, §5c): the workspace's agents, under its repos. -->
2076
+ ${agentsSectionHtml(wsName)}
2077
+
2026
2078
  <!-- Validate a manifest (by path, or pasted) -->
2027
2079
  <details class="validate-details">
2028
2080
  <summary>Validate a manifest</summary>
@@ -2041,6 +2093,7 @@
2041
2093
  `;
2042
2094
  _tbl = { wsName, ws, svcList, depsMap, repoRank, appTree };
2043
2095
  renderTable();
2096
+ mountAgents(wsName);
2044
2097
 
2045
2098
  // Pre-fetch GitHub org repos so the Add Service modal loads instantly
2046
2099
  fetch("/api/github/repos")
@@ -681,6 +681,9 @@
681
681
  const toolbar = document.getElementById("logs-toolbar");
682
682
  let services;
683
683
  try {
684
+ // An invalid config (the shell knows): not asked, it lists nothing.
685
+ if (window.denShell?.configInvalid?.() === true)
686
+ throw new Error("config invalid");
684
687
  services = await fetch(`/api/services?env=${env}`).then((r) =>
685
688
  r.json(),
686
689
  );
@@ -793,7 +793,7 @@ function activityList(x) {
793
793
  .then(({ entries = [] }) => {
794
794
  if (!live) return;
795
795
  el.innerHTML = entries.length
796
- ? `<ol class="act">${entries.map((a) => `<li><time datetime="${esc(a.ts)}">${esc(hhmm(a.ts))}</time><span class="dg-who ${a.actor === "agent" ? "agent" : ""}">${esc(a.actor === "agent" ? (a.agent ? "[" + a.agent + "]" : "[agent]") + (a.client ? " " + a.client : "") : "you")}</span><span>${esc(a.verb)} ${esc(a.detail)}${a.outcome === "refused" ? ` <em class="refused">refused: ${esc(a.reason || "")}</em>` : ""}</span></li>`).join("")}</ol>`
796
+ ? `<ol class="act">${entries.map((a) => `<li><time datetime="${esc(a.ts)}">${esc(hhmm(a.ts))}</time><span class="dg-who ${a.actor === "agent" ? "agent" : ""}">${esc(a.actor === "agent" ? (a.agent ? "[" + a.agent + "]" : "[agent]") + (a.client ? " " + a.client : "") : "you" + (a.claimed && a.claimed.client ? " · " + a.claimed.client + " (unverified)" : ""))}</span><span>${esc(a.verb)} ${esc(a.detail)}${a.outcome === "refused" ? ` <em class="refused">refused: ${esc(a.reason || "")}</em>` : ""}</span></li>`).join("")}</ol>`
797
797
  : none(
798
798
  "No actions yet. Starts, restarts and syncs, yours and agents', show here.",
799
799
  );
@@ -895,6 +895,14 @@
895
895
 
896
896
  // ── Load status ─────────────────────────────────────────────────────────────
897
897
  async function loadStatus(refresh = false) {
898
+ // No deploy environment (environments: [local] only): nothing to ask.
899
+ if (!state.env) {
900
+ state.rows = [];
901
+ state.loading = null;
902
+ setStatus("no deploy environment configured");
903
+ renderTable();
904
+ return;
905
+ }
898
906
  state.loading = refresh ? "refresh" : "initial";
899
907
  setStatus(refresh ? "refreshing…" : "checking deployed state…");
900
908
  renderTable(); // shows the loading row; see renderTable
@@ -172,6 +172,11 @@ export async function mountSettings(deps) {
172
172
  return true;
173
173
  }
174
174
  async function loadWorkspaceSettings() {
175
+ // 0.4.1: under an invalid config it answers 422; the rows show none.
176
+ if (window.denShell?.configInvalid?.() === true) {
177
+ ctx.wsSettings = null;
178
+ return;
179
+ }
175
180
  ctx.wsSettings = ctx.workspace
176
181
  ? await getJson(
177
182
  `/api/workspaces/settings?workspace=${encodeURIComponent(ctx.workspace)}`,
@@ -465,6 +465,39 @@ export const DEN_SETTINGS = [
465
465
  ? String(ctx.data.user_web_port)
466
466
  : "Not set: the distribution's applies",
467
467
  },
468
+ {
469
+ // SEC-1b: whether this browser is signed in, and Sign out. Signing in
470
+ // is the CLI's (`web login` prints a one-time link): no form here.
471
+ id: "cli.signin",
472
+ category: "cli",
473
+ label: "Browser sign-in",
474
+ description:
475
+ "Signing in is needed only when this server is reached beyond this machine (the edge, or a LAN address); on localhost nothing asks for it. __DEN_CLI__ web login prints a sign-in link; __DEN_CLI__ web sessions lists signed-in browsers.",
476
+ keywords: ["sign in", "sign out", "login", "logout", "session", "browser"],
477
+ scope: "user",
478
+ control: {
479
+ type: "custom",
480
+ render(host, ctx) {
481
+ const draw = (signedIn) => {
482
+ host.innerHTML = signedIn
483
+ ? '<output class="set-ro">Signed in</output> ' +
484
+ '<button type="button" class="btn" id="set-signout">Sign out</button>'
485
+ : '<output class="set-ro">Not signed in: run <code>__DEN_CLI__ web login</code></output>';
486
+ host.querySelector("#set-signout")?.addEventListener("click", () =>
487
+ ctx.shell
488
+ .api("POST", "/api/session/logout", {})
489
+ .then(() => draw(false))
490
+ .catch(() => {}),
491
+ );
492
+ };
493
+ fetch("/api/session", { cache: "no-store" })
494
+ .then((r) => (r.ok ? r.json() : null))
495
+ .then((b) => draw(b?.signedIn === true))
496
+ .catch(() => draw(false));
497
+ },
498
+ },
499
+ read: () => null,
500
+ },
468
501
 
469
502
  // ── Distribution (read-only) ───────────────────────────────────────────
470
503
  distRow(