@monster-orange/den 0.4.17 → 0.4.19

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 (49) hide show
  1. package/CHANGELOG.md +177 -0
  2. package/README.md +14 -13
  3. package/docs/distributions.md +138 -25
  4. package/docs/reference.md +1 -1
  5. package/package.json +2 -1
  6. package/skills/install-den/SKILL.md +35 -75
  7. package/src/shared/agents/den-claude.ts +40 -6
  8. package/src/shared/agents/guardrails/stop-hook.ts +30 -4
  9. package/src/shared/agents/guardrails.ts +40 -4
  10. package/src/shared/agents/handback.ts +22 -4
  11. package/src/shared/agents/personas.ts +32 -5
  12. package/src/shared/agents/runtime.ts +8 -0
  13. package/src/shared/agents/server-agent.ts +57 -0
  14. package/src/shared/agents/server-pack/server.md +45 -0
  15. package/src/shared/environment-manager/workspaces.ts +4 -0
  16. package/src/ui/cli/agent-ops.ts +84 -6
  17. package/src/ui/cli/create.ts +2 -2
  18. package/src/ui/cli/index.ts +1 -1
  19. package/src/ui/cli/repo-command.ts +8 -1
  20. package/src/ui/cli/repo-detect.ts +58 -0
  21. package/src/ui/cli/server-agent-ops.ts +411 -0
  22. package/src/ui/mcp/server.ts +29 -12
  23. package/src/ui/mcp/tools.ts +4 -2
  24. package/src/ui/server/agent-routes.ts +10 -1
  25. package/src/ui/server/held-actions.ts +35 -7
  26. package/src/ui/server/index.ts +20 -5
  27. package/src/ui/server/public/agents-page.js +33 -1
  28. package/src/ui/server/public/agents.html +13 -0
  29. package/src/ui/server/public/error-page.js +39 -1
  30. package/src/ui/server/public/graph.css +43 -0
  31. package/src/ui/server/public/graph.js +68 -1
  32. package/src/ui/server/public/header.js +36 -7
  33. package/src/ui/server/public/i18n/es.js +72 -0
  34. package/src/ui/server/public/i18n/ja.js +74 -0
  35. package/src/ui/server/public/local-dev.html +33 -3
  36. package/src/ui/server/public/log-source.js +26 -0
  37. package/src/ui/server/public/logs.html +7 -20
  38. package/src/ui/server/public/persona-ui.css +6 -0
  39. package/src/ui/server/public/server-agent-page.js +415 -0
  40. package/src/ui/server/public/server-agent.html +222 -0
  41. package/src/ui/server/public/services.html +18 -3
  42. package/src/ui/server/public/shell.js +274 -31
  43. package/src/ui/server/public/svc-panel.js +685 -0
  44. package/src/ui/server/public/topbar.js +13 -1
  45. package/src/ui/server/route-classes.ts +21 -0
  46. package/src/ui/server/server-agent-routes.ts +176 -0
  47. package/src/ui/server/session-routes.ts +12 -1
  48. package/src/ui/server/ui-assets.ts +6 -0
  49. package/src/ui/server/workspace-routes.ts +3 -0
@@ -52,6 +52,43 @@
52
52
  "act.unpin": "ピン留めを外す",
53
53
  "act.visit": "開く",
54
54
  "act.visitX": "{s} を開く(新しいタブ)",
