@monster-orange/den 0.4.16 → 0.4.17

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,64 @@ 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.17] - 2026-11-08
8
+
9
+ ### Fixed
10
+
11
+ - **An agent's workspace is a link.** The agent pane's header and the
12
+ workspace's heading in the Agents list link to the Workspaces page
13
+ focused on it (`?ws=`). ⌘K has "Go to workspace <ws>" for every
14
+ workspace, the front agent's first on the Agents page, found by the
15
+ workspace's name or any of its agents'.
16
+ - **"Last test run" is the server's.** The Workspaces page's "Last run"
17
+ column is now "Last test run", read from the server's run history
18
+ (`<state>/run-history.jsonl`, `GET /api/runs/last`). It shows runs from
19
+ every browser and survives a restart. The service's own state ("up 2h")
20
+ is shown apart from it.
21
+ - **One org rule.** Environments and Releases have no org picker of their
22
+ own. Both follow the top bar's View and say so with the same note. A
23
+ page shows its own org picker only when it filters by org.
24
+ - **Environments never waits on a slow host.** Every row shows at once,
25
+ pulsing "checking", and each result streams into its row. The server
26
+ runs at most 12 checks at once, each capped at 10 s; one that runs out
27
+ of time says "timed out after 10 s". Re-check works while checks run.
28
+ - **An agent in an inactive workspace opens its terminal.** The terminal
29
+ route checks the person and the agent, not the workspace's active
30
+ flag. The pane says "Sign in" only when you're signed out; any other
31
+ refusal gives its real reason.
32
+ - **Menus are never clipped.** Every ⋯ menu in the shell (Workspaces
33
+ rows and the workspace menu, the top bar's menus) now opens in the
34
+ browser's top layer through one shared helper, so a scrolling table or
35
+ card no longer cuts it off. It stays beside its button, flips up near
36
+ the bottom of the window, and closes on an outside click as before.
37
+ - **The Tests module picker is never clipped.** It uses the same
38
+ top-layer menu.
39
+ - **Visit ↗ opens a service.** Workspaces (beside Start, Stop, Restart
40
+ and Logs, and as a link on the service's name), Services and Graph
41
+ cards open the service's edge URL when it is routed, else its local
42
+ URL, in a new tab. A service with no URL has no Visit. Below 1600 px
43
+ wide the Workspaces button shows only "↗". The detail panel's "Open ↗"
44
+ is now "Visit ↗".
45
+ - **Favourite a service from Workspaces and Graph.** Workspaces rows and
46
+ Graph cards (top-right) have the ☆ star. It uses the same list as
47
+ Services, so a favourite set on one page shows on all of them.
48
+ - **Change persona on an agent that was never prompted** said "same
49
+ conversation", though there was nothing to resume: it starts fresh under
50
+ the same conversation id, as Resume does. The CLI, the API (`note`, with
51
+ `fresh: true`), the toast and the pane now say "nothing to resume yet;
52
+ started fresh (same conversation id, folder and scope)".
53
+ - **Last test run counts a repo's own suites.** A run of the suites pillar
54
+ (a repo's `tests:`, such as `unit`) and a repo's performance modules
55
+ now name the repos they test, so a repo with only `tests: unit` fills
56
+ its Last test run. Den's own integration tests name none.
57
+ - **The Workspaces table keeps its height in a short window.** Under about
58
+ 620 px tall it shrank to nothing and its rows and ⋯ were out of reach.
59
+ It keeps at least 200 px and scrolls.
60
+
61
+ ### Migrations
62
+
63
+ None.
64
+
7
65
  ## [0.4.16] - 2026-11-08
8
66
 
9
67
  ### Added
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.16/hero-dark.png">
14
- <img src="docs/images/0.4.16/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.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">
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.16/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.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">
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.16/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.16/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.16/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.16/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.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">
75
75
  </p>
76
76
 
77
77
  ## Get started
@@ -355,6 +355,19 @@ is never mistaken for another repo's service. An alias that names more than
355
355
  one service is an error that lists them (`"worker" is ambiguous in workspace
356
356
  "default": one/worker, two/worker`); name one of them.
357
357
 
358
+ ## Opening a service
359
+
360
+ A service with a URL gets **Visit ↗**, which opens it in a new tab. It
361
+ shows on Workspaces (beside Start, Stop, Restart and Logs, and as a link
362
+ on the service's name), on Services and on Graph cards. Den picks the
363
+ URL in this order: the service's `urls.<env>`; for `local`, its edge
364
+ route when the edge serves one; else the origin of its HTTP health
365
+ check. A service with none of these has no Visit.
366
+
367
+ The ☆ star on Workspaces rows, Services and Graph cards marks a
368
+ favourite. It is one list, saved in `shell.pins`, so a star set on one
369
+ page shows on all of them.
370
+
358
371
  ## Health checks
359
372
 
360
373
  A service declares its health per environment under `health`, in one of
@@ -432,6 +445,36 @@ that a container existed, running or not).
432
445
  `service`, with both forms at once, or with a key of another type (`cmd`,
433
446
  `url`), and says which.
434
447
 
448
+ ### The Environments page
449
+
450
+ The Environments page lists every service's row at once, each cell pulsing
451
+ "checking" until its result arrives. The results stream in one at a time
452
+ (`GET /api/health/stream`, one event per check), so a slow host holds up
453
+ its own cell only. The server runs at most 12 checks at once, and no check
454
+ takes longer than 10 s. A check that runs out of time says "timed out after
455
+ 10 s" in its row. Re-check and the page's other controls work while the
456
+ checks run.
457
+
458
+ The page has no org picker of its own. It follows the top bar's View (all
459
+ active workspaces, or one), asks for that workspace only, and says so:
460
+ "Shows workspace south, as the top bar's View. Change it there." The
461
+ Release page follows the same rule with the same note. A page shows its own
462
+ org picker only when it filters by org.
463
+
464
+ ### Last test run
465
+
466
+ The Workspaces page's "Last test run" column shows each repo's latest test
467
+ run: its result (✓ or ✗), how long ago, and the pillar and environment on
468
+ hover. It reads the server's run history, so it shows runs started from any
469
+ browser or tab, and it survives a restart. The service's own state ("up 2h")
470
+ is shown apart from it, in the Health cell.
471
+
472
+ The history is `<state>/run-history.jsonl` (mode 0600): one line when a run
473
+ starts and one when it ends. Past 2000 lines it keeps the newest 1000.
474
+ `GET /api/runs/last` (read only) gives `{ repos: { <repo>: { id, pillar,
475
+ env, repos, startedAt, endedAt, exitCode, running } } }`. A run a restart
476
+ cut off has no end and is not shown.
477
+
435
478
  ## Placement
436
479
 
437
480
  Each service says where it runs, per environment: `local` (on this machine,
@@ -2552,6 +2595,18 @@ text frame `{"type":"resize","cols":c,"rows":r}` resizes, and the server
2552
2595
  sends `{"type":"session",…}` first and `{"type":"exit","exitCode":n}` when
2553
2596
  the session ends.
2554
2597
 
2598
+ The terminal route checks the person and the agent, not whether the
2599
+ agent's workspace is active: an agent in a workspace you've switched away
2600
+ from still opens. A `GET` of the io path (not a WebSocket) says why one
2601
+ would be refused: `401` when you're signed out (the pane then says "Sign
2602
+ in"), `404` with the reason when there is no such session here, and `426`
2603
+ when the upgrade would be allowed. The pane shows that reason, not "Sign
2604
+ in", for any refusal but the first.
2605
+
2606
+ The pane's header and the workspace's heading in the list link to the
2607
+ Workspaces page focused on that workspace (`/local-dev?ws=<ws>`), and ⌘K
2608
+ has "Go to workspace <ws>" for each workspace with agents.
2609
+
2555
2610
  ### Stopped agents: Resume, Start fresh, Remove
2556
2611
 
2557
2612
  An agent that stops stays in the list, below the running ones (AG-14, the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monster-orange/den",
3
- "version": "0.4.16",
3
+ "version": "0.4.17",
4
4
  "description": "Den: a local multi-repo dev orchestrator, HTTPS edge and E2E harness",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2212,6 +2212,13 @@ export interface PersonaChanged extends Resumed {
2212
2212
  from: string;
2213
2213
  }
2214
2214
 
2215
+ /**
2216
+ * What a resume with nothing to continue yet says (AG-14, AG-18): the
2217
+ * runtime started fresh under the same conversation id.
2218
+ */
2219
+ export const FRESH_NOTE =
2220
+ "nothing to resume yet; started fresh (same conversation id, folder and scope)";
2221
+
2215
2222
  /** How often, and how long, "after this turn" checks its turn (AG-18). */
2216
2223
  export const TURN_POLL_MS = 1_000;
2217
2224
  export const TURN_WAIT_MS = 30 * 60_000;
@@ -95,6 +95,7 @@ import {
95
95
  type AgentsDeps,
96
96
  type AgentsResolved,
97
97
  type EarlyExit,
98
+ FRESH_NOTE,
98
99
  type RuntimeChoice,
99
100
  type Started,
100
101
  } from "./agent-ops.js";
@@ -420,7 +421,9 @@ async function persona(args: string[], d: AgentsResolved): Promise<number> {
420
421
  return report(
421
422
  d,
422
423
  s,
423
- `agent ${s.rec.id} is now ${s.rec.persona} (was ${s.from}); same conversation, as ${s.rec.sessionName} in ${s.rec.cwd}`,
424
+ s.fresh
425
+ ? `agent ${s.rec.id} is now ${s.rec.persona} (was ${s.from}); ${FRESH_NOTE}, as ${s.rec.sessionName} in ${s.rec.cwd}`
426
+ : `agent ${s.rec.id} is now ${s.rec.persona} (was ${s.from}); same conversation, as ${s.rec.sessionName} in ${s.rec.cwd}`,
424
427
  );
425
428
  }
426
429
 
@@ -468,10 +471,7 @@ async function resume(args: string[], d: AgentsResolved): Promise<number> {
468
471
  if (args.includes("--all-interrupted")) return resumeAll(args, d);
469
472
  const { id, ws } = idAndWorkspace("agents resume", args);
470
473
  const s = await resumeAgent(d, id, ws);
471
- if (s.fresh)
472
- d.out(
473
- `agent ${s.rec.id}: nothing to resume yet; started fresh (same conversation id, folder and scope)`,
474
- );
474
+ if (s.fresh) d.out(`agent ${s.rec.id}: ${FRESH_NOTE}`);
475
475
  return report(
476
476
  d,
477
477
  s,
@@ -140,6 +140,7 @@ import {
140
140
  type AgentsDeps,
141
141
  type AgentsResolved,
142
142
  type Started,
143
+ FRESH_NOTE,
143
144
  } from "../cli/agent-ops.js";
144
145
  import { agentSessionName } from "../../shared/agents/session-name.js";
145
146
  import { recordAction } from "./audit.js";
@@ -714,7 +715,9 @@ export async function handleAgentRoutes(
714
715
  }
715
716
  const s = await change();
716
717
  audit(what, s.early ? "exited at once" : undefined);
717
- sendStarted(res, s, d);
718
+ // Never prompted: nothing was resumed, so it says so (not "same
719
+ // conversation").
720
+ sendStarted(res, s, d, s.fresh ? { note: FRESH_NOTE } : {});
718
721
  } else if (verb === "remove") {
719
722
  const removed = await removeAgent(d, id!, ws);
720
723
  audit(what);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * How health checks run (0.4.17): at most CHECK_CONCURRENCY at once, each
3
+ * given at most CHECK_TIMEOUT_MS. A check that runs over its time gets the
4
+ * caller's stand-in result ("timed out after 10 s") rather than holding up
5
+ * the run, and the run never waits on it again.
6
+ */
7
+
8
+ /** The most checks in flight at once. */
9
+ export const CHECK_CONCURRENCY = 12;
10
+ /** The longest one check may take, whatever its kind. */
11
+ export const CHECK_TIMEOUT_MS = 10_000;
12
+
13
+ /** What a check that ran out of time says. */
14
+ export function timedOutText(ms: number): string {
15
+ return `timed out after ${Math.round(ms / 1000)} s`;
16
+ }
17
+
18
+ /**
19
+ * `p`, or `late()` once `ms` passes first. A late `p` that later settles
20
+ * is ignored, and so is its rejection.
21
+ */
22
+ export function withTimeout<T>(
23
+ p: Promise<T>,
24
+ ms: number,
25
+ late: () => T,
26
+ ): Promise<T> {
27
+ return new Promise<T>((resolve, reject) => {
28
+ let done = false;
29
+ const timer = setTimeout(() => {
30
+ if (done) return;
31
+ done = true;
32
+ resolve(late());
33
+ }, ms);
34
+ timer.unref?.();
35
+ p.then(
36
+ (v) => {
37
+ if (done) return;
38
+ done = true;
39
+ clearTimeout(timer);
40
+ resolve(v);
41
+ },
42
+ (e: unknown) => {
43
+ if (done) return;
44
+ done = true;
45
+ clearTimeout(timer);
46
+ reject(e);
47
+ },
48
+ );
49
+ });
50
+ }
51
+
52
+ /**
53
+ * `fn` over `items`, at most `limit` at a time, the results in the items'
54
+ * order. Each starts as soon as a slot frees up.
55
+ */
56
+ export async function runCapped<T, R>(
57
+ items: readonly T[],
58
+ limit: number,
59
+ fn: (item: T, index: number) => Promise<R>,
60
+ ): Promise<R[]> {
61
+ const out = new Array<R>(items.length);
62
+ let next = 0;
63
+ const worker = async (): Promise<void> => {
64
+ while (next < items.length) {
65
+ const i = next++;
66
+ out[i] = await fn(items[i], i);
67
+ }
68
+ };
69
+ const n = Math.max(1, Math.min(limit, items.length));
70
+ await Promise.all(Array.from({ length: n }, worker));
71
+ return out;
72
+ }
@@ -31,6 +31,8 @@ import { moduleScanner } from "../../shared/test-discovery/module-scanner.js";
31
31
  import { resolveFieldEnv } from "../../shared/perf/optional-fields.js";
32
32
  import { testRunner } from "../cli/test-runner.js";
33
33
  import { discoverRepos } from "../../pillars/smoke/discover.js";
34
+ import { planSuites } from "../../pillars/suites/run.js";
35
+ import { listServicesWithManifests } from "../../shared/manifest/workspace-manifests.js";
34
36
  import {
35
37
  awsProfileEnv,
36
38
  loadAwsProfiles,
@@ -856,6 +858,19 @@ import { heldActions } from "./held-actions.js";
856
858
  import type { ApprovalQueue } from "./approvals.js";
857
859
  import { processPathRules } from "../cli/onboard-reads.js";
858
860
  import { handleAgentRoutes, startAgentUpkeep } from "./agent-routes.js";
861
+ import {
862
+ lastRunsByRepo,
863
+ recordRunEnd,
864
+ recordRunStart,
865
+ reposOfRun,
866
+ } from "./run-history.js";
867
+ import {
868
+ CHECK_CONCURRENCY,
869
+ CHECK_TIMEOUT_MS,
870
+ runCapped,
871
+ timedOutText,
872
+ withTimeout,
873
+ } from "./check-pool.js";
859
874
  import {
860
875
  handleSessionList,
861
876
  handleSessionUpgrade,
@@ -1273,6 +1288,9 @@ function sshUserNow(): string {
1273
1288
  return sshUser(set, osUser);
1274
1289
  }
1275
1290
 
1291
+ /** Test runs this server is running now, by id (run-history.ts). */
1292
+ const LIVE_RUNS = new Set<string>();
1293
+
1276
1294
  /** The ssh target for this request: the Host it used, minus the port. */
1277
1295
  function sshTargetOf(req: http.IncomingMessage): string {
1278
1296
  return sshTarget(req.headers.host, os.hostname());
@@ -1969,8 +1987,10 @@ async function handleRequest(
1969
1987
  // AG-4a-term's session routes (`/api/sessions/<id>/io`): shell-grade, and
1970
1988
  // an agent's token reaches its own session's only (request-guard). The io
1971
1989
  // is a WebSocket (the server's upgrade handler): a plain GET of it is 426.
1972
- if (pathname.startsWith("/api/sessions/"))
1973
- return sendSessionHttp(req, res, pathname);
1990
+ if (pathname.startsWith("/api/sessions/")) {
1991
+ await sendSessionHttp(req, res, pathname);
1992
+ return;
1993
+ }
1974
1994
  // ON-3: an agent's held writes; Allow from the person's browser only.
1975
1995
  if (
1976
1996
  guard.ok &&
@@ -2782,15 +2802,55 @@ async function handleRequest(
2782
2802
  // Streaming emits in completion order, which varies per poll (shell probes
2783
2803
  // finish at wildly different speeds), so without a stable key the client
2784
2804
  // repaints the dots in a different order every 5s.
2785
- const results = await Promise.all(
2786
- sortedEntries.map((e, idx) =>
2787
- checkEntry(e).then((r) => {
2788
- const ranked = { ...r, openUrl: e.openUrl, links: e.links, idx };
2789
- onResult?.(ranked);
2790
- return ranked;
2791
- }),
2805
+ // 0.4.17: at most CHECK_CONCURRENCY at once, each with at most
2806
+ // CHECK_TIMEOUT_MS: a dead host's check says "timed out after 10 s" and
2807
+ // never holds up the run (check-pool.ts).
2808
+ const late = (e: (typeof sortedEntries)[number]): HealthResult => ({
2809
+ repo: e.repo,
2810
+ service: e.service,
2811
+ label: e.label,
2812
+ ...(e.checkType !== "none"
2813
+ ? healthCheckFields(e.checkType, e.target)
2814
+ : { url: null }),
2815
+ repoPath: e.repoPath,
2816
+ mode: e.mode,
2817
+ isEntrypoint: e.isEntrypoint,
2818
+ ...e.meta,
2819
+ ms: CHECK_TIMEOUT_MS,
2820
+ ok: false,
2821
+ error: timedOutText(CHECK_TIMEOUT_MS),
2822
+ });
2823
+ const run = (e: (typeof sortedEntries)[number], idx: number) =>
2824
+ withTimeout(checkEntry(e), CHECK_TIMEOUT_MS, () => late(e)).then((r) => {
2825
+ const ranked = { ...r, openUrl: e.openUrl, links: e.links, idx };
2826
+ onResult?.(ranked);
2827
+ return ranked;
2828
+ });
2829
+ // Compose and container checks are answered in-process from one shared
2830
+ // docker listing (the call in flight): they start no process, so they
2831
+ // run outside the cap, all at once, and a cycle still lists once.
2832
+ const inProcess = (e: (typeof sortedEntries)[number]) =>
2833
+ (e.checkType === "container" && !!e.container) ||
2834
+ (e.checkType === "shell" && !!e.compose && e.target === e.compose.cmd);
2835
+ const indexed = sortedEntries.map((e, idx) => ({ e, idx }));
2836
+ const results = new Array<HealthResult>(sortedEntries.length);
2837
+ await Promise.all([
2838
+ ...indexed
2839
+ .filter(({ e }) => inProcess(e))
2840
+ .map(({ e, idx }) =>
2841
+ run(e, idx).then((r) => {
2842
+ results[idx] = r;
2843
+ }),
2844
+ ),
2845
+ runCapped(
2846
+ indexed.filter(({ e }) => !inProcess(e)),
2847
+ CHECK_CONCURRENCY,
2848
+ ({ e, idx }) =>
2849
+ run(e, idx).then((r) => {
2850
+ results[idx] = r;
2851
+ }),
2792
2852
  ),
2793
- );
2853
+ ]);
2794
2854
  return results;
2795
2855
  }
2796
2856
 
@@ -3413,6 +3473,13 @@ async function handleRequest(
3413
3473
  return;
3414
3474
  }
3415
3475
 
3476
+ // ── API: each repo's last test run (0.4.17, run-history.ts) ─────────────────
3477
+ if (pathname === "/api/runs/last") {
3478
+ return sendJson(res, {
3479
+ repos: lastRunsByRepo(getStateDir(), LIVE_RUNS),
3480
+ });
3481
+ }
3482
+
3416
3483
  // ── API: running daemons list ──────────────────────────────────────────────
3417
3484
  if (pathname === "/api/service/daemons") {
3418
3485
  await startupPrune;
@@ -3878,8 +3945,36 @@ async function handleRequest(
3878
3945
 
3879
3946
  const startTime = Date.now();
3880
3947
  let overallExit = 0;
3948
+ // 0.4.17: the server's run history (every browser's runs, kept), which
3949
+ // Workspaces' Last test run reads per repo.
3950
+ // Every run kind that runs a repo's own tests names it, the suites
3951
+ // pillar (`tests:`) and a repo's performance modules included.
3952
+ const tested = await reposOfRun(String(pillar), String(env), modules, {
3953
+ pillarSources: (p) =>
3954
+ collectTestSources(p as Pillar).map((x) => x.repoName),
3955
+ suiteRepos: (e, only) =>
3956
+ planSuites(listServicesWithManifests(), e as Environment, only).run.map(
3957
+ (x) => x.repo,
3958
+ ),
3959
+ perfModules: async () =>
3960
+ (await moduleScanner.scanModules("performance")).map((m) => m.name),
3961
+ repoNames: () => discoverRepos().map((r) => r.repoName),
3962
+ });
3963
+ LIVE_RUNS.add(runId);
3964
+ recordRunStart(getStateDir(), {
3965
+ id: runId,
3966
+ pillar: String(pillar),
3967
+ env: String(env),
3968
+ repos: tested,
3969
+ });
3970
+ let recorded = false;
3881
3971
 
3882
3972
  const finishLifecycle = (exitCode: number, note?: string): void => {
3973
+ if (!recorded) {
3974
+ recorded = true;
3975
+ LIVE_RUNS.delete(runId);
3976
+ recordRunEnd(getStateDir(), runId, exitCode);
3977
+ }
3883
3978
  const dur = formatDuration(Date.now() - startTime);
3884
3979
  const extra = note ? ` · ${note}` : "";
3885
3980
  serverLog(
@@ -147,6 +147,9 @@ const st = {
147
147
  signedOut: false,
148
148
  open: [],
149
149
  panes: new Map(),
150
+ // AG-18: agents (ws\0id) whose persona change started fresh, for the
151
+ // pane's line once it follows them.
152
+ fresh: new Set(),
150
153
  timer: null,
151
154
  xterm: null,
152
155
  csrf: null,
@@ -326,6 +329,18 @@ function rotationWhy(rot) {
326
329
  );
327
330
  }
328
331
 
332
+ /** The Workspaces page, focused on workspace `ws` (0.4.17). */
333
+ function wsHref(ws) {
334
+ const nav = window.denShell?.nav?.() ?? [];
335
+ const base = nav.find((n) => n.id === "local-dev")?.href ?? "/local-dev";
336
+ return `${base}?ws=${encodeURIComponent(ws)}`;
337
+ }
338
+
339
+ /** Workspace `ws` as a link to it on Workspaces. */
340
+ function wsLinkHtml(ws) {
341
+ return `<a class="wslink" href="${esc(wsHref(ws))}" title="${esc(t("ag.goWs", { ws }))}">${esc(ws)}</a>`;
342
+ }
343
+
329
344
  /** Its rotation, when one is due: "ready" or "due" (den#4). */
330
345
  function rotationOf(r) {
331
346
  if (!r.rotation?.due) return "";
@@ -480,7 +495,7 @@ function paintList() {
480
495
  ? groups
481
496
  .map(
482
497
  (g) =>
483
- `<div class="wsg" data-ws="${esc(g.ws)}"><div class="wsh"><span>${esc(g.ws)}</span><span>${esc(g.rows.length)}</span></div>` +
498
+ `<div class="wsg" data-ws="${esc(g.ws)}"><div class="wsh"><span>${wsLinkHtml(g.ws)}</span><span>${esc(g.rows.length)}</span></div>` +
484
499
  g.rows.map(rowHtml).join("") +
485
500
  `</div>`,
486
501
  )
@@ -616,6 +631,11 @@ function followAgents() {
616
631
  }
617
632
  }
618
633
 
634
+ /** The pane's line for a resume that had nothing to continue (AG-14). */
635
+ function freshLine(p) {
636
+ p.term?.write(`\x1b[2m${t("ag.freshLine")}\x1b[0m\r\n`);
637
+ }
638
+
619
639
  /** Move pane `p` to session row `row`, where it was. */
620
640
  function retarget(p, row) {
621
641
  const old = p.id;
@@ -639,6 +659,7 @@ function retarget(p, row) {
639
659
  p.term?.write(
640
660
  `\r\n\x1b[2m${t("ag.switched", { p: row.persona ?? row.agent })}\x1b[0m\r\n`,
641
661
  );
662
+ if (st.fresh.delete(`${p.agentWs}\u0000${p.agent}`)) freshLine(p);
642
663
  paintBar(p);
643
664
  paintLayout();
644
665
  writeUrl();
@@ -769,6 +790,7 @@ function paintLayout() {
769
790
  if (n === 0) {
770
791
  box.className = "agempty";
771
792
  delete box.dataset.layout;
793
+ delete box.dataset.ws;
772
794
  box.innerHTML = `<div>${esc(t("ag.empty"))}</div>`;
773
795
  return;
774
796
  }
@@ -776,6 +798,10 @@ function paintLayout() {
776
798
  const shown = visibleIds();
777
799
  box.className = `agp n${Math.max(1, shown.length)}${layout === "tabs" ? " tabs" : ""}`;
778
800
  box.dataset.layout = layout;
801
+ // The front agent's workspace: ⌘K lists it first (0.4.17).
802
+ const front = rowOf(st.cur)?.workspace;
803
+ if (front) box.dataset.ws = front;
804
+ else delete box.dataset.ws;
779
805
  const empty = box.querySelector(":scope > div:not(.pane):not(.agtabs)");
780
806
  if (empty) empty.remove();
781
807
  let strip = box.querySelector(":scope > .agtabs");
@@ -919,10 +945,14 @@ function paintBar(p) {
919
945
  const r = rowOf(p.id);
920
946
  const bar = p.el.querySelector(".bar2");
921
947
  const name = r ? paneName(r) : p.label || p.id;
922
- // AG-4b: the session's name last, when it says more.
923
- const where = r
924
- ? [r.workspace, r.runtime, sessionExtra(r)].filter(Boolean).join(" · ")
925
- : p.workspace || "";
948
+ // AG-4b: the session's name last, when it says more. The workspace is a
949
+ // link to it on Workspaces (0.4.17).
950
+ const ws = r ? r.workspace : p.workspace || "";
951
+ const rest = r
952
+ ? [r.runtime, sessionExtra(r)].filter(Boolean).join(" · ")
953
+ : "";
954
+ const where =
955
+ (ws ? wsLinkHtml(ws) : "") + (ws && rest ? " · " : "") + esc(rest);
926
956
  const tone = r ? stateTone(r) : p.exited ? "off" : "pending";
927
957
  const stop =
928
958
  r?.kind === "agent" && (r.state === "running" || r.state === "stopping")
@@ -943,7 +973,7 @@ function paintBar(p) {
943
973
  : "";
944
974
  bar.innerHTML =
945
975
  `<span class="den-dot ${tone}" aria-hidden="true"></span>` +
946
- `<b>${esc(name)}</b><span class="where mono">${esc(where)}</span>` +
976
+ `<b>${esc(name)}</b><span class="where mono">${where}</span>` +
947
977
  (r?.tokenRevoked
948
978
  ? `<span class="tagx warn" title="${esc(t("ag.revokedTip"))}">${esc(t("ag.revoked"))}</span>`
949
979
  : "") +
@@ -1124,21 +1154,41 @@ async function connect(p) {
1124
1154
  }
1125
1155
  p.term?.write(new Uint8Array(e.data));
1126
1156
  };
1127
- sock.onclose = (e) => {
1157
+ sock.onclose = async (e) => {
1128
1158
  if (p.sock !== sock) return;
1129
1159
  p.sock = null;
1130
1160
  if (p.closed) return;
1131
- // Refused before it opened: beyond loopback without a session, the
1132
- // upgrade is 401 (a browser sees only the close).
1133
- if (!opened) void refresh();
1134
- const why =
1135
- e.reason || (opened ? String(e.code) : t("ag.signin", { cli: CLI }));
1161
+ // Refused before it opened: a browser sees only the close, so ask the
1162
+ // route why with a plain GET (0.4.17). "Sign in" only when it's 401.
1163
+ let why = e.reason || String(e.code);
1164
+ if (!opened) {
1165
+ void refresh();
1166
+ why = await refusalReason(p.id);
1167
+ if (p.closed || p.sock) return;
1168
+ }
1136
1169
  p.term?.write(`\r\n${t("ag.lost", { why })}\r\n`);
1137
1170
  p.lost = true;
1138
1171
  paintBar(p);
1139
1172
  };
1140
1173
  }
1141
1174
 
1175
+ /** Why session `id`'s io upgrade was refused, in words for its pane. */
1176
+ async function refusalReason(id) {
1177
+ try {
1178
+ const r = await fetch(`/api/sessions/${encodeURIComponent(id)}/io`, {
1179
+ headers: { accept: "application/json" },
1180
+ credentials: "same-origin",
1181
+ });
1182
+ if (r.status === 401) return t("ag.signin", { cli: CLI });
1183
+ const b = await r.json().catch(() => null);
1184
+ if (r.status === 426) return t("ag.refusedUnknown");
1185
+ if (typeof b?.error === "string" && b.error) return b.error;
1186
+ return t("ag.refusedStatus", { status: r.status });
1187
+ } catch {
1188
+ return t("ag.refusedUnknown");
1189
+ }
1190
+ }
1191
+
1142
1192
  // ── links and keys in a pane (AG-4b) ───────────────────────────────────
1143
1193
 
1144
1194
  /** What Shift+Enter sends to a pane's session (a plain session: Enter). */
@@ -2306,11 +2356,20 @@ async function submitPersonaChange(when) {
2306
2356
  body,
2307
2357
  );
2308
2358
  window.denShell?.toast?.(
2309
- t(r?.pending ? "ag.pc.pending" : "ag.pc.done", {
2310
- id: ch.name,
2311
- p: personaName(p),
2312
- }),
2359
+ t(
2360
+ r?.pending ? "ag.pc.pending" : r?.fresh ? "ag.pc.fresh" : "ag.pc.done",
2361
+ { id: ch.name, p: personaName(p) },
2362
+ ),
2313
2363
  );
2364
+ // Never prompted: nothing was resumed. Its pane says so, now if it
2365
+ // has already moved to the new session, else when it does.
2366
+ if (r?.fresh) {
2367
+ const pane = [...st.panes.values()].find(
2368
+ (q) => q.agent === ch.id && q.agentWs === ch.workspace,
2369
+ );
2370
+ if (pane && pane.id === r.agent?.sessionId) freshLine(pane);
2371
+ else st.fresh.add(`${ch.workspace}\u0000${ch.id}`);
2372
+ }
2314
2373
  for (const w of r?.warnings ?? []) window.denShell?.toast?.(w);
2315
2374
  dlg.close?.();
2316
2375
  await refresh();