@monster-orange/den 0.4.17 → 0.4.18

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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,49 @@ All notable changes to Den are documented here. Each release lists the
4
4
  config and manifest changes a distribution must make to move to it under
5
5
  **Migrations**.
6
6
 
7
+ ## [0.4.18] - 2026-11-08
8
+
9
+ ### Added
10
+
11
+ - **A pinned service opens a quick panel, not a page jump.** A click on a
12
+ pinned service's name in the rail (or Enter on its row, or its dot in
13
+ the collapsed rail) opens a panel beside the row, without leaving the
14
+ page. It has the service's workspace, state and URL; Visit ↗, Start or
15
+ Stop, Restart and Logs, which call what the Services page calls; the
16
+ repo's last test run with Run tests; the agents working in the repo,
17
+ each with Open, and Start agent here; and Open in Services, Open in
18
+ Graph and Unpin. It stays current while open. Esc or a click outside
19
+ closes it and focus goes back to the row. Under 640 px it is a bottom
20
+ sheet. ⌘-click or a middle click still opens Services in a new tab. The
21
+ Services card's name and the Graph card's new ⋯ open the same panel.
22
+ - **Visit ↗ on every pinned row.** Always shown, it opens the service's
23
+ URL in a new tab. It is greyed out ("Not running") while the service is
24
+ down, and absent when the service has no URL. The hover × is gone:
25
+ Unpin is in the panel, and Delete on the row still unpins.
26
+ - **Start agent here.** The Start dialog has a Folder field (optional):
27
+ the agent runs in that folder of the workspace's, or in a repo's
28
+ folder. The route takes it as `folder`, and refuses a folder outside
29
+ the workspace (403). The panel's Start agent here fills the workspace
30
+ and the repo's folder.
31
+ - **Open in Graph focuses the service.** `/graph#focus=<repo>` or
32
+ `#focus=<repo>/<service>` opens the Graph on that card.
33
+
34
+ ### Fixed
35
+
36
+ - **The panel's Logs reads the service's log.** It asks where the Logs
37
+ page asks (taillog for a local service), so a service with no `logs:`
38
+ block no longer says "Connection closed". The Services page's Logs
39
+ uses the same rule.
40
+ - **"starting…" follows health.** After Start or Restart, the panel
41
+ leaves "starting…" once health says up, once it says down after a
42
+ moment, or after two minutes. Restart works again then, even when the
43
+ service's `up` never exits (a dev server).
44
+ - **A pinned row's Visit ↗ greys out with the panel.** It reads the
45
+ panel's live state, so it greys out on the same health update, also
46
+ for a service outside the top bar's View.
47
+ - **Open in Graph keeps its target.** The Graph page keeps the URL's
48
+ `#focus=` when it picks a workspace, so the card is focused and in view.
49
+
7
50
  ## [0.4.17] - 2026-11-08
8
51
 
9
52
  ### Fixed
package/README.md CHANGED
@@ -10,8 +10,8 @@
10
10
  </p>
11
11
 
12
12
  <picture>
13
- <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.17/hero-dark.png">
14
- <img src="docs/images/0.4.17/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
13
+ <source media="(prefers-color-scheme: dark)" srcset="docs/images/0.4.18/hero-dark.png">
14
+ <img src="docs/images/0.4.18/hero-light.png" width="100%" alt="Den's Graph page: the demo workspace's services grouped by application, all healthy, with the status chip and the left rail">
15
15
  </picture>
16
16
 
17
17
  ## Set up by your agent
@@ -38,7 +38,7 @@ It doesn't replace your tools. Your repos, Docker, your test runners, your secre
38
38
  yours. Den connects them and turns on good practice from the start.
39
39
 
40
40
  <p align="center">
41
- <img src="docs/images/0.4.17/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
41
+ <img src="docs/images/0.4.18/up.gif" width="100%" alt="make up in the Graph page: every stack down, then demo-api, demo-web and demo-notes come up in dependency order until the status chip reads All 3 healthy">
42
42
  </p>
43
43
 
44
44
  ## How it helps
@@ -68,10 +68,10 @@ yours. Den connects them and turns on good practice from the start.
68
68
  lives in your own repo.
69
69
 
70
70
  <p align="center">
