@monster-orange/den 0.4.3 → 0.4.4

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 +179 -0
  2. package/README.md +9 -9
  3. package/docs/distributions.md +215 -65
  4. package/package.json +1 -1
  5. package/src/shared/agents/catalog-runtime.ts +178 -0
  6. package/src/shared/agents/catalog.ts +343 -0
  7. package/src/shared/agents/den-claude.ts +14 -18
  8. package/src/shared/agents/runtime-search.ts +142 -15
  9. package/src/shared/agents/runtime.ts +18 -1
  10. package/src/shared/agents/runtimes.ts +23 -1
  11. package/src/shared/auth/sudo-vault.ts +16 -0
  12. package/src/shared/den-config.ts +57 -3
  13. package/src/shared/edge/status.ts +134 -0
  14. package/src/shared/environment-manager/health.ts +82 -8
  15. package/src/shared/environment-manager/workspaces.ts +54 -0
  16. package/src/shared/health/not-checked.ts +42 -0
  17. package/src/shared/health/rollup.ts +4 -0
  18. package/src/shared/process/spawn-streaming.ts +8 -3
  19. package/src/shared/sessions/client.ts +5 -1
  20. package/src/shared/sessions/host.ts +29 -1
  21. package/src/shared/sessions/protocol.ts +6 -1
  22. package/src/shared/sessions/types.ts +6 -0
  23. package/src/shared/stacks/status.ts +24 -0
  24. package/src/shared/strategies/registry.ts +5 -0
  25. package/src/shared/types/index.ts +6 -0
  26. package/src/ui/cli/agent-ops.ts +210 -19
  27. package/src/ui/cli/agents-command.ts +50 -21
  28. package/src/ui/cli/edge-command.ts +16 -3
  29. package/src/ui/cli/health-display.ts +4 -1
  30. package/src/ui/mcp/scope.ts +23 -3
  31. package/src/ui/mcp/server.ts +31 -7
  32. package/src/ui/mcp/tools.ts +327 -20
  33. package/src/ui/mcp/views.ts +450 -0
  34. package/src/ui/server/agent-routes.ts +118 -28
  35. package/src/ui/server/index.ts +77 -6
  36. package/src/ui/server/public/agent-start.js +46 -7
  37. package/src/ui/server/public/agents-page.js +232 -97
  38. package/src/ui/server/public/agents-section.js +3 -5
  39. package/src/ui/server/public/agents.html +12 -28
  40. package/src/ui/server/public/graph-model.js +2 -0
  41. package/src/ui/server/public/header.js +2 -3
  42. package/src/ui/server/public/health-rollup.js +20 -8
  43. package/src/ui/server/public/i18n/es.js +15 -13
  44. package/src/ui/server/public/i18n/ja.js +14 -13
  45. package/src/ui/server/public/index.html +17 -4
  46. package/src/ui/server/public/local-dev.html +28 -17
  47. package/src/ui/server/public/services.html +21 -5
  48. package/src/ui/server/public/settings-page.js +3 -2
  49. package/src/ui/server/public/settings-registry.js +225 -0
  50. package/src/ui/server/public/settings.html +90 -1
  51. package/src/ui/server/public/shell.js +28 -23
  52. package/src/ui/server/public/topbar.js +166 -206
  53. package/src/ui/server/readonly-routes.ts +29 -5
  54. package/src/ui/server/request-guard.ts +1 -1
  55. package/src/ui/server/route-classes.ts +14 -2
  56. package/src/ui/server/session-routes.ts +9 -1
  57. package/src/ui/server/sudo-store.ts +170 -6
  58. package/src/ui/server/workspace-routes.ts +10 -3
@@ -142,26 +142,21 @@
142
142
  "dock.err": "falló",
143
143
  "dock.last": "Última ejecución: {s} ↗",
144
144
  "dock.resize": "Arrastra para cambiar el tamaño",
145
- "lock.locked": "Bloqueado",
146
- "lock.unlocked": "Desbloqueado",
147
- "lock.title":
148
- "Desbloquea para iniciar, detener y reiniciar servicios y agentes",
149
- "lock.nopw":
150
- "sudo sin contraseña (NOPASSWD): no hace falta la contraseña de administrador",
151
- "lock.session": "Desbloqueado durante esta sesión",
152
- "lock.sessionDesc":
153
- "Desbloqueado durante esta sesión: iniciar, detener y reiniciar funcionan en todas las páginas.",
145
+ "lock.waiting": "Se necesita la contraseña de administrador",
146
+ "lock.needs": "{cmd} necesita tu contraseña de administrador",
147
+ "lock.cmd": "Un comando",
154
148
  "lock.cfg":