55
+ // 0.4.18: the service panel (svc-panel.js) and Start's Folder.
56
+ "svp.label": "{s}: クイックパネル",
57
+ "svp.more": "{s} のクイックパネル",
58
+ "svp.notRunning": "停止中",
59
+ "svp.up": "稼働 {d}",
60
+ "svp.upNow": "稼働中",
61
+ "svp.down": "停止: {c}",
62
+ "svp.downNone": "停止",
63
+ "svp.starting": "起動中…",
64
+ "svp.stopped": "停止",
65
+ "svp.noUrl": "URL なし",
66
+ "svp.start": "開始",
67
+ "svp.stop": "停止",
68
+ "svp.restart": "再起動",
69
+ "svp.logs": "ログ",
70
+ "svp.stopping": "{s} を停止中",
71
+ "svp.tests": "前回のテスト",
72
+ "svp.testsOk": "✓ {t}",
73
+ "svp.testsBad": "✗ 失敗 {t}",
74
+ "svp.testsRun": "実行中…",
75
+ "svp.testsNone": "—",
76
+ "svp.runTests": "テストを実行",
77
+ "svp.testsDone": "テスト成功: {s}",
78
+ "svp.testsFailed": "テスト失敗: {s}",
79
+ "svp.agents": "エージェント",
80
+ "svp.noAgents": "このリポジトリにエージェントはいません。",
81
+ "svp.open": "開く",
82
+ "svp.openAg": "{s} を開く",
83
+ "svp.startHere": "ここでエージェントを開始",
84
+ "svp.openSvc": "サービスで開く",
85
+ "svp.openGraph": "グラフで開く",
86
+ "graph.outside": "{s} は {ws} にあり、この表示の範囲外です。",
87
+ "graph.showWs": "{ws} を表示",
88
+ "svp.close": "閉じる",
89
+ "ag.new.folder": "フォルダー(任意)",
90
+ "ag.new.folderHelp":
91
+ "ワークスペースのフォルダー内。空欄ならワークスペースのフォルダー。",
55
92
  "act.pinX": "{s} をピン留め",
56
93
  "act.unpinX": "{s} のピン留めを外す",
57
94
  "toast.pinned": "{s} をピン留めしました",
@@ -267,6 +304,10 @@
267
304
  "err.signin.step2":
268
305
  "承認されると、このページは自動でサインインします。コードの有効期限は 10 分です。",
269
306
  "err.signin.waiting": "承認を待っています…",
307
+ "err.signin.windowsSteps":
308
+ "Windows ターミナルか PowerShell を開き、これを貼り付けて Enter キーを押します。",
309
+ "err.signin.nothingOpened":
310
+ "何も開きませんでしたか?下のコマンドをコピーして、ターミナルアプリに貼り付けてください。",
270
311
  "err.signin.noTerminal":
271
312
  "{host} のターミナルがありませんか?このコンピューターで次を実行します:",
272
313
  "err.signin.openTerminal": "ターミナルを開く",
@@ -531,6 +572,8 @@
531
572
  "ap.do.repo_add.new":
532
573
  "{path} のリポジトリを新しいワークスペース「{workspace}」に追加",
533
574
  "ap.do.service_try": "{service} を起動",
575
+ "ap.do.service_try.activates":
576
+ "{service} を起動 ({workspace} を有効にします)",
534
577
  "ap.says": "エージェントのコメント:",
535
578
  "ap.review": "確認",
536
579
  "ap.hide": "詳細を隠す",
@@ -672,6 +715,37 @@
672
715
  "ag.ws": "ワークスペース",
673
716
  "ag.pal": "エージェント › {ws} / {name}",
674
717
  "ag.palStart": "エージェントを開始…",
718
+ // ON-6: the server agent (server-agent-page.js, the rail, ⌘K).
719
+ "n.serverAgent": "__DEN_TITLE__ エージェント",
720
+ "ag.server": "サーバー",
721
+ "pal.askServer": "__DEN_TITLE__ に聞く",
722
+ "sa.sub.fresh":
723
+ "__DEN_TITLE__ をセットアップしましょう。変更を許可するまで、読み取りだけを行います。",
724
+ "sa.sub":
725
+ "サーバーのことなら何でも:リポジトリの追加、失敗しているサービスの修正、設定の変更、動いているものの説明。",
726
+ "sa.trust":
727
+ "Claude がこのフォルダーを信頼するか尋ねます。「はい」を選んでください。",
728
+ "sa.model": "モデル",
729
+ "sa.login":
730
+ "Claude からログインを求められたら、ここでログインしてください。そのあと {name} が始まります。",
731
+ "sa.readsOnly":
732
+ "__DEN_TITLE__ 自身のツールだけで動きます。変更はそのたびに、このブラウザーで先に確認します。",
733
+ "sa.starting": "エージェントを起動しています…",
734
+ "sa.idle": "{name} は動いていません。",
735
+ "sa.start": "開始",
736
+ "sa.failed":
737
+ "起動しませんでした:{why}。詳しくは {cli} agents doctor で確認できます。",
738
+ "sa.unread":
739
+ "エージェントの状態を読めませんでした。Web サーバーは動いていますか?",
740
+ "sa.none.h": "エージェントを動かせるランタイムがまだありません",
741
+ "sa.none":
742
+ "エージェントはランタイムカタログのエージェント CLI で動きます。1 つインストールして一度ログインし、このページを再読み込みしてください。",
743
+ "sa.noTools": "まだ __DEN_TITLE__ のツールを使えません",
744
+ "sa.manual.h": "自分でセットアップする",
745
+ "sa.manual": "エージェントの各手順はコマンドでもできます。ターミナルで:",
746
+ "sa.runsOn": "実行するランタイム",
747
+ "sa.openAgents": "エージェントで開く",
748
+ "sa.saved": "保存しました。次回の起動から使われます。",
675
749
  "ag.cap":