71
- <img src="docs/images/0.4.17/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
72
- <img src="docs/images/0.4.17/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
73
- <img src="docs/images/0.4.17/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
74
- <img src="docs/images/0.4.17/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
71
+ <img src="docs/images/0.4.18/agents.png" width="49%" alt="The Agents tab: the demo workspace's sessions on the left, and one open in a terminal pane on the right, running demo-api's test and then the service">
72
+ <img src="docs/images/0.4.18/workspaces.png" width="49%" alt="The Workspaces page: the demo workspace's repos with their health, mode and Start, Stop, Restart and Logs actions">
73
+ <img src="docs/images/0.4.18/status-drawer.png" width="49%" alt="The status drawer: nothing needs attention, and every service in the demo workspace, by application, with its latency">
74
+ <img src="docs/images/0.4.18/edge.png" width="49%" alt="The Edge page: the edge container up, its ports and TLS, and every route it serves, Den's own and each stack's, with its upstream">
75
75
  </p>
76
76
 
77
77
  ## Get started
@@ -368,6 +368,39 @@ The ☆ star on Workspaces rows, Services and Graph cards marks a
368
368
  favourite. It is one list, saved in `shell.pins`, so a star set on one
369
369
  page shows on all of them.
370
370
 
371
+ ### The service panel
372
+
373
+ A pinned service in the rail has **↗** at the end of its row, always
374
+ shown: it opens the service's URL in a new tab ("Visit <service> (opens a
375
+ new tab)", the URL as its tooltip). It is greyed out, saying "Not
376
+ running", while the service is down, and absent when the service has no
377
+ URL.
378
+
379
+ A click on the service's name (or Enter on its row, or its dot when the
380
+ rail is collapsed) opens its **panel** beside the row, without leaving
381
+ the page. ⌘-click, Ctrl-click or a middle click still opens Services in a
382
+ new tab. Esc or a click outside closes it, and focus goes back to the
383
+ row. Under 640 px wide it is a sheet at the bottom of the screen. The
384
+ Services card's name and the Graph card's **⋯** open the same panel.
385
+
386
+ Top to bottom, it has:
387
+
388
+ - the name, workspace, health dot, state ("up 2h", "down: HTTP 502",
389
+ "starting…") and URL;
390
+ - **Visit ↗**, then **Start** or **Stop**, **Restart** and **Logs**. These
391
+ call what the Services page calls (`/api/service/exec` with `up`,
392
+ `down` or `restart`, and `/api/service/logs`), each in a tab of the runs
393
+ dock. Logs opens the dock on that service;
394
+ - **Last test run** (the server's run history, `GET /api/runs/last`) and
395
+ **Run tests**, which runs the repo's suites (`/api/run?pillar=suites`);
396
+ - **Agents**: the agents working in the repo, each with **Open**, and
397
+ **+ Start agent here**, which opens the Start dialog with the workspace
398
+ and the repo's folder filled in;
399
+ - **Open in Services**, **Open in Graph** and **Unpin** (or **Pin**).
400
+
401
+ It stays current while it is open, from the same feeds the pages use. Delete
402
+ on a pinned row still unpins it.
403
+
371
404
  ## Health checks
372
405
 
373
406
  A service declares its health per environment under `health`, in one of
@@ -2398,6 +2431,11 @@ started. The workspace header counts them ("2 agents").
2398
2431
  `agents.runtimes.<id>.bin` to its path in <your user.yml>, or run
2399
2432
  `<cli> agents doctor`" (see [Runtimes](#runtimes)). Nothing fails only
2400
2433
  after Start.
2434
+ - **Folder** (optional) is where the agent runs: a folder inside the
2435
+ workspace's own, or a repo's folder of the workspace (relative to the
2436
+ workspace's folder, or absolute). Empty, it runs in the workspace's
2437
+ folder. A folder outside the workspace is refused (403). The service
2438
+ panel's **Start agent here** fills it with the repo's folder.
2401
2439
  - The page and the CLI call the same operations and read the same agent
2402
2440
  records, so an agent started in one is listed in the other. A workspace
2403
2441
  lists its own agents only.
@@ -2412,7 +2450,7 @@ started. The workspace header counts them ("2 agents").
2412
2450
 
2413
2451
  The routes: `GET /api/workspaces/<ws>/agents`, `POST
2414
2452
  /api/workspaces/<ws>/agents` (`{ persona, runtime?, model?, version?,
2415
- permission?, allow?, instruction?, id? }`, see
2453
+ permission?, allow?, instruction?, id?, folder? }`, see
2416
2454
  [The Start dialog](#the-start-dialog)), `POST
2417
2455
  /api/workspaces/<ws>/agents/<id>/stop` and `…/resume`, `POST
2418
2456
  /api/workspaces/<ws>/sessions` (a plain session), `GET /api/personas`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.17",
3
+ "version": "0.4.18",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1409,6 +1409,12 @@ export interface StartRequest {
1409
1409
  allow?: unknown;
1410
1410
  /** AG-10.10: a display name for this agent (AG-4b's), to tell two apart. */
1411
1411
  name?: unknown;
1412
+ /**
1413
+ * 0.4.18, Start agent here: the folder it runs in, the workspace's own
1414
+ * or one inside it (a repo's), relative to the workspace's folder or
1415
+ * absolute. Default: the workspace's folder.
1416
+ */
1417
+ folder?: string;
1412
1418
  /**
1413
1419
  * The page's start (AG-10.1): a model must be one the runtime offers,
1414
1420
  * never a free string (the CLI's --model for generic stays free).
@@ -1524,6 +1530,39 @@ interface StartMore {
1524
1530
  bringIn?: { token: string; cwd: string };
1525
1531
  }
1526
1532
 
1533
+ /**
1534
+ * 0.4.18: the folder a start runs in. Relative to the workspace's folder,
1535
+ * or absolute; it must be a directory inside the workspace's scope (its
1536
+ * folder, or a by-path repo's), never outside it.
1537
+ */
1538
+ export function startFolder(
1539
+ d: AgentsResolved,
1540
+ ws: string,
1541
+ folder: string,
1542
+ ): string {
1543
+ const where = "agents start";
1544
+ if (typeof folder !== "string" || !folder.trim() || folder.length > 4096)
1545
+ throw usage(`${where}: folder must be a path`);
1546
+ const dir = path.resolve(d.workspaceDir(ws), folder.trim());
1547
+ let real: string;
1548
+ try {
1549
+ real = fs.realpathSync(dir);
1550
+ } catch {
1551
+ throw usage(`${where}: folder ${dir} does not exist`, 404);
1552
+ }
1553
+ if (!fs.statSync(real).isDirectory())
1554
+ throw usage(`${where}: folder ${dir} is not a directory`);
1555
+ // The real folder only: a link inside the workspace that points out of
1556
+ // it is outside it (the scope's own folders are compared real, too).
1557
+ if (!inScope(real, d.workspaceFolders(ws)))
1558
+ throw refused(
1559
+ `${where}: folder ${dir} is not inside workspace ${ws}`,
1560
+ [],
1561
+ 403,
1562
+ );
1563
+ return real;
1564
+ }
1565
+
1527
1566
  export async function startAgent(
1528
1567
  d: AgentsResolved,
1529
1568
  req: StartRequest,
@@ -1571,6 +1610,10 @@ export async function startAgent(
1571
1610
  ...(rt.permissions ? { allow } : {}),
1572
1611
  }),
1573
1612
  ...(more.firstPrompt ? { firstPrompt: more.firstPrompt } : {}),
1613
+ // 0.4.18: Start agent here: in a folder of the workspace's.
1614
+ ...(req.folder !== undefined && !more.bringIn
1615
+ ? { worktree: startFolder(d, ws, req.folder) }
1616
+ : {}),
1574
1617
  // AG-17.1: the runtime's resume, in the folder the conversation ran in.
1575
1618
  ...(more.bringIn
1576
1619
  ? { worktree: more.bringIn.cwd, resumeToken: more.bringIn.token }
@@ -315,6 +315,8 @@ interface StartBody {
315
315
  instruction?: unknown;
316
316
  /** AG-10.10: a display name, checked as a rename's is. */
317
317
  name?: unknown;
318
+ /** 0.4.18: the folder it runs in (Start agent here). */
319
+ folder?: string;
318
320
  }
319
321
 
320
322
  const START_KEYS = [
@@ -327,6 +329,7 @@ const START_KEYS = [
327
329
  "allow",
328
330
  "instruction",
329
331
  "name",
332
+ "folder",
330
333
  ];
331
334
 
332
335
  async function startBody(req: http.IncomingMessage): Promise<StartBody> {
@@ -374,6 +377,11 @@ async function startBody(req: http.IncomingMessage): Promise<StartBody> {
374
377
  if (o.instruction !== undefined) out.instruction = o.instruction;
375
378
  if (o.name !== undefined && o.name !== null && o.name !== "")
376
379
  out.name = o.name;
380
+ if (o.folder !== undefined && o.folder !== null && o.folder !== "") {
381
+ if (typeof o.folder !== "string" || o.folder.length > 4096)
382
+ throw new AgentError("folder must be a path", { usage: true });
383
+ out.folder = o.folder;
384
+ }
377
385
  return out as StartBody;
378
386
  }
379
387
 
@@ -585,6 +593,7 @@ export async function handleAgentRoutes(
585
593
  : {}),
586
594
  ...(body.allow !== undefined ? { allow: body.allow } : {}),
587
595
  ...(body.name !== undefined ? { name: body.name } : {}),
596
+ ...(body.folder !== undefined ? { folder: body.folder } : {}),
588
597
  strict: true,
589
598
  });
590
599
  const ov = s.rec.overrides?.length
@@ -203,6 +203,7 @@ function writeUrl() {
203
203
  else u.searchParams.delete("open");
204
204
  u.searchParams.set("layout", st.layout);
205
205
  u.searchParams.delete("start");
206
+ u.searchParams.delete("folder");
206
207
  const s = u.searchParams.toString().replace(/%2C/gi, ",");
207
208
  history.replaceState(history.state, "", u.pathname + (s ? `?${s}` : ""));
208
209
  paintSub();
@@ -2101,8 +2102,16 @@ function dialogMode(bring, change = null) {
2101
2102
  );
2102
2103
  $d("#ag-go").textContent = goLabel();
2103
2104
  $d("#ag-conv-f").hidden = !bring;
2105
+ // 0.4.18: a new agent's folder (Start agent here fills it).
2106
+ for (const id of ["#ag-folder-l", "#ag-folder-h"]) {
2107
+ const el = $d(id);
2108
+ if (el) el.hidden = !!(bring || change);
2109
+ }
2104
2110
  }
2105
2111
 
2112
+ /** 0.4.18: Start agent here (the service panel): ?start=1&ws=…&folder=…. */
2113
+ let startPrefill = null;
2114
+
2106
2115
  /** The submit button's words for the dialog's mode. */
2107
2116
  function goLabel() {
2108
2117
  return t(sd.change ? "ag.pc.go" : sd.bring ? "ag.bring.go" : "ag.go");
@@ -2124,12 +2133,18 @@ async function openStart(bring = false, change = null) {
2124
2133
  const dlg = $d("#ag-dlg");
2125
2134
  if (!dlg || dlg.open) return;
2126
2135
  dialogMode(bring, change);
2136
+ const pre = change || bring ? null : startPrefill;
2137
+ startPrefill = null;
2127
2138
  const shown = change ? [change.workspace] : viewWorkspaces();
2139
+ if (pre?.ws && !shown.includes(pre.ws)) shown.push(pre.ws);
2128
2140
  const wsSel = $d("#ag-ws");
2129
2141
  wsSel.innerHTML = shown
2130
2142
  .map((w) => `<option value="${esc(w)}">${esc(w)}</option>`)
2131
2143
  .join("");
2132
2144
  wsSel.disabled = shown.length <= 1;
2145
+ if (pre?.ws) wsSel.value = pre.ws;
2146
+ const folder = $d("#ag-folder");
2147
+ if (folder) folder.value = pre?.folder ?? "";
2133
2148
  sd.f = { q: "", cat: "", src: "" };
2134
2149
  sd.det = false;
2135
2150
  sd.what = false;
@@ -2170,6 +2185,8 @@ function startBody() {
2170
2185
  if (task) body.instruction = task;
2171
2186
  const name = $d("#ag-name").value.trim();
2172
2187
  if (name) body.name = name;
2188
+ const folder = $d("#ag-folder")?.value.trim();
2189
+ if (folder) body.folder = folder;
2173
2190
  return body;
2174
2191
  }
2175
2192
 
@@ -2628,6 +2645,8 @@ function translateStatic(root) {
2628
2645
  set("#ag-what-p", "pe.what");
2629
2646
  set("#ag-task-t", "ag.new.task");
2630
2647
  set("#ag-name-t", "ag.new.name");
2648
+ set("#ag-folder-t", "ag.new.folder");
2649
+ set("#ag-folder-h", "ag.new.folderHelp");
2631
2650
  set("#ag-adv-s", "ag.new.adv");
2632
2651
  set("#ag-note", "ag.new.note");
2633
2652
  const task = root.querySelector("#ag-task");
@@ -2659,6 +2678,10 @@ export async function mountAgentsPage(root) {
2659
2678
  const wanted = openFromUrl(location.search);
2660
2679
  const linked = layoutFromUrl(location.search);
2661
2680
  const startAsked = new URLSearchParams(location.search).get("start") === "1";
2681
+ if (startAsked) {
2682
+ const q = new URLSearchParams(location.search);
2683
+ startPrefill = { ws: q.get("ws") || "", folder: q.get("folder") || "" };
2684
+ }
2662
2685
 
2663
2686
  root.querySelector("#agl").addEventListener("click", (e) => {
2664
2687
  const act = e.target.closest("button[data-act]");
@@ -739,6 +739,19 @@
739
739
  ><span id="ag-name-t">Name (optional)</span>
740
740
  <input id="ag-name" maxlength="40" autocomplete="off" />
741
741
  </label>
742
+ <label class="fld" id="ag-folder-l"
743
+ ><span id="ag-folder-t">Folder (optional)</span>
744
+ <input
745
+ id="ag-folder"
746
+ maxlength="4096"
747
+ autocomplete="off"
748
+ spellcheck="false"
749
+ aria-describedby="ag-folder-h"
750
+ />
751
+ </label>
752
+ <p class="helpl" id="ag-folder-h">
753
+ Inside the workspace's folder. Empty: the workspace's own.
754
+ </p>
742
755
  <p class="helpl" id="ag-as" role="status"></p>
743
756
  <details id="ag-adv">
744
757
  <summary id="ag-adv-s">Advanced: model and permissions</summary>
@@ -349,6 +349,28 @@
349
349
  color: var(--accent-text);
350
350
  background: var(--sunken);
351
351
  }
352
+ /* 0.4.18: ⋯ opens the service panel (svc-panel.js), as a pinned row does.
353
+ It sits before the star, which stays top-right. */
354
+ .dg-svp {
355
+ margin-left: auto;
356
+ flex: none;
357
+ background: none;
358
+ border: 1px solid transparent;
359
+ border-radius: 6px;
360
+ color: var(--faint);
361
+ cursor: pointer;
362
+ font-size: 13px;
363
+ line-height: 1;
364
+ padding: 1px 4px;
365
+ }
366
+ .dg-svp + .dg-star {
367
+ margin-left: 0;
368
+ }
369
+ .dg-svp:hover,
370
+ .dg-svp[aria-expanded="true"] {
371
+ color: var(--accent-text);
372
+ background: var(--sunken);
373
+ }
352
374
  .dg-dot {
353
375
  display: inline-block;
354
376
  width: 9px;
@@ -256,9 +256,26 @@ export function mountGraph(el, data, pageHooks) {
256
256
  st.fitted = true;
257
257
  } else applyView();
258
258
  if (st.sel) openDetail(st.sel, true);
259
+ focusFromHash();
259
260
  });
260
261
  }
261
262
 
263
+ /** 0.4.18: the panel's Open in Graph (#focus=<repo> or <repo>/<service>), once. */
264
+ let hashFocused = false;
265
+ function focusFromHash() {
266
+ if (hashFocused || !location.hash.startsWith("#focus=")) return;
267
+ let id = "";
268
+ try {
269
+ id = decodeURIComponent(location.hash.slice(7));
270
+ } catch {
271
+ return;
272
+ }
273
+ if (M.byId.has(id)) focusOn({ id });
274
+ else if (M.byRepo.has(id)) focusOn({ repo: id, viaRepo: true });
275
+ else return;
276
+ hashFocused = true;
277
+ }
278
+
262
279
  /** A /api/health update: refresh dots, health lines and the open panel in place. */
263
280
  export function graphHealth(results) {
264
281
  if (!M || !host?.isConnected) return;
@@ -511,6 +528,13 @@ function starBtn(id) {
511
528
  : `${on ? "Unpin" : "Pin"} ${id}`;
512
529
  return `<button type="button" class="dg-star" data-pin-toggle="${esc(id)}" aria-pressed="${on}" aria-label="${esc(label)}" title="${esc(label)}">${on ? "★" : "☆"}</button>`;
513
530
  }
531
+ /** 0.4.18: ⋯ opens the service's quick panel (svc-panel.js), the rail's. */
532
+ function svpBtn(id) {
533
+ const sh = window.denShell;
534
+ if (!sh?.openServicePanel) return "";
535
+ const label = sh.t ? sh.t("svp.more", { s: id }) : id;
536
+ return `<button type="button" class="dg-svp" data-svpanel-open="${esc(id)}" aria-haspopup="dialog" aria-expanded="false" aria-label="${esc(label)}" title="${esc(label)}">⋯</button>`;
537
+ }
514
538
  /** A card's actions: the first three, and Visit ↗ always when it has a URL. */
515
539
  function cardActs(s) {
516
540
  const acts = actionsFor(s);
@@ -522,7 +546,7 @@ function cardActs(s) {
522
546
  }
523
547
  function cardInner(s) {
524
548
  const showPlace = s.runs === "remote" || M.mixed;
525
- return `<div class="r1"><span class="dg-dot ${status(s)}" title="${statusWord(s)}"></span><span class="nm" title="${esc(s.name)}">${esc(s.name)}</span>${showPlace ? `<span class="dg-badge ${s.runs}">${esc(placementText(s))}</span>` : ""}${starBtn(s.id)}</div>
549
+ return `<div class="r1"><span class="dg-dot ${status(s)}" title="${statusWord(s)}"></span><span class="nm" title="${esc(s.name)}">${esc(s.name)}</span>${showPlace ? `<span class="dg-badge ${s.runs}">${esc(placementText(s))}</span>` : ""}${svpBtn(s.id)}${starBtn(s.id)}</div>
526
550
  <div class="r2" title="${esc(s.id)}${s.inWorkspaces.length > 1 ? `: in ${esc(s.inWorkspaces.join(", "))}` : ""}">${esc(s.repo + ownersLabel(s.inWorkspaces))}</div>
527
551
  <div class="r3">${s.entry ? '<span class="dg-tag entry">entry</span>' : ""}${s.infra ? '<span class="dg-tag">infra</span>' : ""}${s.missing.length ? `<span class="dg-tag warn" title="Missing: ${esc(s.missing.join(", "))}">⚠ missing</span>` : ""}${!s.cloned ? '<span class="dg-tag warn">not cloned</span>' : ""}<span class="h ${status(s)}">${esc(healthLine(s))}</span></div>
528
552
  <div class="dg-acts">${cardActs(s)
@@ -17,6 +17,7 @@ import { changelogView } from "./changelog-view.js";
17
17
  import { buildRow, healthCycle, paintGate, shellHealth } from "./health-fit.js";
18
18
  import { initTopbar, paintChecking, paintStatus } from "./topbar.js";
19
19
  import { registerDenSettings } from "./settings-registry.js";
20
+ import { initServicePanel } from "./svc-panel.js";
20
21
 
21
22
  /** The shell's message lookup (R19; shell.js MESSAGES). */
22
23
  const t = (key, vars) =>
@@ -54,6 +55,12 @@ if (typeof document !== "undefined")
54
55
  healthEnv: () => healthEnv(),
55
56
  storeOpenLog: (repo, service, env) => storeOpenLog(repo, service, env),
56
57
  });
58
+ // 0.4.18: the service panel (rail pins, Graph ⋯, Services names).
59
+ if (typeof document !== "undefined")
60
+ initServicePanel({
61
+ currentEnv: () => currentEnv(),
62
+ refreshHealth: (env, opts) => refreshHealth(env, opts),
63
+ });
57
64
 
58
65
  // 5s between polls — measured from each poll's COMPLETION (schedulePoll
59
66
  // re-arms after renderHealth resolves), so slow probes can't overlap.
@@ -55,6 +55,41 @@
55
55
  "act.unpin": "Quitar",
56
56
  "act.visit": "Visitar",
57
57
  "act.visitX": "Visitar {s} (se abre en una pestaña nueva)",
58
+ // 0.4.18: the service panel (svc-panel.js) and Start's Folder.
59
+ "svp.label": "{s}: panel rápido",
60
+ "svp.more": "Panel rápido de {s}",
61
+ "svp.notRunning": "No está en marcha",
62
+ "svp.up": "activo {d}",
63
+ "svp.upNow": "activo",
64
+ "svp.down": "caído: {c}",
65
+ "svp.downNone": "caído",
66
+ "svp.starting": "iniciando…",
67
+ "svp.stopped": "detenido",
68
+ "svp.noUrl": "Sin URL",
69
+ "svp.start": "Iniciar",
70
+ "svp.stop": "Detener",
71
+ "svp.restart": "Reiniciar",
72
+ "svp.logs": "Registros",
73
+ "svp.stopping": "Deteniendo {s}",
74
+ "svp.tests": "Última ejecución de pruebas",
75
+ "svp.testsOk": "✓ {t}",
76
+ "svp.testsBad": "✗ falló {t}",
77
+ "svp.testsRun": "en curso…",
78
+ "svp.testsNone": "—",
79
+ "svp.runTests": "Ejecutar pruebas",
80
+ "svp.testsDone": "Pruebas superadas: {s}",
81
+ "svp.testsFailed": "Pruebas fallidas: {s}",
82
+ "svp.agents": "Agentes",
83
+ "svp.noAgents": "No hay agentes en este repo.",
84
+ "svp.open": "Abrir",
85
+ "svp.openAg": "Abrir {s}",
86
+ "svp.startHere": "Iniciar agente aquí",
87
+ "svp.openSvc": "Abrir en Servicios",
88
+ "svp.openGraph": "Abrir en Grafo",
89
+ "svp.close": "Cerrar",
90
+ "ag.new.folder": "Carpeta (opcional)",
91
+ "ag.new.folderHelp":
92
+ "Dentro de la carpeta del espacio de trabajo. Vacío: la suya.",
58
93
  "act.pinX": "Fijar {s}",
59
94
  "act.unpinX": "Quitar {s}",
60
95
  "toast.pinned": "{s} fijado",
@@ -52,6 +52,41 @@
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
+ "svp.close": "閉じる",
87
+ "ag.new.folder": "フォルダー(任意)",
88
+ "ag.new.folderHelp":
89
+ "ワークスペースのフォルダー内。空欄ならワークスペースのフォルダー。",
55
90
  "act.pinX": "{s} をピン留め",
56
91
  "act.unpinX": "{s} のピン留めを外す",
57
92
  "toast.pinned": "{s} をピン留めしました",
@@ -1874,7 +1874,12 @@
1874
1874
  if (updateUrl) {
1875
1875
  const params = new URLSearchParams(location.search);
1876
1876
  params.set("ws", name);
1877
- history.replaceState(null, "", "?" + params.toString());
1877
+ // The hash stays (#focus=<id>: Open in Graph's card).
1878
+ history.replaceState(
1879
+ null,
1880
+ "",
1881
+ "?" + params.toString() + location.hash,
1882
+ );
1878
1883
  }
1879
1884
  document
1880
1885
  .querySelectorAll(".ws-item")
@@ -4273,7 +4278,11 @@
4273
4278
  const params = new URLSearchParams(location.search);
4274
4279
  params.delete("ws");
4275
4280
  const qs = params.toString();
4276
- history.replaceState(null, "", qs ? "?" + qs : location.pathname);
4281
+ history.replaceState(
4282
+ null,
4283
+ "",
4284
+ (qs ? "?" + qs : location.pathname) + location.hash,
4285
+ );
4277
4286
  }
4278
4287
  await loadWorkspaces();
4279
4288
  }
@@ -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);