@nanobpm/nano-workforce 0.195.1 → 0.196.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/app/github.ts CHANGED
@@ -153,16 +153,31 @@ export interface ReviewThread {
153
153
  }
154
154
 
155
155
  /** A suppressed / low-confidence Copilot advisory parsed out of a review body. Its `key` is the
156
- * line-stable identity (survives a line drift); `label` is the human-facing `path:line` shown in
157
- * block reasons. */
156
+ * line-stable identity (survives a line drift); `label` is the human-facing locator shown in block
157
+ * reasons.
158
+ *
159
+ * Two source formats populate this shape, so the per-field shapes below vary by variant:
160
+ * - LEGACY "Suppressed comments" block (`parseLegacySuppressedBlock`): a `**path:line**` header
161
+ * with real `line` (> 0), `text` is the first non-empty prose line after the header, and `label`
162
+ * is `path:line`.
163
+ * - `ccr-overview-v2` file-table findings (`parseOverviewV2TableFindings`, issue #835): `text` is a
164
+ * finding clause from the table's notes column (no header, no source line), so `line` is the
165
+ * sentinel `0` and `label` is `<path> (review overview)` rather than `path:line`.
166
+ *
167
+ * Consumers must therefore treat `line === 0` as "not line-addressable" (an overview finding), NOT
168
+ * as a real line, and must not assume `label` is always `path:line`. */
158
169
  export interface SuppressedAdvisory {
159
170
  path: string;
171
+ /** Source line of the advisory. Real (> 0) for a legacy `**path:line**` header; the sentinel `0`
172
+ * for a `ccr-overview-v2` table finding, which carries no source line and is not line-addressable. */
160
173
  line: number;
161
- /** The advisory prose (first non-empty line after the header), used for the stable fingerprint. */
174
+ /** The advisory prose: the first non-empty line after the header (legacy block) or a finding
175
+ * clause from the overview table's notes column (`ccr-overview-v2`). Used for the stable fingerprint. */
162
176
  text: string;
163
177
  /** Line-stable identity: `<path>#<fingerprint>` of the normalized prose. Survives line drift. */
164
178
  key: string;
165
- /** Human-facing `path:line` label for block-reason messages. */
179
+ /** Human-facing label for block-reason messages: `path:line` for a legacy advisory, or
180
+ * `<path> (review overview)` for a line-less `ccr-overview-v2` table finding. */
166
181
  label: string;
167
182
  }
168
183
 
@@ -250,11 +265,67 @@ export function questionFingerprint(text: string): string {
250
265
  return fingerprint(normalizeAdvisoryText(text));
251
266
  }
252
267
 
253
- /** Parse Copilot's suppressed / low-confidence advisories out of a review body. Copilot renders them
254
- * under a `<summary>Suppressed comments (N)</summary>` block, each as a bold `**path:line**` header
255
- * followed by the advisory prose. Returns de-duplicated advisories (empty when there is no block). */
268
+ /** Parse Copilot's suppressed / low-confidence advisories out of a review body, supporting BOTH
269
+ * formats Copilot emits:
270
+ *
271
+ * 1. The legacy `<summary>Suppressed comments (N)</summary>` block — each advisory is a bold
272
+ * `**path:line**` header followed by the advisory prose (`parseLegacySuppressedBlock`).
273
+ * 2. The newer `ccr-overview-v2` review body (issue #835), which has NO "Suppressed comments"
274
+ * block — findings appear only as clauses in the "What changed" file table's notes column,
275
+ * each ending `(<severity>, N vote[s])` (`parseOverviewV2TableFindings`).
276
+ *
277
+ * Returns de-duplicated advisories from both formats (empty when neither is present). */
256
278
  export function parseSuppressedAdvisories(reviewBody: string | null | undefined): SuppressedAdvisory[] {
257
279
  const body = reviewBody ?? "";
280
+ const out: SuppressedAdvisory[] = [];
281
+ const seen = new Set<string>();
282
+ for (const a of [...parseLegacySuppressedBlock(body), ...parseOverviewV2TableFindings(body)]) {
283
+ if (seen.has(a.key)) continue;
284
+ seen.add(a.key);
285
+ out.push(a);
286
+ }
287
+ return out;
288
+ }
289
+
290
+ /** A finding clause's trailing `(<severity>, N vote[s])` tag in the ccr-overview-v2 file table. */
291
+ const V2_FINDING_TAG = /\s*\(\s*[a-z]+\s*,\s*\d+\s+votes?\s*\)/gi;
292
+
293
+ /** Copilot's `ccr-overview-v2` review body (issue #835) has NO "Suppressed comments" block. Findings
294
+ * that got an inline comment are threads (gated via `isResolved`), but others appear ONLY as clauses
295
+ * in the "What changed" file table's notes column, each ending `(<severity>, N vote[s])`, e.g.
296
+ * `Adds X; A is wrong (moderate, 1 vote), and B is missing (nit, 3 votes).` Each such clause is an
297
+ * advisory keyed on `<path> :: <clause>` — the vote tag is EXCLUDED so a re-vote next round keeps the
298
+ * key stable. The first clause keeps any leading row summary (`Adds X; A is wrong`): Copilot does not
299
+ * always emit a summary (`Maven is declared…; requirements should model…` is ONE finding), so cutting
300
+ * at `; ` would silently truncate a finding — keeping it is fail-CLOSED. Table findings carry
301
+ * no line, so `line` is 0. Findings that duplicate an inline thread are still returned: fail-CLOSED
302
+ * (an extra ack is cheap; a finding silently dropped is the false-OPEN this gate prevents). */
303
+ function parseOverviewV2TableFindings(body: string): SuppressedAdvisory[] {
304
+ if (!/<!--\s*ccr-overview-v\d+\s*-->/i.test(body)) return [];
305
+ const out: SuppressedAdvisory[] = [];
306
+ const rowRe = /^\|\s*`([^`]+)`\s*\|(.*)\|\s*$/;
307
+ for (const raw of body.split(/\r?\n/)) {
308
+ const row = rowRe.exec(raw);
309
+ if (!row) continue;
310
+ const path = row[1].replace(/\u200b|\u200c|\u200d|\ufeff/g, "").trim();
311
+ const notes = row[2].trim();
312
+ let prev = 0;
313
+ V2_FINDING_TAG.lastIndex = 0;
314
+ let m: RegExpExecArray | null;
315
+ // biome-ignore lint/suspicious/noAssignInExpressions: canonical regex-exec accumulation loop
316
+ while ((m = V2_FINDING_TAG.exec(notes)) !== null) {
317
+ const seg = notes.slice(prev, m.index);
318
+ prev = m.index + m[0].length;
319
+ const text = seg.replace(/^[\s,;.]*(?:and\s+)?/i, "").trim();
320
+ if (!text) continue;
321
+ out.push({ path, line: 0, text, key: advisoryStableKey(path, text), label: `${path} (review overview)` });
322
+ }
323
+ }
324
+ return out;
325
+ }
326
+
327
+ /** The legacy "Suppressed comments (N)" block: `**path:line**` headers followed by advisory prose. */
328
+ function parseLegacySuppressedBlock(body: string): SuppressedAdvisory[] {
258
329
  const idx = body.search(/Suppressed comments\s*\(/i);
259
330
  if (idx < 0) return [];
260
331
  // Scan only from the "Suppressed comments" marker onward so a `**path:line**` elsewhere in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/nano-workforce",
3
- "version": "0.195.1",
3
+ "version": "0.196.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",
@@ -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": [