676
750
  "ペインは最大 4 つです。{id} のペインを閉じました(実行は続きます)",
677
751
  "ag.layout": "レイアウト",
@@ -1544,6 +1544,7 @@
1544
1544
  copyToClipboard,
1545
1545
  computePrimaryByRepo,
1546
1546
  setServiceRunner,
1547
+ switchView,
1547
1548
  sudoRun,
1548
1549
  takePendingAction,
1549
1550
  problemCards,
@@ -1552,7 +1553,12 @@
1552
1553
  } from "/header.js";
1553
1554
  import { checkLine } from "/topbar.js";
1554
1555
  import { agentsSectionHtml, mountAgents } from "/agents-section.js";
1555
- import { mountGraph, graphHealth, graphMaximized } from "/graph.js";
1556
+ import {
1557
+ mountGraph,
1558
+ graphEmptyNote,
1559
+ graphHealth,
1560
+ graphMaximized,
1561
+ } from "/graph.js";
1556
1562
  import {
1557
1563
  mergeWorkspaceViews,
1558
1564
  ownerOfRepos,
@@ -1874,7 +1880,12 @@
1874
1880
  if (updateUrl) {
1875
1881
  const params = new URLSearchParams(location.search);
1876
1882
  params.set("ws", name);
1877
- history.replaceState(null, "", "?" + params.toString());
1883
+ // The hash stays (#focus=<id>: Open in Graph's card).
1884
+ history.replaceState(
1885
+ null,
1886
+ "",
1887
+ "?" + params.toString() + location.hash,
1888
+ );
1878
1889
  }
1879
1890
  document
1880
1891
  .querySelectorAll(".ws-item")
@@ -4273,7 +4284,11 @@
4273
4284
  const params = new URLSearchParams(location.search);
4274
4285
  params.delete("ws");
4275
4286
  const qs = params.toString();
4276
- history.replaceState(null, "", qs ? "?" + qs : location.pathname);
4287
+ history.replaceState(
4288
+ null,
4289
+ "",
4290
+ (qs ? "?" + qs : location.pathname) + location.hash,
4291
+ );
4277
4292
  }
4278
4293
  await loadWorkspaces();
4279
4294
  }
@@ -4644,6 +4659,19 @@
4644
4659
  });
4645
4660
 
