@nanobpm/nano-workforce 0.195.2 → 0.197.0

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
@@ -1,3 +1,15 @@
1
+ ## [0.197.0](https://github.com/nanobpm/nano-workforce/compare/v0.196.0...v0.197.0) (2026-10-01)
2
+
3
+ ### Features
4
+
5
+ * **pages:** render every list as the card view ([#838](https://github.com/nanobpm/nano-workforce/issues/838)) ([47b9bad](https://github.com/nanobpm/nano-workforce/commit/47b9bad71fe999cd34e298a2c6f36ce796a402bb)), closes [#837](https://github.com/nanobpm/nano-workforce/issues/837)
6
+
7
+ ## [0.196.0](https://github.com/nanobpm/nano-workforce/compare/v0.195.2...v0.196.0) (2026-09-30)
8
+
9
+ ### Features
10
+
11
+ * **cockpit:** 'Agent' link on active rows opens the cockpit focused on that process ([#834](https://github.com/nanobpm/nano-workforce/issues/834)) ([c9c07a3](https://github.com/nanobpm/nano-workforce/commit/c9c07a3886d065d407260c2dabe2abb410fc7d2d)), closes [#833](https://github.com/nanobpm/nano-workforce/issues/833)
12
+
1
13
  ## [0.195.2](https://github.com/nanobpm/nano-workforce/compare/v0.195.1...v0.195.2) (2026-09-30)
2
14
 
3
15
  ### Bug Fixes
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
3
 
4
- import { parseCockpitRoute } from "./cockpit-route.ts";
4
+ import { parseCockpitRoute, parseHostCockpitParam } from "./cockpit-route.ts";
5
5
 
6
6
  test("parses empty and main cockpit hashes as the main route", () => {
7
7
  assert.deepEqual(parseCockpitRoute(""), { kind: "main" });
@@ -19,3 +19,17 @@ test("empty worker and junk hashes fall back to the main route", () => {
19
19
  assert.deepEqual(parseCockpitRoute("#/elsewhere"), { kind: "main" });
20
20
  assert.deepEqual(parseCockpitRoute("#/cockpit/worker/%E0%A4%A"), { kind: "main" });
21
21
  });
22
+
23
+ test("#833: parses a process-focus hash into the process route", () => {
24
+ assert.deepEqual(parseCockpitRoute("#/cockpit/process/2251799813685249"), { kind: "process", processInstanceKey: "2251799813685249" });
25
+ assert.deepEqual(parseCockpitRoute("#/cockpit/process/"), { kind: "main" });
26
+ });
27
+
28
+ test("#833: the host page's #/cockpit/<process_key> param (an 'Agent' grid link) focuses that process", () => {
29
+ assert.equal(parseHostCockpitParam("#/cockpit/2251799813685249"), "2251799813685249");
30
+ assert.equal(parseHostCockpitParam("#/cockpit/a%20b"), "a b");
31
+ assert.equal(parseHostCockpitParam("#/cockpit"), undefined);
32
+ assert.equal(parseHostCockpitParam("#/cockpit/"), undefined);
33
+ assert.equal(parseHostCockpitParam("#/overview/123"), undefined);
34
+ assert.equal(parseHostCockpitParam("#/cockpit/%E0%A4%A"), undefined);
35
+ });
@@ -1,17 +1,46 @@
1
- export type CockpitRoute = { readonly kind: "main" } | { readonly kind: "worker"; readonly instance: string };
1
+ export type CockpitRoute =
2
+ | { readonly kind: "main" }
3
+ | { readonly kind: "worker"; readonly instance: string }
4
+ /** Focused on one process instance (#833) — the target of an "Agent" grid link. */
5
+ | { readonly kind: "process"; readonly processInstanceKey: string };
6
+
7
+ function decodeSegment(raw: string): string | undefined {
8
+ if (raw === "") return undefined;
9
+ try {
10
+ const value = decodeURIComponent(raw);
11
+ return value === "" ? undefined : value;
12
+ } catch {
13
+ return undefined;
14
+ }
15
+ }
2
16
 
3
17
  /** Parse the cockpit hash route. Unknown or malformed hashes fall back to the main list. */
4
18
  export function parseCockpitRoute(hash: string): CockpitRoute {
5
19
  const route = hash.startsWith("#") ? hash.slice(1) : hash;
6
- if (route === "" || route === "/cockpit" || route === "/cockpit/") return { kind: "main" };
7
- const prefix = "/cockpit/worker/";
8
- if (!route.startsWith(prefix)) return { kind: "main" };
9
- const raw = route.slice(prefix.length);
10
- if (raw === "") return { kind: "main" };
11
- try {
12
- const instance = decodeURIComponent(raw);
13
- return instance === "" ? { kind: "main" } : { kind: "worker", instance };
14
- } catch {
15
- return { kind: "main" };
20
+ const worker = "/cockpit/worker/";
21
+ if (route.startsWith(worker)) {
22
+ const instance = decodeSegment(route.slice(worker.length));
23
+ return instance === undefined ? { kind: "main" } : { kind: "worker", instance };
24
+ }
25
+ const process = "/cockpit/process/";
26
+ if (route.startsWith(process)) {
27
+ const processInstanceKey = decodeSegment(route.slice(process.length));
28
+ return processInstanceKey === undefined ? { kind: "main" } : { kind: "process", processInstanceKey };
16
29
  }
30
+ return { kind: "main" };
31
+ }
32
+
33
+ /**
34
+ * The process-instance key carried by the HOST page's `#/cockpit/<param>` route (#833). An "Agent" grid
35
+ * link (`{ kind: "page", page: "cockpit", keyField: "process_key" }`) navigates the host page — the
36
+ * cockpit embed is an iframe with its own hash — so the embed reads the param from its same-origin
37
+ * parent. Only a single, decodable segment counts; anything else is undefined (no focus).
38
+ */
39
+ export function parseHostCockpitParam(hash: string): string | undefined {
40
+ const route = hash.startsWith("#") ? hash.slice(1) : hash;
41
+ const prefix = "/cockpit/";
42
+ if (!route.startsWith(prefix)) return undefined;
43
+ const raw = route.slice(prefix.length);
44
+ if (raw.includes("/")) return undefined;
45
+ return decodeSegment(raw);
17
46
  }
@@ -99,6 +99,7 @@ test("the rendered transcript region sits directly beneath the Workers — suppl
99
99
  const shell = document.querySelector(".cockpit-shell");
100
100
  const order = [...(shell?.children ?? [])].map((c: { className: string }) => c.className);
101
101
  assertEquals(order, [
102
+ "cockpit-focus",
102
103
  "cockpit-supply-region",
103
104
  "cockpit-terminal",
104
105
  "cockpit-past-region",
@@ -407,3 +408,393 @@ test("agent-history tool call with an empty-string elementId renders no empty ()
407
408
  restore();
408
409
  }
409
410
  });
411
+
412
+ // #833 — an "Agent" grid link lands on the cockpit focused on ONE process instance. The agent may run
413
+ // in a CHILD instance (call activity), so the focus resolves the engine AgentInstances whose ROOT is
414
+ // the linked key and matches live job correlations against every instance in that tree.
415
+ const PI_ROOT = "pi-root";
416
+ const FOCUS_INSTANCE = {
417
+ agentInstanceKey: "ai-7",
418
+ status: "THINKING",
419
+ processInstanceKey: "pi-child",
420
+ rootProcessInstanceKey: PI_ROOT,
421
+ elementId: "implement-task",
422
+ creationDate: "2024-01-01T00:00:00Z",
423
+ };
424
+ const FOCUS_HISTORY = {
425
+ agentInstanceKey: "ai-7",
426
+ count: 1,
427
+ instance: FOCUS_INSTANCE,
428
+ records: [
429
+ {
430
+ historyItemKey: "h-0",
431
+ agentInstanceKey: "ai-7",
432
+ loopIteration: 0,
433
+ role: "ASSISTANT",
434
+ commitStatus: "COMMITTED",
435
+ content: [{ contentType: "TEXT", text: "working on the focused process" }],
436
+ toolCalls: [],
437
+ },
438
+ ],
439
+ };
440
+
441
+ function focusStub(opts: { correlations: unknown[]; transcripts?: unknown[]; replay?: unknown; seen?: string[] }) {
442
+ return (url: string): Promise<unknown> => {
443
+ opts.seen?.push(url);
444
+ const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => body });
445
+ if (url.includes("/supply")) return ok({ leaves: [], workers: [], correlations: opts.correlations });
446
+ if (/\/agent-instances\/[^/?]+\/history/.test(url)) return ok(FOCUS_HISTORY);
447
+ if (url.includes("/agent-instances")) {
448
+ const root = url.includes(`rootProcessInstanceKey=${PI_ROOT}`);
449
+ return ok(root ? { count: 1, instances: [FOCUS_INSTANCE] } : { count: 0, instances: [] });
450
+ }
451
+ if (/[?&]stream=/.test(url)) return ok(opts.replay);
452
+ if (url.includes("/transcripts")) {
453
+ const mine = (opts.transcripts ?? []).filter((t) => url.includes(`processInstanceKey=${(t as { processInstanceKey: string }).processInstanceKey}`));
454
+ return ok({ count: mine.length, transcripts: mine });
455
+ }
456
+ return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
457
+ };
458
+ }
459
+
460
+ async function settle(): Promise<void> {
461
+ for (let i = 0; i < 20; i++) await new Promise((r) => setTimeout(r, 0));
462
+ }
463
+
464
+ test("#833: a process-focus route live-drills the worker running a job of a CHILD instance and shows its agent history", async () => {
465
+ const restore = installEnv(focusStub({ correlations: [{ jobKey: "j1", stream: "wk-a/j1", processInstanceKey: "pi-child" }] }));
466
+ (globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
467
+ try {
468
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
469
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
470
+ try {
471
+ await settle();
472
+ assert((document.querySelector(".cockpit-focus")?.textContent ?? "").includes(PI_ROOT), "the focus banner names the process");
473
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "live");
474
+ assert(StubWebSocket.instances.length > 0, "a relay socket was opened for the focused job");
475
+ const transcript = document.querySelector('.cockpit-agent-transcript[data-agent-instance-key="ai-7"]');
476
+ assert((transcript?.textContent ?? "").includes("working on the focused process"), "the focused agent's history is shown");
477
+ } finally {
478
+ handle.dispose();
479
+ }
480
+ } finally {
481
+ restore();
482
+ }
483
+ });
484
+
485
+ test("#833: with no live job, the process focus replays the newest captured transcript of that process", async () => {
486
+ const replay = {
487
+ stream: "wk-a/j9",
488
+ from: 0,
489
+ gap: false,
490
+ nextOffset: 1,
491
+ entries: [{ offset: 0, chunk: envChunk("message", { role: "assistant", text: "finished earlier" }) }],
492
+ };
493
+ const transcripts = [
494
+ { stream: "wk-a/j9", lifecycle: "ephemeral", status: "completed", createdAt: "2024-01-01T00:00:00Z", nextOffset: 1, byteLength: 10, chunkCount: 1, processInstanceKey: "pi-child" },
495
+ ];
496
+ const restore = installEnv(focusStub({ correlations: [], transcripts, replay }));
497
+ (globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
498
+ try {
499
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
500
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
501
+ try {
502
+ await settle();
503
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "replay");
504
+ assert((document.querySelector('[data-terminal="host"]')?.textContent ?? "").includes("finished earlier"));
505
+ } finally {
506
+ handle.dispose();
507
+ }
508
+ } finally {
509
+ restore();
510
+ }
511
+ });
512
+
513
+ test("#833: embedded, the host page's #/cockpit/<process_key> (the grid 'Agent' link) focuses the cockpit", async () => {
514
+ const restore = installEnv(focusStub({ correlations: [] }));
515
+ const win = (globalThis as { window: Record<string, unknown> }).window;
516
+ const savedParent = win.parent;
517
+ win.parent = { location: { hash: `#/cockpit/${PI_ROOT}` } };
518
+ try {
519
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
520
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
521
+ try {
522
+ await settle();
523
+ assert((document.querySelector(".cockpit-focus")?.textContent ?? "").includes(PI_ROOT), "the host param focused the cockpit");
524
+ } finally {
525
+ handle.dispose();
526
+ }
527
+ } finally {
528
+ win.parent = savedParent;
529
+ restore();
530
+ }
531
+ });
532
+
533
+ test("#833: no focus route → no focus banner", async () => {
534
+ const restore = installEnv(fetchStub());
535
+ try {
536
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
537
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
538
+ try {
539
+ await settle();
540
+ assertEquals(document.querySelector(".cockpit-focus")?.getAttribute("data-focus"), "none");
541
+ } finally {
542
+ handle.dispose();
543
+ }
544
+ } finally {
545
+ restore();
546
+ }
547
+ });
548
+
549
+ // #834 review — a MUTABLE fetch stub: the supply correlations and per-tree transcripts can change
550
+ // between polls, so a test can model a focused live job finishing and its session becoming a capture.
551
+ function mutableFocusStub(state: { correlations: unknown[]; transcripts: unknown[]; replay?: unknown; roots?: string[] }) {
552
+ return (url: string): Promise<unknown> => {
553
+ const ok = (body: unknown) => Promise.resolve({ ok: true, status: 200, json: async () => body });
554
+ if (url.includes("/supply")) return ok({ leaves: [], workers: [], correlations: state.correlations });
555
+ if (/\/agent-instances\/[^/?]+\/history/.test(url)) return ok(FOCUS_HISTORY);
556
+ if (url.includes("/agent-instances")) {
557
+ const root = (state.roots ?? [PI_ROOT]).some((r) => url.includes(`rootProcessInstanceKey=${r}`));
558
+ return ok(root ? { count: 1, instances: [FOCUS_INSTANCE] } : { count: 0, instances: [] });
559
+ }
560
+ if (/[?&]stream=/.test(url)) return ok(state.replay);
561
+ if (url.includes("/transcripts")) {
562
+ const mine = state.transcripts.filter((t) => url.includes(`processInstanceKey=${(t as { processInstanceKey: string }).processInstanceKey}`));
563
+ return ok({ count: mine.length, transcripts: mine });
564
+ }
565
+ return Promise.resolve({ ok: false, status: 404, json: async () => ({}) });
566
+ };
567
+ }
568
+
569
+ test("#834: when the auto-followed live job finishes, the focus falls back to the newest capture", async () => {
570
+ const replay = {
571
+ stream: "wk-a/j1",
572
+ from: 0,
573
+ gap: false,
574
+ nextOffset: 1,
575
+ entries: [{ offset: 0, chunk: envChunk("message", { role: "assistant", text: "captured after finish" }) }],
576
+ };
577
+ const state = {
578
+ correlations: [{ jobKey: "j1", stream: "wk-a/j1", processInstanceKey: "pi-child" }] as unknown[],
579
+ transcripts: [] as unknown[],
580
+ replay,
581
+ };
582
+ const restore = installEnv(mutableFocusStub(state));
583
+ (globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
584
+ try {
585
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
586
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
587
+ try {
588
+ await settle();
589
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "live");
590
+ // The followed job finishes: its live correlation drops out and its session becomes a capture.
591
+ state.correlations = [];
592
+ state.transcripts = [
593
+ { stream: "wk-a/j1", lifecycle: "ephemeral", status: "completed", createdAt: "2024-01-01T00:00:00Z", nextOffset: 1, byteLength: 10, chunkCount: 1, processInstanceKey: "pi-child" },
594
+ ];
595
+ await handle.refresh();
596
+ await settle();
597
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "replay");
598
+ assert((document.querySelector('[data-terminal="host"]')?.textContent ?? "").includes("captured after finish"), "the newest capture replays after the live job ends");
599
+ } finally {
600
+ handle.dispose();
601
+ }
602
+ } finally {
603
+ restore();
604
+ }
605
+ });
606
+
607
+ test("#834: changing the focused process resets the terminal — it never keeps showing the old process's live stream", async () => {
608
+ const state = {
609
+ correlations: [{ jobKey: "j1", stream: "wk-a/j1", processInstanceKey: "pi-child" }] as unknown[],
610
+ transcripts: [] as unknown[],
611
+ replay: undefined as unknown,
612
+ };
613
+ const restore = installEnv(mutableFocusStub(state));
614
+ (globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
615
+ try {
616
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
617
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
618
+ try {
619
+ await settle();
620
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "live");
621
+ // Focus a DIFFERENT process with no live job and no capture: the terminal must reset to idle,
622
+ // not keep replaying process A's live stream.
623
+ handle.focus("pi-other");
624
+ await settle();
625
+ assert((document.querySelector(".cockpit-focus")?.textContent ?? "").includes("pi-other"), "the banner names the new focus");
626
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "idle");
627
+ } finally {
628
+ handle.dispose();
629
+ }
630
+ } finally {
631
+ restore();
632
+ }
633
+ });
634
+
635
+ test("#834: navigating from a process route to the main route clears the focus", async () => {
636
+ const restore = installEnv(focusStub({ correlations: [] }));
637
+ (globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
638
+ try {
639
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
640
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
641
+ try {
642
+ await settle();
643
+ assert((document.querySelector(".cockpit-focus")?.textContent ?? "").includes(PI_ROOT), "the process route focused the cockpit");
644
+ // Navigate away to the main route: the URL no longer selects a process, so the focus must clear
645
+ // (the banner and background focus polls must not keep following the process the view left).
646
+ // linkedom's dispatchEvent rejects a native Event, so construct the event from the stub window.
647
+ (globalThis as { location: { hash: string } }).location.hash = "#/cockpit";
648
+ const win = (globalThis as { window: { Event: new (t: string) => unknown; dispatchEvent: (e: unknown) => void } }).window;
649
+ win.dispatchEvent(new win.Event("hashchange"));
650
+ await settle();
651
+ assertEquals(document.querySelector(".cockpit-focus")?.getAttribute("data-focus"), "none");
652
+ } finally {
653
+ handle.dispose();
654
+ }
655
+ } finally {
656
+ restore();
657
+ }
658
+ });
659
+
660
+ test("#834: a failed auto-opened history read is retried on a later poll", async () => {
661
+ // The history read fails on the FIRST attempt (ok:false → boundedJson throws), then succeeds. The
662
+ // focus must not latch the run as shown on the failed read: a later poll re-fires it and lands the detail.
663
+ let historyCalls = 0;
664
+ const state = { correlations: [] as unknown[], transcripts: [] as unknown[], replay: undefined as unknown };
665
+ const base = mutableFocusStub(state);
666
+ const restore = installEnv((url: string) => {
667
+ if (/\/agent-instances\/[^/?]+\/history/.test(url)) {
668
+ historyCalls++;
669
+ if (historyCalls === 1) return Promise.resolve({ ok: false, status: 500, json: async () => ({}) });
670
+ }
671
+ return base(url);
672
+ });
673
+ (globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
674
+ try {
675
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
676
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
677
+ try {
678
+ await settle();
679
+ assertEquals(historyCalls, 1, "the focus auto-opened the newest run's history once");
680
+ assert(
681
+ (document.querySelector(".cockpit-agent-transcript")?.textContent ?? "").includes("working on the focused process") === false,
682
+ "the failed read rendered nothing",
683
+ );
684
+ // A later poll retries the failed read; the detail now renders.
685
+ await handle.refresh();
686
+ await settle();
687
+ assert(historyCalls >= 2, "the failed auto-open was retried on a later poll");
688
+ assert(
689
+ (document.querySelector(".cockpit-agent-transcript")?.textContent ?? "").includes("working on the focused process"),
690
+ "the retried read rendered the focused agent's history",
691
+ );
692
+ } finally {
693
+ handle.dispose();
694
+ }
695
+ } finally {
696
+ restore();
697
+ }
698
+ });
699
+
700
+ test("#834: 'Show all' clears the focus route from the URL so a refresh does not reopen the process", async () => {
701
+ const restore = installEnv(focusStub({ correlations: [] }));
702
+ (globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
703
+ try {
704
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
705
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
706
+ try {
707
+ await settle();
708
+ assert((document.querySelector(".cockpit-focus")?.textContent ?? "").includes(PI_ROOT), "the process route focused the cockpit");
709
+ // Clicking "Show all" must navigate the focus route out of the URL, not just hide the banner —
710
+ // otherwise refreshing/sharing `#/cockpit/process/<key>` reopens the process just cleared (#834).
711
+ const clear = document.querySelector(".cockpit-focus-clear");
712
+ assert(clear != null, "the focus banner offers a 'Show all' control");
713
+ const win = (globalThis as { window: { Event: new (t: string) => unknown; dispatchEvent: (e: unknown) => void } }).window;
714
+ (clear as { dispatchEvent: (e: unknown) => void }).dispatchEvent(new win.Event("click"));
715
+ assertEquals((globalThis as { location: { hash: string } }).location.hash, "#/cockpit", "'Show all' navigated the URL back to the cockpit root");
716
+ // The resulting hashchange then clears the focus, exactly as a real browser navigation would.
717
+ win.dispatchEvent(new win.Event("hashchange"));
718
+ await settle();
719
+ assertEquals(document.querySelector(".cockpit-focus")?.getAttribute("data-focus"), "none");
720
+ } finally {
721
+ handle.dispose();
722
+ }
723
+ } finally {
724
+ restore();
725
+ }
726
+ });
727
+
728
+ test("#834: switching the focused process clears the old focus's agent-history list immediately", async () => {
729
+ const state = { correlations: [] as unknown[], transcripts: [] as unknown[], replay: undefined as unknown };
730
+ const restore = installEnv(mutableFocusStub(state));
731
+ (globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
732
+ try {
733
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
734
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
735
+ try {
736
+ await settle();
737
+ assert(document.querySelector(".cockpit-agent-region .cockpit-agent-history") != null, "focus A rendered its agent-history list");
738
+ // The switch must blank the list SYNCHRONOUSLY, not leave A's rows up until B's reads land: a click
739
+ // on a stale A row would otherwise start a detail read under B's focus generation (#834).
740
+ handle.focus("pi-other");
741
+ assertEquals(document.querySelector(".cockpit-agent-region")?.childElementCount, 0, "the old focus's history list was cleared immediately on focus change");
742
+ } finally {
743
+ handle.dispose();
744
+ }
745
+ } finally {
746
+ restore();
747
+ }
748
+ });
749
+
750
+ test("#834: a superseded focus's in-flight transcript scan does not block the new focus's scan", async () => {
751
+ // Park focus A's per-tree transcript scan on a never-settling fetch, then switch to B. With a shared
752
+ // scan lock, B would skip its scan until A's request settled (up to the 15s timeout); the lock must be
753
+ // scoped to the focus generation so B scans immediately and replays its own capture (#834).
754
+ let releaseA: () => void = () => {};
755
+ const aParked = new Promise<void>((r) => {
756
+ releaseA = r;
757
+ });
758
+ const bReplay = {
759
+ stream: "wk-b/j9",
760
+ from: 0,
761
+ gap: false,
762
+ nextOffset: 1,
763
+ entries: [{ offset: 0, chunk: envChunk("message", { role: "assistant", text: "B capture replays" }) }],
764
+ };
765
+ const state = { correlations: [] as unknown[], transcripts: [] as unknown[], replay: bReplay, roots: [] as string[] };
766
+ const base = mutableFocusStub(state);
767
+ const restore = installEnv(async (url: string) => {
768
+ if (url.includes("/transcripts") && url.includes(`processInstanceKey=${PI_ROOT}`)) {
769
+ await aParked; // focus A's scan hangs here until released
770
+ return { ok: true, status: 200, json: async () => ({ count: 0, transcripts: [] }) };
771
+ }
772
+ return base(url);
773
+ });
774
+ (globalThis as { location: { hash: string } }).location.hash = `#/cockpit/process/${PI_ROOT}`;
775
+ try {
776
+ const { mountCockpit } = await import("../../../pages/cockpit/mount.js");
777
+ const handle = mountCockpit(document.getElementById("root"), OPTS);
778
+ try {
779
+ await settle();
780
+ // A is focused; its transcript scan is parked awaiting `aParked` and holds the scan lock.
781
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "idle");
782
+ // Switch to B, which HAS a capture: its scan must run despite A's lock still being held.
783
+ state.transcripts = [
784
+ { stream: "wk-b/j9", lifecycle: "ephemeral", status: "completed", createdAt: "2024-01-01T00:00:00Z", nextOffset: 1, byteLength: 10, chunkCount: 1, processInstanceKey: "pi-bee" },
785
+ ];
786
+ handle.focus("pi-bee");
787
+ await settle();
788
+ assertEquals(document.querySelector(".cockpit-terminal")?.getAttribute("data-terminal-mode"), "replay");
789
+ assert(
790
+ (document.querySelector('[data-terminal="host"]')?.textContent ?? "").includes("B capture replays"),
791
+ "the new focus replayed its capture even though the superseded focus's scan is still in flight",
792
+ );
793
+ } finally {
794
+ releaseA();
795
+ handle.dispose();
796
+ }
797
+ } finally {
798
+ restore();
799
+ }
800
+ });
package/nano.app.json CHANGED
@@ -265,7 +265,8 @@
265
265
  "enabled": true,
266
266
  "pagesDir": "pages",
267
267
  "homePage": "overview",
268
- "sourceName": "app"
268
+ "sourceName": "app",
269
+ "gridLayout": "cards"
269
270
  }
270
271
  },
271
272
  "ui": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.195.2",
3
+ "version": "0.197.0",
4
4
  "description": "Nano Workforce — an Agent Graph Orchestration application for Agentic SDLC: durable BPMN processes that coordinate a graph of AI agents across the software delivery lifecycle.",
5
5
  "type": "module",
6
6
  "main": "main.ts",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  "dependencies": {
67
67
  "@nanobpm/agentic": "^0.14.0",
68
- "@nanobpm/urban": "^0.93.0",
68
+ "@nanobpm/urban": "^0.94.1",
69
69
  "bpmn-auto-layout": "^2.0.0-alpha.2"
70
70
  },
71
71
  "devDependencies": {
@@ -545,3 +545,20 @@
545
545
  border-top: 1px solid var(--cockpit-edge);
546
546
  padding-top: 6px;
547
547
  }
548
+
549
+ /* ── Process focus banner (#833): the cockpit opened from an "Agent" grid link. ── */
550
+
551
+ .cockpit-focus[data-focus="none"] { display: none; }
552
+
553
+ .cockpit-focus {
554
+ display: flex;
555
+ gap: 12px;
556
+ align-items: center;
557
+ padding: 6px 10px;
558
+ margin-bottom: 8px;
559
+ border: 1px solid var(--cockpit-edge);
560
+ border-radius: 6px;
561
+ font-size: 13px;
562
+ }
563
+
564
+ .cockpit-focus-clear { margin-left: auto; }
@@ -35,18 +35,53 @@ function isPosInt(value) {
35
35
  return Number.isSafeInteger(value) && value > 0;
36
36
  }
37
37
 
38
+ function decodeSegment(raw) {
39
+ if (raw === "") return undefined;
40
+ try {
41
+ const value = decodeURIComponent(raw);
42
+ return value === "" ? undefined : value;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ // Mirrors app/agentic/cockpit/cockpit-route.ts `parseCockpitRoute` (keep in lockstep).
38
49
  function parseCockpitRoute(hash) {
39
50
  const route = hash.startsWith("#") ? hash.slice(1) : hash;
40
- if (route === "" || route === "/cockpit" || route === "/cockpit/") return { kind: "main" };
41
- const prefix = "/cockpit/worker/";
42
- if (!route.startsWith(prefix)) return { kind: "main" };
51
+ const worker = "/cockpit/worker/";
52
+ if (route.startsWith(worker)) {
53
+ const instance = decodeSegment(route.slice(worker.length));
54
+ return instance === undefined ? { kind: "main" } : { kind: "worker", instance };
55
+ }
56
+ const process = "/cockpit/process/";
57
+ if (route.startsWith(process)) {
58
+ const processInstanceKey = decodeSegment(route.slice(process.length));
59
+ return processInstanceKey === undefined ? { kind: "main" } : { kind: "process", processInstanceKey };
60
+ }
61
+ return { kind: "main" };
62
+ }
63
+
64
+ // Mirrors app/agentic/cockpit/cockpit-route.ts `parseHostCockpitParam` (#833): the process key an
65
+ // "Agent" grid link put on the HOST page's `#/cockpit/<param>` route.
66
+ function parseHostCockpitParam(hash) {
67
+ const route = hash.startsWith("#") ? hash.slice(1) : hash;
68
+ const prefix = "/cockpit/";
69
+ if (!route.startsWith(prefix)) return undefined;
43
70
  const raw = route.slice(prefix.length);
44
- if (raw === "") return { kind: "main" };
71
+ if (raw.includes("/")) return undefined;
72
+ return decodeSegment(raw);
73
+ }
74
+
75
+ // The host page's cockpit param, when this module runs in a same-origin App-View iframe. A
76
+ // cross-origin parent throws on `location` access — no focus then.
77
+ function hostCockpitParam() {
45
78
  try {
46
- const instance = decodeURIComponent(raw);
47
- return instance === "" ? { kind: "main" } : { kind: "worker", instance };
79
+ const parent = window.parent;
80
+ if (parent == null || parent === window) return undefined;
81
+ const hash = parent.location?.hash;
82
+ return typeof hash === "string" ? parseHostCockpitParam(hash) : undefined;
48
83
  } catch {
49
- return { kind: "main" };
84
+ return undefined;
50
85
  }
51
86
  }
52
87
 
@@ -836,6 +871,10 @@ export function mountCockpit(host, opts = {}) {
836
871
  const terminalNote = el(doc, "p", "cockpit-terminal-note");
837
872
  terminalNote.setAttribute("data-terminal-note", "none");
838
873
  terminalPanel.appendChild(terminalNote);
874
+ // Process-focus banner (#833): shown when the cockpit was opened from an "Agent" grid link.
875
+ const focusBanner = el(doc, "div", "cockpit-focus");
876
+ focusBanner.setAttribute("data-focus", "none");
877
+ shell.appendChild(focusBanner);
839
878
  shell.appendChild(listRegion);
840
879
  shell.appendChild(terminalPanel);
841
880
  shell.appendChild(pastRegion);
@@ -865,6 +904,40 @@ export function mountCockpit(host, opts = {}) {
865
904
  let agentRefreshing = false;
866
905
  let agentRefreshPending = false;
867
906
  let shownAgentInstanceKey;
907
+ // In-flight guard for an auto-triggered agent-history read, distinct from `shownAgentInstanceKey`.
908
+ // The focus auto-opens the newest run's history once; marking it `shown` only AFTER the read renders
909
+ // (in viewAgentHistory) means a failed/timed-out read leaves `shown` unset so a later poll retries —
910
+ // while this in-flight key still prevents a duplicate request stacking on the one outstanding (#834).
911
+ let autoHistoryKey;
912
+ // Process focus (#833). `focusKey` is the linked process instance; `focusKeys` grows to every
913
+ // process instance in its tree that hosts one of its agents (a call-activity CHILD runs the agent
914
+ // job, so its correlation carries the child key). While `focusAuto` holds, the terminal follows the
915
+ // focus (live drill if a job is running, else the newest captured transcript); any operator
916
+ // selection clears it so the cockpit never yanks the panel away from what they chose.
917
+ let lastReport;
918
+ let focusKey;
919
+ let focusKeys = new Set();
920
+ let focusAuto = false;
921
+ // The per-tree transcript-scan lock, scoped to the focus generation that holds it rather than a
922
+ // shared boolean: a stale scan from a superseded focus must NOT block the new focus's scan, and its
923
+ // late settle must NOT release the new focus's lock (review #834). `undefined` = free.
924
+ let focusReplayGen;
925
+ // Bumped by every setFocus(); captured before each focus-scoped async read (agent-instances list,
926
+ // per-tree transcript scan, history detail) so a response that resolves after the focus changed —
927
+ // including an A→B→A flip where the key matches again — is dropped instead of mutating the new
928
+ // focus's `focusKeys`, terminal, or agent detail (review #834).
929
+ let focusGen = 0;
930
+ // The stream of a live drill the FOCUS auto-selected (vs. one the operator picked). When that job
931
+ // finishes (its correlation drops out) the auto drill is torn down so the focus can fall back to the
932
+ // newest capture; a manually chosen stream is never auto-closed.
933
+ let autoDrillStream;
934
+ // Rate-limit the per-tree transcript scan: a fan-out focus with many children and no captures would
935
+ // otherwise re-scan every child on every supply poll. Scan at most once per focusScanMinMs.
936
+ const focusScanMinMs = staleAfterMs;
937
+ let focusScanAt = 0;
938
+ // Monotonic token for the agent-history DETAIL read, so a slow response for a previously selected
939
+ // run can't overwrite a newer selection within the same focus.
940
+ let detailToken = 0;
868
941
 
869
942
  function setMode(next, stream) {
870
943
  mode = next;
@@ -903,10 +976,10 @@ export function mountCockpit(host, opts = {}) {
903
976
  function renderRoute() {
904
977
  if (view == null) return;
905
978
  if (route.kind === "worker") {
906
- renderWorkerDetail(listRegion, doc, workerDetailView(view, route.instance), backToMain, drillInto);
979
+ renderWorkerDetail(listRegion, doc, workerDetailView(view, route.instance), backToMain, userDrill);
907
980
  return;
908
981
  }
909
- renderSupply(listRegion, doc, view, drillInto, openWorker);
982
+ renderSupply(listRegion, doc, view, userDrill, openWorker);
910
983
  }
911
984
 
912
985
  function openWorker(instance) {
@@ -932,6 +1005,13 @@ export function mountCockpit(host, opts = {}) {
932
1005
 
933
1006
  function onHashChange() {
934
1007
  route = parseCockpitRoute(location.hash);
1008
+ if (route.kind === "process") {
1009
+ if (route.processInstanceKey !== focusKey) setFocus(route.processInstanceKey);
1010
+ } else if (focusKey != null) {
1011
+ // Navigated to the main/worker route: the URL no longer selects a process, so drop the focus —
1012
+ // otherwise the banner and the background focus polls keep following a process the view left (#834).
1013
+ setFocus(undefined);
1014
+ }
935
1015
  try {
936
1016
  renderRoute();
937
1017
  } catch (err) {
@@ -942,6 +1022,132 @@ export function mountCockpit(host, opts = {}) {
942
1022
 
943
1023
  window.addEventListener("hashchange", onHashChange);
944
1024
 
1025
+ function userDrill(stream) {
1026
+ focusAuto = false;
1027
+ autoDrillStream = undefined;
1028
+ drillInto(stream);
1029
+ }
1030
+
1031
+ function userReplay(stream) {
1032
+ focusAuto = false;
1033
+ autoDrillStream = undefined;
1034
+ return replayInto(stream);
1035
+ }
1036
+
1037
+ function renderFocus() {
1038
+ focusBanner.replaceChildren();
1039
+ if (focusKey == null) {
1040
+ focusBanner.setAttribute("data-focus", "none");
1041
+ return;
1042
+ }
1043
+ focusBanner.setAttribute("data-focus", focusKey);
1044
+ focusBanner.appendChild(el(doc, "span", "cockpit-focus-label", `Focused on process instance ${focusKey}`));
1045
+ const clear = el(doc, "button", "cockpit-focus-clear", "Show all");
1046
+ clear.setAttribute("type", "button");
1047
+ clear.addEventListener("click", () => clearFocus());
1048
+ focusBanner.appendChild(clear);
1049
+ }
1050
+
1051
+ // "Show all" must clear the URL that selected the focus, not just the banner — otherwise the focus
1052
+ // route survives in the address bar and a refresh/share reopens the process the operator just
1053
+ // dismissed (review #834). Standalone, the focus lives in our own `#/cockpit/process/<key>` hash, so
1054
+ // navigating it to `#/cockpit` fires onHashChange, which clears the focus. Embedded, the focus derives
1055
+ // from the host page's `#/cockpit/<param>` hash; rewrite the parent hash to `#/cockpit` so it stops
1056
+ // re-seeding the focus (a cross-origin parent throws on access — fall back to a local clear).
1057
+ function clearFocus() {
1058
+ if (route.kind === "process") {
1059
+ location.hash = "#/cockpit";
1060
+ return;
1061
+ }
1062
+ try {
1063
+ const parent = window.parent;
1064
+ if (parent != null && parent !== window && parseHostCockpitParam(parent.location?.hash ?? "") != null) {
1065
+ parent.location.hash = "#/cockpit";
1066
+ }
1067
+ } catch {
1068
+ // cross-origin host — can't touch its hash; fall through to a local focus clear.
1069
+ }
1070
+ setFocus(undefined);
1071
+ }
1072
+
1073
+ function setFocus(key) {
1074
+ // A new focus invalidates every in-flight focus-scoped read (list/scan/detail) and the terminal
1075
+ // that was following the old focus. Bump the generation first so any awaited result now resolving
1076
+ // is rejected, then hard-reset the panels the old focus owned.
1077
+ focusGen++;
1078
+ focusKey = key;
1079
+ focusKeys = new Set(key == null ? [] : [key]);
1080
+ focusAuto = key != null;
1081
+ autoDrillStream = undefined;
1082
+ focusScanAt = 0;
1083
+ shownAgentInstanceKey = undefined;
1084
+ autoHistoryKey = undefined;
1085
+ // Clear the agent-history LIST alongside the detail: leaving the old focus's list up until the new
1086
+ // focus's reads land would let a click on a stale row start a detail read under the new focus's
1087
+ // generation, surfacing A's history beneath B's banner (review #834).
1088
+ agentRegion.replaceChildren();
1089
+ agentDetailRegion.replaceChildren();
1090
+ teardownTerminal();
1091
+ setMode(undefined, undefined);
1092
+ renderFocus();
1093
+ void focusTick();
1094
+ }
1095
+
1096
+ // Follow the focus: drill into a live job of any process in the focused tree; otherwise replay
1097
+ // the newest captured transcript of that tree (only while the terminal is idle).
1098
+ async function autoFocusTerminal() {
1099
+ if (!focusAuto || disposed) return;
1100
+ const gen = focusGen;
1101
+ const live = (lastReport?.correlations ?? []).find(
1102
+ (c) => c.processInstanceKey != null && focusKeys.has(c.processInstanceKey) && typeof c.stream === "string" && c.stream !== "",
1103
+ );
1104
+ if (live) {
1105
+ autoDrillStream = live.stream;
1106
+ drillInto(live.stream);
1107
+ return;
1108
+ }
1109
+ // The auto-followed live job finished — its correlation is gone but the terminal is still parked in
1110
+ // `live` mode on that stream. Close only THIS auto drill (never an operator's manual selection) so
1111
+ // the focus can fall back to the newest capture below.
1112
+ if (autoDrillStream != null && mode === "live" && drill?.stream === autoDrillStream) {
1113
+ autoDrillStream = undefined;
1114
+ teardownTerminal();
1115
+ setMode(undefined, undefined);
1116
+ }
1117
+ if (mode != null) return;
1118
+ // A scan already running for THIS focus generation blocks a duplicate; a lock still held by a
1119
+ // superseded focus (gen mismatch) must NOT block this focus's scan (review #834).
1120
+ if (focusReplayGen === gen) return;
1121
+ // Throttle the per-tree transcript scan (a focus with many children and no captures would otherwise
1122
+ // re-scan every child every poll). The first scan after a focus change runs immediately (focusScanAt=0).
1123
+ if (Date.now() - focusScanAt < focusScanMinMs) return;
1124
+ focusScanAt = Date.now();
1125
+ focusReplayGen = gen;
1126
+ try {
1127
+ const transcripts = [];
1128
+ for (const key of focusKeys) {
1129
+ const url = new URL(transcriptsUrl, location.href);
1130
+ url.searchParams.set("processInstanceKey", key);
1131
+ const report = await boundedJson(url.href);
1132
+ if (gen !== focusGen || disposed) return;
1133
+ transcripts.push(...(report.transcripts ?? []));
1134
+ }
1135
+ const newest = transcriptsView({ count: transcripts.length, transcripts }).sessions[0];
1136
+ if (newest && gen === focusGen && focusAuto && mode == null && !disposed) await replayInto(newest.stream);
1137
+ } catch (err) {
1138
+ onError(err);
1139
+ } finally {
1140
+ // Only release the lock if THIS scan still owns it: a newer focus may have claimed it while we
1141
+ // awaited, and clearing it then would let a duplicate scan stack under the new focus.
1142
+ if (focusReplayGen === gen) focusReplayGen = undefined;
1143
+ }
1144
+ }
1145
+
1146
+ async function focusTick() {
1147
+ await refreshAgentHistory();
1148
+ await autoFocusTerminal();
1149
+ }
1150
+
945
1151
  function drillInto(stream) {
946
1152
  if (disposed || (mode === "live" && drill?.stream === stream)) return;
947
1153
  // Claim the terminal region: bump the op token so an in-flight replay drops its stale result.
@@ -1113,7 +1319,7 @@ export function mountCockpit(host, opts = {}) {
1113
1319
  pastRegion,
1114
1320
  doc,
1115
1321
  transcriptsView(report),
1116
- replayInto,
1322
+ userReplay,
1117
1323
  mode === "replay" ? shownStream : undefined,
1118
1324
  instance == null ? "Past sessions" : "Job history",
1119
1325
  instance == null ? "No captured sessions yet." : "No captured sessions for this worker yet.",
@@ -1143,6 +1349,7 @@ export function mountCockpit(host, opts = {}) {
1143
1349
  }
1144
1350
  if (disposed) return;
1145
1351
  try {
1352
+ lastReport = report;
1146
1353
  view = supplyView(report, staleAfterMs);
1147
1354
  renderRoute();
1148
1355
  } catch (err) {
@@ -1150,8 +1357,9 @@ export function mountCockpit(host, opts = {}) {
1150
1357
  }
1151
1358
  // Fire-and-forget: a hung transcripts endpoint must never stall the supply poll's next tick.
1152
1359
  void refreshPast(routeInstance());
1153
- // Same discipline for the engine agent-history list (issue #745): single-flight + bounded.
1154
- void refreshAgentHistory();
1360
+ // Same discipline for the engine agent-history list (issue #745): single-flight + bounded. Under a
1361
+ // process focus (#833) the list is focus-filtered and the terminal follows the focus.
1362
+ void focusTick();
1155
1363
  }
1156
1364
 
1157
1365
  // The engine-native SETTLED agent-history endpoints (issue #745/#747), anchored module-relatively
@@ -1180,6 +1388,25 @@ export function mountCockpit(host, opts = {}) {
1180
1388
  }
1181
1389
  }
1182
1390
 
1391
+ // The engine AgentInstances of one focused process TREE (#833): those owned by the instance itself
1392
+ // and those whose ROOT it is (an agent in a call-activity child). Their process keys join
1393
+ // `focusKeys` so live-job correlations on a child instance match the focus.
1394
+ async function focusedAgentInstances(key, gen) {
1395
+ const byKey = new Map();
1396
+ for (const param of ["processInstanceKey", "rootProcessInstanceKey"]) {
1397
+ const url = new URL(agentInstancesUrl, location.href);
1398
+ url.searchParams.set(param, key);
1399
+ const report = await boundedJson(url.href);
1400
+ // The focus changed while this list was in flight — drop it rather than fold a stale tree's keys
1401
+ // into the new focus's `focusKeys`.
1402
+ if (gen !== focusGen || disposed) return { count: 0, instances: [] };
1403
+ for (const i of report.instances ?? []) byKey.set(i.agentInstanceKey, i);
1404
+ }
1405
+ const instances = [...byKey.values()];
1406
+ for (const i of instances) if (i.processInstanceKey) focusKeys.add(i.processInstanceKey);
1407
+ return { count: instances.length, instances };
1408
+ }
1409
+
1183
1410
  async function refreshAgentHistory() {
1184
1411
  // Single-flight (mirrors refreshPast): a slow/hung engine read endpoint never stacks fetches nor
1185
1412
  // gates the supply poll. The list is engine-global (settled AgentInstances), so it is not route-filtered.
@@ -1190,17 +1417,34 @@ export function mountCockpit(host, opts = {}) {
1190
1417
  agentRefreshing = true;
1191
1418
  try {
1192
1419
  let report;
1420
+ const key = focusKey;
1421
+ const gen = focusGen;
1193
1422
  try {
1194
- report = await boundedJson(agentInstancesUrl);
1423
+ report = key == null ? await boundedJson(agentInstancesUrl) : await focusedAgentInstances(key, gen);
1195
1424
  } catch (err) {
1196
1425
  onError(err);
1197
1426
  return;
1198
1427
  }
1199
- if (disposed) return;
1428
+ if (disposed || gen !== focusGen) return;
1429
+ let sessions;
1200
1430
  try {
1201
- renderAgentSessions(agentRegion, doc, agentSessionsView(report), viewAgentHistory, shownAgentInstanceKey);
1431
+ sessions = agentSessionsView(report);
1432
+ renderAgentSessions(agentRegion, doc, sessions, viewAgentHistory, shownAgentInstanceKey);
1202
1433
  } catch (err) {
1203
1434
  onError(err);
1435
+ return;
1436
+ }
1437
+ // Under a focus, open the newest run's history once so the linked agent is on screen. Fire it
1438
+ // WITHOUT awaiting: `focusTick` follows autoFocusTerminal right after this resolves, so a slow
1439
+ // (bounded) history-detail read must not stall the terminal from following the focused live job.
1440
+ // Gate on the in-flight `autoHistoryKey`, NOT `shownAgentInstanceKey`: the run is marked shown
1441
+ // only after its detail actually renders (viewAgentHistory), so a read that failed or timed out
1442
+ // leaves `shown` unset and a later poll retries it — while `autoHistoryKey` still stops a second
1443
+ // request stacking on the one already outstanding (#834).
1444
+ const newest = sessions.sessions[0];
1445
+ if (key != null && shownAgentInstanceKey == null && newest != null && autoHistoryKey !== newest.agentInstanceKey) {
1446
+ autoHistoryKey = newest.agentInstanceKey;
1447
+ void viewAgentHistory(newest.agentInstanceKey);
1204
1448
  }
1205
1449
  } finally {
1206
1450
  agentRefreshing = false;
@@ -1213,14 +1457,22 @@ export function mountCockpit(host, opts = {}) {
1213
1457
 
1214
1458
  async function viewAgentHistory(agentInstanceKey) {
1215
1459
  if (disposed) return;
1460
+ // Capture the focus generation and a fresh selection token; a response that resolves after the
1461
+ // focus changed or after a newer run was selected is dropped instead of overwriting the current view.
1462
+ const gen = focusGen;
1463
+ const token = ++detailToken;
1216
1464
  let report;
1217
1465
  try {
1218
1466
  report = await boundedJson(agentHistoryReadUrl(agentInstanceKey));
1219
1467
  } catch (err) {
1220
1468
  onError(err);
1469
+ // The auto-open read failed: release the in-flight key so a later poll retries it (the run was
1470
+ // never marked shown). A stale superseded read is left latched — a newer selection already
1471
+ // replaced it.
1472
+ if (gen === focusGen && autoHistoryKey === agentInstanceKey) autoHistoryKey = undefined;
1221
1473
  return;
1222
1474
  }
1223
- if (disposed) return;
1475
+ if (disposed || gen !== focusGen || token !== detailToken) return;
1224
1476
  try {
1225
1477
  shownAgentInstanceKey = agentInstanceKey;
1226
1478
  renderAgentHistory(agentDetailRegion, doc, agentHistoryView(report));
@@ -1228,6 +1480,9 @@ export function mountCockpit(host, opts = {}) {
1228
1480
  void refreshAgentHistory();
1229
1481
  } catch (err) {
1230
1482
  onError(err);
1483
+ } finally {
1484
+ // The auto-open read settled: release the in-flight key (no-op for a manual or superseded read).
1485
+ if (gen === focusGen && autoHistoryKey === agentInstanceKey) autoHistoryKey = undefined;
1231
1486
  }
1232
1487
  }
1233
1488
 
@@ -1261,8 +1516,16 @@ export function mountCockpit(host, opts = {}) {
1261
1516
  window.removeEventListener("hashchange", onHashChange);
1262
1517
  }
1263
1518
 
1519
+ const initialFocus = route.kind === "process" ? route.processInstanceKey : hostCockpitParam();
1520
+ if (initialFocus != null) {
1521
+ focusKey = initialFocus;
1522
+ focusKeys = new Set([initialFocus]);
1523
+ focusAuto = true;
1524
+ }
1525
+ renderFocus();
1526
+
1264
1527
  start();
1265
- return { start, stop, dispose, refresh, drill: drillInto, replay: replayInto, viewAgentHistory };
1528
+ return { start, stop, dispose, refresh, drill: userDrill, replay: userReplay, viewAgentHistory, focus: setFocus };
1266
1529
  }
1267
1530
 
1268
1531
  /**
@@ -34,14 +34,20 @@
34
34
  // credentials via the URL fragment — the report guard secret via #secret= (sent as
35
35
  // x-hook-secret) and the relay credentials via #token= and #capability= — NOT the query string,
36
36
  // so they never leak via server access logs, browser history, or the Referer header. The
37
- // fragment is stripped from the address bar immediately after it is read.
37
+ // sensitive fragment keys are stripped from the address bar immediately after they are read,
38
+ // while any OTHER fragment — notably a `#/cockpit/process/<key>` focus route (#833) — is
39
+ // preserved so mountCockpit can still read it and focus that process.
38
40
  const params = new URLSearchParams(location.search);
39
41
  const secrets = new URLSearchParams(location.hash.slice(1));
40
42
  const hookSecret = secrets.get("secret") ?? undefined;
41
43
  const relayToken = secrets.get("token") ?? undefined;
42
44
  const relayCapability = secrets.get("capability") ?? undefined;
43
- if (location.hash) {
44
- history.replaceState(null, "", location.pathname + location.search);
45
+ if (hookSecret !== undefined || relayToken !== undefined || relayCapability !== undefined) {
46
+ secrets.delete("secret");
47
+ secrets.delete("token");
48
+ secrets.delete("capability");
49
+ const rest = secrets.toString();
50
+ history.replaceState(null, "", location.pathname + location.search + (rest ? `#${rest}` : ""));
45
51
  }
46
52
  mountCockpit(document.getElementById("cockpit-root"), {
47
53
  reportUrl: params.get("report") ?? undefined,
@@ -96,6 +96,7 @@
96
96
  "columns": [
97
97
  { "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "30%" },
98
98
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
99
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
99
100
  {
100
101
  "field": "stage",
101
102
  "header": "Pipeline",
@@ -132,14 +132,15 @@
132
132
  { "label": "All", "filter": [] }
133
133
  ],
134
134
  "columns": [
135
- { "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "18%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
135
+ { "field": "title", "template": "{{title}}", "header": "Graph", "subtitleField": "run_key", "truncate": true, "width": "16%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
136
136
  { "field": "status", "header": "Status", "truncate": true, "width": "10%", "link": { "kind": "processExplorer", "keyField": "process_key" } },
137
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "truncate": true, "width": "5%", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
137
138
  { "field": "process_key", "header": "Instance", "width": "8%", "truncate": true, "link": { "kind": "processExplorer", "keyField": "process_key" } },
138
139
  {
139
140
  "field": "stage",
140
141
  "header": "Pipeline",
141
142
  "kind": "pipeline",
142
- "width": "18%",
143
+ "width": "15%",
143
144
  "stages": [
144
145
  { "key": "Requested", "label": "Requested" },
145
146
  { "key": "Implementing", "label": "Implementing" },
@@ -149,6 +149,7 @@
149
149
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "34%", "linkField": "issue_url" },
150
150
  { "field": "epic_phase", "header": "Phase" },
151
151
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
152
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
152
153
  { "field": "delivery", "header": "Delivery" },
153
154
  { "field": "wait_gate_label", "header": "Wait gate" },
154
155
  { "field": "promotion_state", "header": "Promotion" },
@@ -242,6 +243,7 @@
242
243
  { "field": "task_id", "header": "Task" },
243
244
  { "field": "title", "header": "Title" },
244
245
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
246
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
245
247
  { "field": "pr_key", "header": "PR", "linkField": "pr_url" },
246
248
  { "field": "summary", "header": "Summary" },
247
249
  { "field": "updated_at", "header": "Updated", "format": "datetime" }
@@ -121,6 +121,7 @@
121
121
  "columns": [
122
122
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "38%", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
123
123
  { "field": "epic_phase", "header": "Phase", "link": { "kind": "processExplorer", "keyField": "process_key" } },
124
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
124
125
  { "field": "delivery_label", "header": "Delivery", "truncate": true },
125
126
  { "field": "task_count", "header": "Tasks" },
126
127
  { "field": "issue_number", "header": "Issue", "linkField": "issue_url" },
@@ -136,6 +136,7 @@
136
136
  "notInPathField": "stage_skipped",
137
137
  "locus": { "field": "process_key", "link": { "kind": "processExplorer", "keyField": "process_key" } }
138
138
  },
139
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
139
140
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
140
141
  ],
141
142
  "rowActions": [
@@ -173,6 +173,17 @@
173
173
  "keyField": "process_key"
174
174
  }
175
175
  },
176
+ {
177
+ "field": "process_key",
178
+ "header": "Agent",
179
+ "template": "Transcript",
180
+ "width": "7rem",
181
+ "link": {
182
+ "kind": "page",
183
+ "page": "cockpit",
184
+ "keyField": "process_key"
185
+ }
186
+ },
176
187
  {
177
188
  "field": "incident_message",
178
189
  "header": "Incident",
@@ -122,6 +122,7 @@
122
122
  "truncate": true,
123
123
  "link": { "kind": "processExplorer", "keyField": "process_key" }
124
124
  },
125
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
125
126
  { "field": "pr_count", "header": "PRs", "width": "5rem" },
126
127
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
127
128
  ],
@@ -145,6 +146,7 @@
145
146
  "columns": [
146
147
  { "field": "title", "template": "{{title}}", "header": "PR", "subtitleField": "pr_key", "truncate": true, "linkField": "url" },
147
148
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
149
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
148
150
  { "field": "current_round", "header": "Round" },
149
151
  { "field": "outcome", "header": "Outcome", "truncate": true },
150
152
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
@@ -112,6 +112,7 @@
112
112
  "columns": [
113
113
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "pr_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "home", "keyField": "pr_key" } },
114
114
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
115
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
115
116
  { "field": "incident_message", "header": "Incident", "truncate": true, "badge": { "tone": "danger", "label": "1" } },
116
117
  { "field": "current_round", "template": "{{current_round}} · {{active_worker}}", "header": "Round · Agent" },
117
118
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
@@ -149,6 +150,7 @@
149
150
  "columns": [
150
151
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "plan_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "epic-detail", "keyField": "plan_key" } },
151
152
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
153
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
152
154
  { "field": "delivery_label", "header": "Landing" },
153
155
  { "field": "wave_label", "header": "Wave" },
154
156
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
@@ -186,6 +188,7 @@
186
188
  "columns": [
187
189
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "feature_key", "truncate": true, "width": "36%", "link": { "kind": "page", "page": "feature", "keyField": "feature_key" } },
188
190
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
191
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
189
192
  { "field": "delivery_label", "header": "Delivery" },
190
193
  { "field": "updated_at", "header": "Updated", "width": "9rem", "format": "datetime" }
191
194
  ],
@@ -227,6 +230,7 @@
227
230
  "columns": [
228
231
  { "field": "title", "template": "{{title}}", "header": "Item", "subtitleField": "run_key", "truncate": true, "width": "30%", "link": { "kind": "page", "page": "delivery-graph-detail", "keyField": "run_key" } },
229
232
  { "field": "status", "header": "Status", "link": { "kind": "processExplorer", "keyField": "process_key" } },
233
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
230
234
  {
231
235
  "field": "stage",
232
236
  "header": "Pipeline",
@@ -106,6 +106,7 @@
106
106
  "header": "Process",
107
107
  "link": { "kind": "processExplorer", "keyField": "process_key" }
108
108
  },
109
+ { "field": "process_key", "header": "Agent", "template": "Transcript", "width": "7rem", "link": { "kind": "page", "page": "cockpit", "keyField": "process_key" } },
109
110
  { "field": "updated_at", "header": "Updated", "format": "datetime" }
110
111
  ],
111
112
  "rowKey": "user_task_key",
@@ -0,0 +1,66 @@
1
+ // #833 — every grid whose cell links a process instance to the explorer also offers an "Agent" cell
2
+ // linking the SAME process_key to the cockpit (which focuses that process: live terminal, else its
3
+ // latest transcript, plus the engine agent history). Guards the class: a new active-run grid can't
4
+ // ship with a process-explorer link but no way to reach its agent — regardless of which column
5
+ // carries the explorer link (Status, Phase, Stage, Process, …).
6
+ import assert from "node:assert/strict";
7
+ import { readdirSync, readFileSync } from "node:fs";
8
+ import { test } from "node:test";
9
+
10
+ function isObject(value: unknown): value is Record<string, unknown> {
11
+ return value !== null && typeof value === "object" && !Array.isArray(value);
12
+ }
13
+
14
+ // The `link` object of a column, if it has one (no `as`: narrowed through type guards).
15
+ function columnLink(column: unknown): Record<string, unknown> | undefined {
16
+ if (!isObject(column)) return undefined;
17
+ return isObject(column.link) ? column.link : undefined;
18
+ }
19
+
20
+ // The process-explorer link of a column, wherever it lives: a plain column carries it at `link`,
21
+ // but a `pipeline` column carries it on its `locus` (the cell that links the row's process instance —
22
+ // e.g. the Feature runs grid's Pipeline column, pages/feature.page.json). Guarding only `link` would
23
+ // let a pipeline-locus explorer link ship with no Agent column and this test still pass (#834).
24
+ function explorerLink(column: unknown): Record<string, unknown> | undefined {
25
+ const direct = columnLink(column);
26
+ if (direct?.kind === "processExplorer") return direct;
27
+ if (!isObject(column)) return undefined;
28
+ const locus = isObject(column.locus) ? column.locus : undefined;
29
+ const locusLink = locus && isObject(locus.link) ? locus.link : undefined;
30
+ return locusLink?.kind === "processExplorer" ? locusLink : undefined;
31
+ }
32
+
33
+ // Collect every `columns` array anywhere in the page tree (top-level grids, tab grids, detail
34
+ // children, …) without asserting the parsed JSON's shape.
35
+ function grids(node: unknown, out: unknown[][]): void {
36
+ if (Array.isArray(node)) {
37
+ for (const n of node) grids(n, out);
38
+ return;
39
+ }
40
+ if (!isObject(node)) return;
41
+ if (Array.isArray(node.columns)) out.push(node.columns);
42
+ for (const v of Object.values(node)) grids(v, out);
43
+ }
44
+
45
+ test("#833: every process-explorer grid also links its process to the cockpit via an Agent column", () => {
46
+ let checked = 0;
47
+ for (const file of readdirSync("pages").filter((f) => f.endsWith(".page.json"))) {
48
+ const all: unknown[][] = [];
49
+ grids(JSON.parse(readFileSync(`pages/${file}`, "utf8")), all);
50
+ for (const cols of all) {
51
+ // A grid qualifies whenever ANY of its columns links to the process explorer — not just a
52
+ // Status/Phase header, and not just a plain `link`: a pipeline column's `locus.link` counts too
53
+ // — so a Stage/Process/Pipeline (or any future) explorer link is guarded too.
54
+ const explorer = cols.some((c) => explorerLink(c) !== undefined);
55
+ if (!explorer) continue;
56
+ checked++;
57
+ const agent = cols.find((c) => isObject(c) && c.header === "Agent");
58
+ assert.deepEqual(
59
+ columnLink(agent),
60
+ { kind: "page", page: "cockpit", keyField: "process_key" },
61
+ `${file}: grid missing the Agent → cockpit link`,
62
+ );
63
+ }
64
+ }
65
+ assert.ok(checked > 0, "found no process-explorer grids to check");
66
+ });
@@ -0,0 +1,10 @@
1
+ // #837 — every dataGrid renders as the urban card list at every width: the fixed-max-width shell
2
+ // clips wide tables on any monitor, while the card view shows every field.
3
+ import assert from "node:assert/strict";
4
+ import { readFileSync } from "node:fs";
5
+ import { test } from "node:test";
6
+
7
+ test("#837: the pages surface opts into the app-wide card layout", () => {
8
+ const manifest = JSON.parse(readFileSync("nano.app.json", "utf8"));
9
+ assert.equal(manifest.surfaces?.pages?.gridLayout, "cards");
10
+ });