155
149
  "Guardar la contraseña está desactivado: define secrets.provider y secrets.refs.sudo en {file}.",
156
150
  "lock.update": "Actualizar la contraseña guardada",
157
151
  "lock.saveTo": "Guardar en {s}",
158
152
  "lock.pw": "Contraseña de administrador",
159
- "lock.go": "Desbloquear",
153
+ "lock.go": "Continuar",
160
154
  "lock.checking": "Comprobando…",
161
155
  "lock.note":
162
- "Den la pide una vez por sesión. Los agentes no pueden desbloquear.",
163
- "lock.again": "Escribir la contraseña de nuevo…",
164
- "lock.saveFailed": "Desbloqueado, pero no se pudo guardar en {s}: {err}",
156
+ "Den la usa solo para sudo. Los agentes no pueden introducirla.",
157
+ "lock.saved": "Contraseña de administrador guardada para esta sesión",
158
+ "lock.saveFailed":
159
+ "Guardada para esta sesión, pero no se pudo guardar en {s}: {err}",
165
160
  "lock.wrong": "Contraseña incorrecta; inténtalo de nuevo",
166
161
  "lock.reqFailed": "La solicitud falló: ¿está en marcha el servidor?",
167
162
  "v.title": "Ver el registro de cambios",
@@ -289,11 +284,13 @@
289
284
  "set.graph.density": "Tarjetas",
290
285
  "set.graph.panelWidth": "Panel de detalles",
291
286
  "set.workspace.editor": "IDE de este espacio de trabajo",
287
+ "set.agents.runtimes": "Runtimes",
292
288
  "set.cli.local_dev": "Entorno local",
293
289
  "set.cli.cli_tabs": "Pestañas de terminal",
294
290
  "set.cli.log_window_lines": "Líneas del panel de registros",
295
291
  "set.cli.web_port": "Tu puerto web",
296
292
  "set.cli.signin": "Inicio de sesión del navegador",
293
+ "set.cli.sudo": "Contraseña de administrador",
297
294
  "set.distribution.brand": "Nombre del producto",
298
295
  "set.distribution.github_org": "Org de GitHub",
299
296
  "set.distribution.orgs": "Etiquetas de org",
@@ -383,5 +380,10 @@
383
380
  "ag.rt.fix":
384
381
  "Instálalo, o pon su ruta en {key} en {file}, o ejecuta {cmd}.",
385
382
  "ag.rt.unknown": "Aquí no hay ningún runtime {id}.",
383
+ "ag.rt.denTools": "Herramientas de Den: próximamente",
384
+ "ag.rt.ownCli":
385
+ "Se ejecuta como su propia CLI en el workspace: las herramientas y los modos de permiso de Den llegarán más adelante.",
386
+ "ag.rt.notInstalled": "no instalado",
387
+ "ag.rt.needsArgv": "la persona no tiene plantilla argv",
386
388
  };
387
389
  })((window.denMessages = window.denMessages || {}));
@@ -131,27 +131,21 @@
131
131
  "dock.err": "失敗",
132
132
  "dock.last": "前回の実行: {s} ↗",
133
133
  "dock.resize": "ドラッグしてサイズを変更",
134
- "lock.locked": "ロック中",
135
- "lock.unlocked": "ロック解除",
136
- "lock.title":
137
- "ロックを解除するとサービスとエージェントの起動・停止・再起動ができます",
138
- "lock.nopw":
139
- "パスワード不要の sudo (NOPASSWD): 管理者パスワードは必要ありません",
140
- "lock.session": "このセッションの間ロックを解除しました",
141
- "lock.sessionDesc":
142
- "このセッションの間ロック解除中: どのページでも起動・停止・再起動ができます。",
134
+ "lock.waiting": "管理者パスワードが必要です",
135
+ "lock.needs": "{cmd} に管理者パスワードが必要です",
136
+ "lock.cmd": "コマンド",
143
137
  "lock.cfg":