4646
4661
  const GRAPH_HOOKS = {
4662
+ /**
4663
+ * Open in Graph for a service this graph doesn't draw: the
4664
+ * workspace it is in, outside the View (#focus=, graph.js), or
4665
+ * null when it is in no other workspace.
4666
+ */
4667
+ outsideView: (id) => {
4668
+ const shown = GRAPH_ALL
4669
+ ? GRAPH_ALL.names
4670
+ : [document.getElementById("content")?.dataset.ws || activeWs];
4671
+ return wsOfAddress(id).find((w) => !shown.includes(w)) ?? null;
4672
+ },
4673
+ /** Its Show <workspace>: the top bar's switch (header.js). */
4674
+ showWorkspace: (ws) => switchView(ws),
4647
4675
  maximize: {
4648
4676
  isOn: () => graphMax(),
4649
4677
  toggle: () => setGraphMax(!graphMax()),
@@ -4692,6 +4720,8 @@
4692
4720
  if (!services || services.length === 0) {
4693
4721
  wrap.innerHTML =
4694
4722
  '<div class="graph-empty">No services in workspace</div>';
4723
+ // Open in Graph for a service in another workspace: say where.
4724
+ graphEmptyNote(wrap, GRAPH_HOOKS);
4695
4725
  return;
4696
4726
  }
4697
4727
  mountGraph(
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Where a service's live log comes from (0.4.18): one rule for the Logs
3
+ * page and the service panel's Logs, so they never disagree.
4
+ *
5
+ * - Den's own server log (the "__DEN_CLI__/server" pseudo-service):
6
+ * /api/server-log, the server's log ring;
7
+ * - a local service: /api/service/taillog, the workspace page's source
8
+ * chain (the manifest's command, the conventional container, the
9
+ * daemon's startup log). /api/service/logs answers 404 for a service
10
+ * with no `logs:` block;
11
+ * - any other env: /api/service/logs, for cloud sources and the reauth
12
+ * flow.
13
+ */
14
+
15
+ /** The server's own log, shown like a service's. */
16
+ export function isServerLog(repo, service) {
17
+ return repo === "__DEN_CLI__" && service === "server";
18
+ }
19
+
20
+ /** The SSE URL of `repo/service`'s live log in `env`. */
21
+ export function logStreamUrl(repo, service, env) {
22
+ if (isServerLog(repo, service)) return "/api/server-log";
23
+ if (env === "local")
24
+ return `/api/service/taillog?${new URLSearchParams({ repo, service })}`;
25
+ return `/api/service/logs?${new URLSearchParams({ repo, service, env })}`;
26
+ }
@@ -476,6 +476,7 @@
476
476
  envReady,
477
477
  initWorkspacePill,
478
478
  } from "/header.js";
479
+ import { isServerLog, logStreamUrl } from "/log-source.js";
479
480
 
480
481
  // ── Safe highlight rendering ──────────────────────────────────────────────
481
482
  // Log lines are UNTRUSTED (any service can log attacker-influenced text,
@@ -550,11 +551,9 @@
550
551
  const logStreams = new Map();
551
552
 
552
553
  // "__DEN_CLI__/server" is a pseudo-service that taps the server's own log ring
553
- // via /api/server-log instead of /api/service/logs. Everything else in the
554
- // page treats it like a normal log stream.
555
- function isServerLog(repo, service) {
556
- return repo === "__DEN_CLI__" && service === "server";
557
- }
554
+ // via /api/server-log instead of /api/service/logs (isServerLog,
555
+ // log-source.js). Everything else in the page treats it like a normal
556
+ // log stream.
558
557
  function formatServerLogEntry(entry) {
559
558
  // "14:07:41 [lifecycle] ▶ run synthetic/local · modules=all [abcxyz]"
560
559
  // An agent's action reads "[agent]" and ends "· by agent <client> …".
@@ -1075,21 +1074,9 @@
1075
1074
  // ─────────────────────────────────────────────────────────────────────
1076
1075
 
1077
1076
  const serverLog = isServerLog(repo, service);
1078
- // Local streams go through taillog — the same endpoint the workspace
1079
- // page's tabs use. It has the full source-resolution chain (manifest
1080
- // cmd → conventional opctl container → daemon startup log), whereas
1081
- // /api/service/logs 404s for services with no logs block (shop-web,
1082
- // control-plane). Non-local envs keep /api/service/logs for
1083
- // cloudwatch sources + the AWS reauth flow.
1084
- const es = serverLog
1085
- ? new EventSource("/api/server-log")
1086
- : env === "local"
1087
- ? new EventSource(
1088
- `/api/service/taillog?${new URLSearchParams({ repo, service })}`,
1089
- )
1090
- : new EventSource(
1091
- `/api/service/logs?${new URLSearchParams({ repo, service, env })}`,
1092
- );
1077
+ // Where the stream comes from (taillog for local, logs elsewhere):
1078
+ // log-source.js, which the service panel's Logs uses too.
1079
+ const es = new EventSource(logStreamUrl(repo, service, env));
1093
1080
 
1094
1081
  es.onmessage = (e) => {
1095
1082
  const msg = JSON.parse(e.data);
@@ -345,6 +345,12 @@ dialog.pe-dlg::backdrop {
345
345
  color: var(--faint);
346
346
  font-weight: 400;
347
347
  }
348
+ /* On the chosen row's tint, faint text falls under 4.5:1 (light): the
349
+ role and a plain tag read in muted there. */
350
+ .pr[aria-checked="true"] .pn .role,
351
+ .pr[aria-checked="true"] .tagx:not(.warn, .src-local, .src-distribution, .src-hub) {
352
+ color: var(--muted);
353
+ }
348
354
  .pr .pd {
349
355
  grid-column: 2 / 4;
350
356
  font-size: 12.5px;
@@ -0,0 +1,415 @@
1
+ /**
2
+ * The server agent's page (ON-6): this Den's own agent in a terminal pane,
3
+ * framed as the page, with "Open in Agents" as the way out. A fresh Den's
4
+ * first page (the server serves it at /). Its status is GET
5
+ * /api/server-agent (server-agent-routes.ts).
6
+ *
7
+ * - Fresh, with a runtime that can run it: it starts by itself. It only
8
+ * reads until the person allows a write (each write is held for the
9
+ * browser's Allow, ON-3).
10
+ * - No runtime: the manual path, and how to install a runtime.
11
+ * - Running on Claude on a fresh Den: a line saying Claude asks to trust
12
+ * its folder (choose Yes), and one saying to log in to Claude in the
13
+ * terminal if it asks (Den can't tell whether it is logged in). Which
14
+ * runtime it runs on is its record's (runningRuntime), never the
15
+ * picker's: the two differ after the picker changes, or when the runtime
16
+ * is not found again on a later read.
17
+ * - Started by itself, but not running yet when the page reads it back:
18
+ * the page reads again, a few times, until it runs (so the terminal and
19
+ * its lines come up without a reload).
20
+ * - Not fresh and not running: a Start button (never started by itself).
21
+ * - The runtime picker saves the person's choice (agents.server_runtime),
22
+ * and the model picker beside it (agents.server_model), with only the
23
+ * runtime's own models.
24
+ *
25
+ * serverAgentView(status) is pure (HTML from the status JSON), so tests
26
+ * need no server.
27
+ */
28
+
29
+ const t = (key, vars) => globalThis.window?.denShell?.t?.(key, vars) ?? key;
30
+ const esc = (s) =>
31
+ String(s ?? "").replace(
32
+ /[&<>"']/g,
33
+ (c) =>
34
+ ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
35
+ c
36
+ ],
37
+ );
38
+ const CLI = "__DEN_CLI__";
39
+
40
+ const RUNNING = new Set(["running", "stopping"]);
41
+
42
+ /**
43
+ * What the page shows for `status`: its header line, its body, whether
44
+ * the agent starts by itself, and whether a terminal opens.
45
+ *
46
+ * { sub, body, autoStart, terminal }
47
+ */
48
+ export function serverAgentView(s) {
49
+ const running = RUNNING.has(s.state) && !!s.sessionId;
50
+ const hasRuntime = !!s.runtime;
51
+ const sub = s.fresh ? t("sa.sub.fresh") : t("sa.sub");
52
+ const readsOnly = `<p class="note sa-reads" data-note="reads-only">${esc(t("sa.readsOnly"))}</p>`;
53
+ // Den can't tell whether Claude is logged in (ON-6). On a fresh Den's
54
+ // first run, one line above the terminal says a login prompt is
55
+ // expected, so Claude's own login screen never reads as a crash.
56
+ // Claude Code asks once whether to trust the agent's folder, which Den
57
+ // made for it; Den doesn't answer for the person (that is Claude's own
58
+ // file, ~/.claude.json, with no documented way to pre-accept).
59
+ const onClaude =
60
+ s.fresh &&
61
+ (running ? (s.runningRuntime ?? s.runtime?.id) : s.runtime?.id) ===
62
+ "den-claude";
63
+ const login = onClaude
64
+ ? `<p class="note sa-trust" data-note="trust">${esc(t("sa.trust"))}</p>` +
65
+ `<p class="note sa-login" data-note="login">${esc(t("sa.login", { name: s.name }))}</p>`
66
+ : "";
67
+ if (running)
68
+ return {
69
+ sub,
70
+ body: login + readsOnly,
71
+ autoStart: false,
72
+ terminal: s.sessionId,
73
+ };
74
+ if (!hasRuntime)
75
+ return {
76
+ sub,
77
+ body: noRuntimeHtml(s),
78
+ autoStart: false,
79
+ terminal: null,
80
+ };
81
+ // Fresh and never started (or stopped): it starts by itself.
82
+ if (s.fresh)
83
+ return {
84
+ sub,
85
+ body: `<p class="note" data-note="starting">${esc(t("sa.starting"))}</p>${readsOnly}`,
86
+ autoStart: true,
87
+ terminal: null,
88
+ };
89
+ return {
90
+ sub,
91
+ body:
92
+ `<div class="sa-idle" data-note="idle"><p>${esc(t("sa.idle", { name: s.name }))}</p>` +
93
+ `<button type="button" class="den-btn primary" data-act="start">${esc(t("sa.start"))}</button></div>` +
94
+ readsOnly,
95
+ autoStart: false,
96
+ terminal: null,
97
+ };
98
+ }
99
+
100
+ /** No runtime can run it: how to install one, and the manual path. */
101
+ function noRuntimeHtml(s) {
102
+ const installs = (s.runtimes ?? [])
103
+ .filter((r) => !r.usable)
104
+ .map(
105
+ (r) =>
106
+ `<li data-runtime="${esc(r.id)}"><b>${esc(r.label ?? r.id)}</b>` +
107
+ (r.available && !r.denTools
108
+ ? ` · ${esc(t("sa.noTools"))}`
109
+ : r.install
110
+ ? ` · <code>${esc(r.install)}</code>`
111
+ : "") +
112
+ `</li>`,
113
+ )
114
+ .join("");
115
+ const manual = (s.manual ?? [])
116
+ .map((c) => `<li><code>${esc(c)}</code></li>`)
117
+ .join("");
118
+ return (
119
+ `<section class="sa-none" data-note="no-runtime"><h2 class="sec">${esc(t("sa.none.h"))}</h2>` +
120
+ `<p>${esc(t("sa.none"))}</p>` +
121
+ (installs ? `<ul class="sa-install">${installs}</ul>` : "") +
122
+ `</section>` +
123
+ `<section class="sa-manual" data-note="manual"><h2 class="sec">${esc(t("sa.manual.h"))}</h2>` +
124
+ `<p>${esc(t("sa.manual"))}</p><ol class="sa-steps">${manual}</ol></section>`
125
+ );
126
+ }
127
+
128
+ /** The model picker's options: the runtime's own models, the chosen one selected. */
129
+ export function modelOptionsHtml(s) {
130
+ const models = s.models ?? [];
131
+ if (!models.length) return "";
132
+ return models
133
+ .map(
134
+ (m) =>
135
+ `<option value="${esc(m.id)}"${s.model === m.id ? " selected" : ""}>${esc(m.label ?? m.id)}</option>`,
136
+ )
137
+ .join("");
138
+ }
139
+
140
+ /** The runtime picker's options: the usable ones, the chosen one first. */
141
+ export function runtimeOptionsHtml(s) {
142
+ const usable = (s.runtimes ?? []).filter((r) => r.usable);
143
+ if (!usable.length) return "";
144
+ return usable
145
+ .map(
146
+ (r) =>
147
+ `<option value="${esc(r.id)}"${s.runtime?.id === r.id ? " selected" : ""}>${esc(r.label ?? r.id)}</option>`,
148
+ )
149
+ .join("");
150
+ }
151
+
152
+ // ── the page ────────────────────────────────────────────────────────────
153
+
154
+ const st = {
155
+ root: null,
156
+ status: null,
157
+ pane: null,
158
+ starting: false,
159
+ autoTried: false,
160
+ /** Reads after a start that came back not running yet. */
161
+ rereads: 0,
162
+ };
163
+
164
+ /** How many times, a second apart, a start is read back until it runs. */
165
+ const REREADS = 15;
166
+
167
+ async function readStatus() {
168
+ try {
169
+ const r = await fetch("/api/server-agent", { headers: { accept: "json" } });
170
+ return r.ok ? await r.json() : null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
176
+ function paint() {
177
+ const s = st.status;
178
+ const sub = st.root.querySelector("#sa-sub");
179
+ const body = st.root.querySelector("#sa-body");
180
+ const open = st.root.querySelector("#sa-open");
181
+ const pick = st.root.querySelector("#sa-runtime");
182
+ const mpick = st.root.querySelector("#sa-model");
183
+ body.removeAttribute("aria-busy");
184
+ if (!s) {
185
+ sub.textContent = t("sa.unread");
186
+ body.innerHTML = "";
187
+ return null;
188
+ }
189
+ const v = serverAgentView(s);
190
+ sub.textContent = v.sub;
191
+ body.innerHTML = v.body;
192
+ const opts = runtimeOptionsHtml(s);
193
+ pick.hidden = !opts;
194
+ if (opts) pick.querySelector("select").innerHTML = opts;
195
+ const mopts = modelOptionsHtml(s);
196
+ if (mpick) {
197
+ mpick.hidden = !mopts;
198
+ if (mopts) mpick.querySelector("select").innerHTML = mopts;
199
+ }
200
+ open.hidden = !v.terminal;
201
+ if (v.terminal)
202
+ open.setAttribute("href", `/agents?open=${encodeURIComponent(v.terminal)}`);
203
+ return v;
204
+ }
205
+
206
+ async function refresh() {
207
+ st.status = await readStatus();
208
+ const v = paint();
209
+ if (!v) return;
210
+ if (v.terminal) void openTerminal(v.terminal);
211
+ else closeTerminal();
212
+ // Once per page: a start that ends at once is never retried in a loop.
213
+ if (v.autoStart && !st.autoTried) {
214
+ st.autoTried = true;
215
+ void start();
216
+ return;
217
+ }
218
+ // Started, but not running yet: read again until it is.
219
+ if (st.autoTried && !v.terminal && v.autoStart && st.rereads < REREADS) {
220
+ st.rereads++;
221
+ setTimeout(() => void refresh(), 1000);
222
+ }
223
+ }
224
+
225
+ async function start() {
226
+ if (st.starting) return;
227
+ st.starting = true;
228
+ try {
229
+ await window.denShell.api("POST", "/api/server-agent/start", {});
230
+ } catch (err) {
231
+ st.starting = false;
232
+ const body = st.root.querySelector("#sa-body");
233
+ const why = err?.message ?? String(err);
234
+ body.innerHTML = `<p class="note bad" data-note="failed" role="alert">${esc(t("sa.failed", { why, cli: CLI }))}</p>`;
235
+ return;
236
+ }
237
+ st.starting = false;
238
+ await refresh();
239
+ }
240
+
241
+ async function saveModel(id) {
242
+ try {
243
+ await window.denShell.api("POST", "/api/server-agent/model", {
244
+ model: id,
245
+ });
246
+ window.denShell?.toast?.(t("sa.saved"));
247
+ } catch (err) {
248
+ window.denShell?.errorToast?.(err);
249
+ }
250
+ await refresh();
251
+ }
252
+
253
+ async function saveRuntime(id) {
254
+ try {
255
+ await window.denShell.api("POST", "/api/server-agent/runtime", {
256
+ runtime: id,
257
+ });
258
+ window.denShell?.toast?.(t("sa.saved"));
259
+ } catch (err) {
260
+ window.denShell?.errorToast?.(err);
261
+ }
262
+ await refresh();
263
+ }
264
+
265
+ // ── the terminal ────────────────────────────────────────────────────────
266
+
267
+ function cssVar(name) {
268
+ const v = getComputedStyle(document.documentElement)
269
+ .getPropertyValue(name)
270
+ .trim();
271
+ return v || undefined;
272
+ }
273
+
274
+ async function csrf() {
275
+ try {
276
+ const r = await fetch("/api/session", { credentials: "same-origin" });
277
+ const b = r.ok ? await r.json() : null;
278
+ return typeof b?.csrf === "string" ? b.csrf : null;
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+
284
+ function closeTerminal() {
285
+ const p = st.pane;
286
+ if (!p) return;
287
+ st.pane = null;
288
+ p.closed = true;
289
+ p.sock?.close();
290
+ p.term?.dispose();
291
+ p.observer?.disconnect();
292
+ p.el.remove();
293
+ }
294
+
295
+ async function openTerminal(id) {
296
+ if (st.pane?.id === id) return;
297
+ closeTerminal();
298
+ const el = document.createElement("div");
299
+ el.className = "sa-term";
300
+ el.dataset.id = id;
301
+ el.innerHTML = `<div class="tbox"></div>`;
302
+ st.root.querySelector("#sa-pane").appendChild(el);
303
+ const p = { id, el, term: null, fit: null, sock: null, closed: false };
304
+ st.pane = p;
305
+ let lib;
306
+ try {
307
+ const [x, f] = await Promise.all([
308
+ import("/vendor/xterm.mjs"),
309
+ import("/vendor/addon-fit.mjs"),
310
+ ]);
311
+ lib = { Terminal: x.Terminal, FitAddon: f.FitAddon };
312
+ } catch (err) {
313
+ el.innerHTML = `<p class="note">${esc(err?.message ?? String(err))}</p>`;
314
+ return;
315
+ }
316
+ if (p.closed) return;
317
+ const bg = cssVar("--sunken");
318
+ const term = new lib.Terminal({
319
+ fontFamily: cssVar("--mono") ?? "monospace",
320
+ fontSize: 12,
321
+ lineHeight: 1.25,
322
+ scrollback: 5000,
323
+ theme: { background: bg, foreground: cssVar("--text"), cursorAccent: bg },
324
+ });
325
+ const fit = new lib.FitAddon();
326
+ term.loadAddon(fit);
327
+ term.open(el.querySelector(".tbox"));
328
+ p.term = term;
329
+ p.fit = fit;
330
+ const refit = () => {
331
+ try {
332
+ fit.fit();
333
+ } catch {
334
+ /* not laid out yet */
335
+ }
336
+ };
337
+ refit();
338
+ p.observer = new ResizeObserver(refit);
339
+ p.observer.observe(el);
340
+ const enc = new TextEncoder();
341
+ term.onData((d) => {
342
+ if (p.sock?.readyState === WebSocket.OPEN) p.sock.send(enc.encode(d));
343
+ });
344
+ const size = () => {
345
+ if (p.sock?.readyState === WebSocket.OPEN && term.cols > 1 && term.rows > 1)
346
+ p.sock.send(
347
+ JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }),
348
+ );
349
+ };
350
+ term.onResize(size);
351
+ const token = await csrf();
352
+ if (p.closed) return;
353
+ const proto = location.protocol === "https:" ? "wss:" : "ws:";
354
+ const sock = new WebSocket(
355
+ `${proto}//${location.host}/api/sessions/${encodeURIComponent(id)}/io`,
356
+ ["den.io", `den.csrf.${token ?? ""}`],
357
+ );
358
+ sock.binaryType = "arraybuffer";
359
+ p.sock = sock;
360
+ sock.onopen = () => {
361
+ refit();
362
+ size();
363
+ term.focus();
364
+ };
365
+ sock.onmessage = (e) => {
366
+ if (typeof e.data === "string") {
367
+ let msg = null;
368
+ try {
369
+ msg = JSON.parse(e.data);
370
+ } catch {
371
+ return;
372
+ }
373
+ if (msg?.type === "exit") {
374
+ term.write(`\r\n${t("ag.exitedLine", { code: "" })}\r\n`);
375
+ setTimeout(() => void refresh(), 1000);
376
+ }
377
+ return;
378
+ }
379
+ term.write(new Uint8Array(e.data));
380
+ };
381
+ sock.onclose = (e) => {
382
+ if (p.closed || p.sock !== sock) return;
383
+ p.sock = null;
384
+ term.write(`\r\n${t("ag.lost", { why: e.reason || String(e.code) })}\r\n`);
385
+ };
386
+ }
387
+
388
+ export async function mountServerAgentPage(root) {
389
+ if (!root) return;
390
+ st.root = root;
391
+ for (const [sel, key] of [
392
+ ["#sa-title", "n.serverAgent"],
393
+ ["#sa-runtime > span", "sa.runsOn"],
394
+ ["#sa-model > span", "sa.model"],
395
+ ["#sa-open", "sa.openAgents"],
396
+ ]) {
397
+ const el = root.querySelector(sel);
398
+ if (el) el.textContent = t(key);
399
+ }
400
+ if (window.denShell?.apiOff?.()) return;
401
+ root.addEventListener("click", (e) => {
402
+ const b = e.target.closest("button[data-act]");
403
+ if (b?.dataset.act === "start") {
404
+ b.disabled = true;
405
+ void start();
406
+ }
407
+ });
408
+ root.querySelector("#sa-runtime select")?.addEventListener("change", (e) => {
409
+ void saveRuntime(e.target.value);
410
+ });
411
+ root.querySelector("#sa-model select")?.addEventListener("change", (e) => {
412
+ void saveModel(e.target.value);
413
+ });
414
+ await refresh();
415
+ }