144
138
  "パスワードの保存はオフです: {file} で secrets.provider と secrets.refs.sudo を設定してください。",
145
139
  "lock.update": "保存したパスワードを更新",
146
140
  "lock.saveTo": "{s} に保存",
147
141
  "lock.pw": "管理者パスワード",
148
- "lock.go": "ロック解除",
142
+ "lock.go": "続行",
149
143
  "lock.checking": "確認中…",
150
144
  "lock.note":
151
- "Den が尋ねるのはセッションごとに一度です。エージェントはロックを解除できません。",
152
- "lock.again": "パスワードをもう一度入力…",
145
+ "Den はこれを sudo にだけ使います。エージェントは入力できません。",
146
+ "lock.saved": "このセッションの間、管理者パスワードを保持します",
153
147
  "lock.saveFailed":
154
- "ロックを解除しましたが、{s} への保存に失敗しました: {err}",
148
+ "このセッションの間は保持しますが、{s} への保存に失敗しました: {err}",
155
149
  "lock.wrong": "パスワードが違います。もう一度お試しください",
156
150
  "lock.reqFailed": "リクエストに失敗しました。サーバーは動いていますか?",
157
151
  "v.title": "変更履歴を見る",
@@ -278,11 +272,13 @@
278
272
  "set.graph.density": "カード",
279
273
  "set.graph.panelWidth": "詳細パネル",
280
274
  "set.workspace.editor": "このワークスペースの IDE",
275
+ "set.agents.runtimes": "ランタイム",
281
276
  "set.cli.local_dev": "ローカル環境",
282
277
  "set.cli.cli_tabs": "ターミナルのタブ",
283
278
  "set.cli.log_window_lines": "ログ欄の行数",
284
279
  "set.cli.web_port": "あなたの web ポート",
285
280
  "set.cli.signin": "ブラウザのサインイン",
281
+ "set.cli.sudo": "管理者パスワード",
286
282
  "set.distribution.brand": "製品名",
287
283
  "set.distribution.github_org": "GitHub の組織",
288
284
  "set.distribution.orgs": "組織のラベル",
@@ -368,5 +364,10 @@
368
364
  "ag.rt.fix":
369
365
  "インストールするか、{file} の {key} にパスを設定するか、{cmd} を実行してください。",
370
366
  "ag.rt.unknown": "ランタイム {id} はここにありません。",
367
+ "ag.rt.denTools": "Den ツール: 近日対応",
368
+ "ag.rt.ownCli":
369
+ "ワークスペースで独自の CLI として動きます。Den のツールと権限モードは今後対応します。",
370
+ "ag.rt.notInstalled": "未インストール",
371
+ "ag.rt.needsArgv": "ペルソナに argv テンプレートがありません",
371
372
  };
372
373
  })((window.denMessages = window.denMessages || {}));
@@ -1160,6 +1160,7 @@
1160
1160
  storeOpenLog,
1161
1161
  initChangelog,
1162
1162
  envReady,
1163
+ sudoRun,
1163
1164
  } from "/header.js";
1164
1165
  import { ownersLabel } from "/workspace-picker.js";
1165
1166
  import {
@@ -2128,10 +2129,16 @@
2128
2129
 
2129
2130
  pane.innerHTML = services
2130
2131
  .map((s) => {
2132
+ // The row's own workspace (the first in view that defines it):
2133
+ // its command runs there, not in the focused one.
2134
+ const ws = s.inWorkspaces?.[0];
2135
+ const wsAttr = ws
2136
+ ? ` data-workspace="${String(ws).replace(/[&<>"]/g, (c) => `&#${c.charCodeAt(0)};`)}"`
2137
+ : "";
2131
2138
  const cmdBtns = s.commands
2132
2139
  .map(
2133
2140
  (cmd) =>
2134
- `<button class="cmd-btn" data-repo="${s.repo}" data-service="${s.service}" data-cmd="${cmd}" data-type="exec">${cmd}</button>`,
2141
+ `<button class="cmd-btn" data-repo="${s.repo}" data-service="${s.service}" data-cmd="${cmd}" data-type="exec"${wsAttr}>${cmd}</button>`,
2135
2142
  )
2136
2143
  .join("");
2137
2144
  const logsBtn = s.logs
@@ -2156,8 +2163,8 @@
2156
2163
 
2157
2164
  pane.querySelectorAll(".cmd-btn").forEach((btn) => {
2158
2165
  btn.addEventListener("click", () => {
2159
- const { repo, service, cmd, type } = btn.dataset;
2160
- if (type === "exec") runExec(repo, service, cmd);
2166
+ const { repo, service, cmd, type, workspace } = btn.dataset;
2167
+ if (type === "exec") runExec(repo, service, cmd, workspace);
2161
2168
  else {
2162
2169
  const env = document.getElementById("env-sel").value;
2163
2170
  storeOpenLog(repo, service, env);
@@ -2201,7 +2208,7 @@
2201
2208
  }
2202
2209
 
2203
2210
  // ── Service exec ──────────────────────────────────────────────────────────
2204
- function runExec(repo, service, cmd) {
2211
+ function runExec(repo, service, cmd, workspace) {
2205
2212
  if (execEs) {
2206
2213
  execEs.close();
2207
2214
  execEs = null;
@@ -2234,9 +2241,14 @@
2234
2241
  );
2235
2242
 
2236
2243
  const params = new URLSearchParams({ repo, service, cmd, env });
2244
+ if (workspace) params.set("workspace", workspace);
2237
2245
  execEs = new EventSource(`/api/service/exec?${params}`);
2246
+ // LK-0: a command that needs sudo without the password asks, then
2247
+ // runs again.
2248
+ const sudo = sudoRun();
2238
2249
  execEs.onmessage = (e) => {
2239
2250
  const msg = JSON.parse(e.data);
2251
+ sudo.see(msg);
2240
2252
  if (msg.type === "cmd") {
2241
2253
  cmdAppendLine(msg.text, "cmd");
2242
2254
  execDetachable = !!msg.detached;
@@ -2261,6 +2273,7 @@
2261
2273
  .forEach((b) => (b.disabled = false));
2262
2274
  execEs?.close();
2263
2275
  execEs = null;
2276
+ void sudo.done(() => runExec(repo, service, cmd, workspace));
2264
2277
  }
2265
2278
  };
2266
2279
  execEs.onerror = () => {
@@ -1487,8 +1487,8 @@
1487
1487
  initChangelog,
1488
1488
  copyToClipboard,
1489
1489
  computePrimaryByRepo,
1490
- requireUnlock,
1491
1490
  setServiceRunner,
1491
+ sudoRun,
1492
1492
  takePendingAction,
1493
1493
  problemCards,
1494
1494
  problemFix,
@@ -1545,14 +1545,12 @@
1545
1545
  .replace(/>/g, "&gt;")
1546
1546
  .replace(/"/g, "&quot;");
1547
1547
 
1548
- // ── The lock (R8): the shell's lock chip and popover (topbar.js) ──────────
1549
- // requireSudo() is this page's lock rule, shared with every page
1550
- // (async): true when unlocked, after asking the server and the vault
1551
- // (LK-2, LK-3); otherwise the lock popover opens on its password
1552
- // field and the command doesn't run.
1553
- function requireSudo() {
1554
- return requireUnlock();
1555
- }
1548
+ // ── The admin password (R8, LK-0) ─────────────────────────────────────────
1549
+ // It is for sudo, and nothing else: no action here asks for it up
1550
+ // front. A run whose command is about to run sudo without it gets a
1551
+ // needs-sudo event from the server (sudoRun().see); the shell's
1552
+ // prompt then says what needs it, and after the password the action
1553
+ // runs again (sudoRun().done).
1556
1554
 
1557
1555
  // ── Bootstrap ─────────────────────────────────────────────────────────────
1558
1556
  // Systems with auto-start toggled OFF in the viewed workspace (`autostart:
@@ -1572,7 +1570,7 @@
1572
1570
  ]);
1573
1571
  initLogBadge();
1574
1572
  // Start and Restart from the status drawer and the ⌘K palette run
1575
- // here, with this page's lock rule and output (R15, R17).
1573
+ // here, with this page's output (R15, R17).
1576
1574
  setServiceRunner((cmd, s) =>
1577
1575
  window.runCmd(s.address, s.repo, s.service, cmd),
1578
1576
  );
@@ -3051,9 +3049,9 @@
3051
3049
  // ── Workspace-level command (SSE) ─────────────────────────────────────────
3052
3050
  // Routes output to each service's own tab as `label` events arrive.
3053
3051
  window.runWorkspaceSync = async function () {
3054
- if (!(await requireSudo())) return;
3055
3052
  setAllButtons(true);
3056
3053
  setWsStatus("syncing…");
3054
+ const sudo = sudoRun();
3057
3055
  const es = new EventSource("/api/workspace/sync");
3058
3056
  // Clear previous sync output then open/activate the tab (explicit action).
3059
3057
  clearDismissed("⟳ sync");
@@ -3062,6 +3060,7 @@
3062
3060
  setBusy("⟳ sync", true);
3063
3061
  es.onmessage = (e) => {
3064
3062
  const msg = JSON.parse(e.data);
3063
+ sudo.see(msg);
3065
3064
  if (msg.type === "line") appendToTab("⟳ sync", msg.text);
3066
3065
  if (msg.type === "error")
3067
3066
  appendToTab("⟳ sync", "Error: " + msg.text, "err");
@@ -3073,6 +3072,7 @@
3073
3072
  setTimeout(() => setWsStatus(""), 3000);
3074
3073
  // See refreshAfterSync — a sync that clones brings new commands with it.
3075
3074
  refreshAfterSync();
3075
+ void sudo.done(() => window.runWorkspaceSync());
3076
3076
  }
3077
3077
  };
3078
3078
  es.onerror = () => {
@@ -3103,9 +3103,9 @@
3103
3103
  // system. Buttons are not globally disabled: it is one repo.
3104
3104
  const _syncing = new Set();
3105
3105
  window.runRepoSync = async function (repo) {
3106
- if (!(await requireSudo())) return;
3107
3106
  if (_syncing.has(repo)) return; // already in flight — ignore re-entry
3108
3107
  _syncing.add(repo);
3108
+ const sudo = sudoRun();
3109
3109
 
3110
3110
  const route = tabRouteFor(repo);
3111
3111
  clearDismissed(route); // explicit action — undo any prior dismissal
@@ -3130,12 +3130,14 @@
3130
3130
  );
3131
3131
  es.onmessage = (e) => {
3132
3132
  const msg = JSON.parse(e.data);
3133
+ sudo.see(msg);
3133
3134
  if (msg.type === "line") appendToTab(route, msg.text);
3134
3135
  if (msg.type === "error")
3135
3136
  appendToTab(route, "Error: " + msg.text, "err");
3136
3137
  if (msg.type === "done") {
3137
3138
  es.close();
3138
3139
  finish("sync complete");
3140
+ void sudo.done(() => window.runRepoSync(repo));
3139
3141
  // A pull can replace this repo's health probes on disk. The health
3140
3142
  // response is cached ~8s, so force a fresh poll rather than leaving
3141
3143
  // dots that reflect the code we just replaced.
@@ -3156,7 +3158,7 @@
3156
3158
  };
3157
3159
 
3158
3160
  window.runWorkspaceCmd = async function (cmd, addresses) {
3159
- if (!(await requireSudo())) return;
3161
+ const sudo = sudoRun();
3160
3162
  const only =
3161
3163
  Array.isArray(addresses) && addresses.length
3162
3164
  ? `&addresses=${encodeURIComponent(addresses.join(","))}`
@@ -3174,6 +3176,7 @@
3174
3176
 
3175
3177
  esWs.onmessage = (e) => {
3176
3178
  const msg = JSON.parse(e.data);
3179
+ sudo.see(msg);
3177
3180
  if (msg.type === "label") {
3178
3181
  // New service section — route to the SYSTEM's primary tab (one tab
3179
3182
  // per system; sidecar output lands in its repo's tab with a header).
@@ -3239,6 +3242,7 @@
3239
3242
  idle();
3240
3243
  // Lifecycle changed run-state — sync dots now, don't wait for the poll.
3241
3244
  syncHealthNow();
3245
+ void sudo.done(() => window.runWorkspaceCmd(cmd, addresses));
3242
3246
  }
3243
3247
  };
3244
3248
  esWs.onerror = () => {
@@ -3270,7 +3274,6 @@
3270
3274
  // its rules are load-bearing, and the already-healthy skip it relies on is
3271
3275
  // suppressed whenever an explicit address list is sent.
3272
3276
  window.runUpWithDeps = async function (repo) {
3273
- if (!(await requireSudo())) return;
3274
3277
  void runWorkspaceCmdAsync("up", null, {
3275
3278
  seed: repo,
3276
3279
  undismiss: true,
@@ -3282,7 +3285,6 @@
3282
3285
  // same reason as its counterpart: the already-stopped skip is suppressed
3283
3286
  // when an explicit address list is sent.
3284
3287
  window.runDownWithDeps = async function (repo) {
3285
- if (!(await requireSudo())) return;
3286
3288
  void runWorkspaceCmdAsync("down", null, {
3287
3289
  seed: repo,
3288
3290
  undismiss: true,
@@ -3290,7 +3292,6 @@
3290
3292
  };
3291
3293
 
3292
3294
  window.runCmd = async function (address, repo, service, cmd) {
3293
- if (!(await requireSudo())) return;
3294
3295
  // Explicit single-service action — undo any prior dismissal so its pane returns.
3295
3296
  clearDismissed(address);
3296
3297
  // up/restart/down go through the workspace-exec runner: it uses the full
@@ -3319,9 +3320,12 @@
3319
3320
  setTabStatus(address, { es: esCmd });
3320
3321
  const _cmdTab = _tabs.get(address);
3321
3322
  if (_cmdTab) _cmdTab.esKind = "exec";
3323
+ const sudo = sudoRun();
3324
+ const again = () => window.runCmd(address, repo, service, cmd);
3322
3325
 
3323
3326
  esCmd.onmessage = (e) => {
3324
3327
  const msg = JSON.parse(e.data);
3328
+ sudo.see(msg);
3325
3329
  if (msg.type === "cmd") {
3326
3330
  appendToTab(address, msg.text, "cmd");
3327
3331
  } else if (msg.type === "started") {
@@ -3349,6 +3353,7 @@
3349
3353
  "done-fail",
3350
3354
  );
3351
3355
  setTabStatus(address, { live: false, errored: true, es: null });
3356
+ void sudo.done(again);
3352
3357
  }
3353
3358
  } else if (msg.type === "line") {
3354
3359
  appendToTab(address, msg.text, "");
@@ -3369,6 +3374,7 @@
3369
3374
  );
3370
3375
  _tabs.get(address) && (_tabs.get(address).busy = false);
3371
3376
  setTabStatus(address, { live: false, errored: !ok, es: null });
3377
+ void sudo.done(again);
3372
3378
  }
3373
3379
  };
3374
3380
  esCmd.onerror = () => {
@@ -3853,9 +3859,11 @@
3853
3859
  // address filter — the filter suppresses the already-healthy skip and
3854
3860
  // the auto-start-off handling that make "with deps" behave.
3855
3861
  if (opts.seed) params.set("seed", opts.seed);
3862
+ const sudo = sudoRun();
3856
3863
  const es = new EventSource(`/api/workspace/exec?${params}`);
3857
3864
  es.onmessage = (e) => {
3858
3865
  const msg = JSON.parse(e.data);
3866
+ sudo.see(msg);
3859
3867
  if (msg.type === "label") {
3860
3868
  currentAddress = msg.text;
3861
3869
  // Route to the system's primary tab; skip dismissed panes on
@@ -3924,7 +3932,10 @@
3924
3932
  idle();
3925
3933
  // Lifecycle changed run-state — sync dots now, don't wait for the poll.
3926
3934
  syncHealthNow();
3927
- resolve(ok);
3935
+ // LK-0: after the admin password, the same run again.
3936
+ void sudo
3937
+ .done(() => runWorkspaceCmdAsync(cmd, addresses, opts))
3938
+ .then((again) => resolve(again ?? ok));
3928
3939
  }
3929
3940
  };
3930
3941
  es.onerror = () => {
@@ -333,6 +333,7 @@
333
333
  initChangelog,
334
334
  computePrimaryByRepo,
335
335
  envReady,
336
+ sudoRun,
336
337
  } from "/header.js";
337
338
  import { ownersLabel } from "/workspace-picker.js";
338
339
 
@@ -613,10 +614,16 @@
613
614
  const usableCmds = isSub
614
615
  ? s.commands.filter((cmd) => !LIFECYCLE_CMDS.has(cmd))
615
616
  : s.commands;
617
+ // The workspace the row was built from (the first in view that
618
+ // defines it, focus first): its command runs there, not in
619
+ // whichever workspace is focused.
620
+ const wsAttr = s.inWorkspaces?.[0]
621
+ ? ` data-workspace="${esc(s.inWorkspaces[0])}"`
622
+ : "";
616
623
  const cmdButtons = usableCmds
617
624
  .map(
618
625
  (cmd) =>
619
- `<button class="cmd-btn" data-repo="${s.repo}" data-service="${s.service}" data-cmd="${cmd}" data-type="exec">${cmd}</button>`,
626
+ `<button class="cmd-btn" data-repo="${s.repo}" data-service="${s.service}" data-cmd="${cmd}" data-type="exec"${wsAttr}>${cmd}</button>`,
620
627
  )
621
628
  .join("");
622
629
  const logsButton = s.logs
@@ -743,8 +750,9 @@
743
750
  // Attach click handlers
744
751
  pane.querySelectorAll(".cmd-btn").forEach((btn) => {
745
752
  btn.addEventListener("click", () => {
746
- const { repo, service, cmd, type } = btn.dataset;
747
- if (type === "exec") runExec(repo, service, cmd, btn);
753
+ const { repo, service, cmd, type, workspace } = btn.dataset;
754
+ if (type === "exec")
755
+ runExec(repo, service, cmd, btn, workspace);
748
756
  else runLogs(repo, service, btn);
749
757
  });
750
758
  });
@@ -765,7 +773,7 @@
765
773
  }
766
774
  }
767
775
 
768
- function runExec(repo, service, cmd, btn) {
776
+ function runExec(repo, service, cmd, btn, workspace) {
769
777
  endStream();
770
778
  const env = document.getElementById("env-sel").value;
771
779
 
@@ -781,8 +789,13 @@
781
789
  appendLine(`\n▶ ${repo}/${service} · ${cmd} · ${env}\n`, "cmd");
782
790
 
783
791
  const params = new URLSearchParams({ repo, service, cmd, env });
792
+ if (workspace) params.set("workspace", workspace);
784
793
  es = new EventSource(`/api/service/exec?${params}`);
785
- handleSSE(es, repo, service);
794
+ // LK-0: a command that needs sudo without the password asks,
795
+ // then runs again.
796
+ handleSSE(es, repo, service, {
797
+ again: () => runExec(repo, service, cmd, btn, workspace),
798
+ });
786
799
  }
787
800
 
788
801
  function runLogs(repo, service, btn) {
@@ -819,8 +832,10 @@
819
832
  const isStream = opts.stream === true;
820
833
  const id = runId;
821
834
  const appendLine = (text, cls) => runs().append(id, text, cls);
835
+ const sudo = sudoRun();
822
836
  evtSrc.onmessage = (e) => {
823
837
  const msg = JSON.parse(e.data);
838
+ sudo.see(msg);
824
839
  if (msg.type === "cmd") {
825
840
  appendLine(msg.text, "cmd");
826
841
  } else if (msg.type === "line") {
@@ -842,6 +857,7 @@
842
857
  } else runs().set(id, { running: false });
843
858
  evtSrc.close();
844
859
  es = null;
860
+ if (opts.again) void sudo.done(opts.again);
845
861
  }
846
862
  };
847
863
  evtSrc.onerror = () => {
@@ -300,7 +300,8 @@ export async function mountSettings(deps) {
300
300
  const did = `d-${id}`;
301
301
  const v = value(r);
302
302
  const t = r.control?.type || "readonly";
303
- const ro = !r.write;
303
+ // A custom control draws (and saves) itself: no write to read it by.
304
+ const ro = !r.write && t !== "custom";
304
305
  const single = ["number", "select", "toggle", "text"].includes(t);
305
306
  const label = single
306
307
  ? `<label class="l" id="${lid}" for="${cid}">${esc(r.label)}</label>`
@@ -499,7 +500,7 @@ export async function mountSettings(deps) {
499
500
  }, 2000);
500
501
  }
501
502
  function wireRow(r) {
502
- if (!r.write) return;
503
+ if (!r.write && r.control?.type !== "custom") return;
503
504
  const row = $(`setting-${slug(r.id)}`);
504
505
  if (!row) return;
505
506
  const t = r.control?.type;