pilotswarm 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/mcp/dist/src/session-id.d.ts +13 -0
  2. package/mcp/dist/src/session-id.d.ts.map +1 -0
  3. package/mcp/dist/src/session-id.js +15 -0
  4. package/mcp/dist/src/session-id.js.map +1 -0
  5. package/mcp/dist/src/tools/artifacts.d.ts.map +1 -1
  6. package/mcp/dist/src/tools/artifacts.js +5 -4
  7. package/mcp/dist/src/tools/artifacts.js.map +1 -1
  8. package/mcp/dist/src/tools/observability.d.ts.map +1 -1
  9. package/mcp/dist/src/tools/observability.js +2 -1
  10. package/mcp/dist/src/tools/observability.js.map +1 -1
  11. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/sessions.js +9 -8
  13. package/mcp/dist/src/tools/sessions.js.map +1 -1
  14. package/mcp/dist/src/tools/turn-control.d.ts.map +1 -1
  15. package/mcp/dist/src/tools/turn-control.js +5 -4
  16. package/mcp/dist/src/tools/turn-control.js.map +1 -1
  17. package/package.json +3 -3
  18. package/tui/plugins/.mcp.json +1 -7
  19. package/tui/src/app.js +12 -1
  20. package/tui/src/node-sdk-transport.js +17 -0
  21. package/ui/core/src/controller.js +62 -11
  22. package/ui/core/src/history.js +34 -0
  23. package/ui/core/src/reducer.js +87 -0
  24. package/ui/core/src/selectors.js +281 -174
  25. package/ui/core/src/state.js +15 -0
  26. package/ui/react/src/components.js +27 -8
  27. package/ui/react/src/web-app.js +83 -38
  28. package/web/dist/assets/index-BKai0rVp.js +24 -0
  29. package/web/dist/assets/index-BRbv6TsU.css +1 -0
  30. package/web/dist/assets/pilotswarm-PUVWBejV.js +90 -0
  31. package/web/dist/assets/{react-CRYBfNR4.js → react-BouHbnso.js} +1 -1
  32. package/web/dist/index.html +4 -4
  33. package/web/runtime.js +23 -2
  34. package/web/dist/assets/index-BmqU2WsR.js +0 -24
  35. package/web/dist/assets/index-D2-7fi56.css +0 -1
  36. package/web/dist/assets/pilotswarm-cIaYAE9e.js +0 -90
@@ -10,7 +10,6 @@ import {
10
10
  decorateArtifactLinksForChat,
11
11
  extractArtifactLinks,
12
12
  extractHttpLinks,
13
- formatDisplayDateTime,
14
13
  formatHumanDurationSeconds,
15
14
  formatTimestamp,
16
15
  padRunsToDisplayWidth,
@@ -21,9 +20,9 @@ import {
21
20
  wrapRunsToDisplayWidth,
22
21
  } from "./formatting.js";
23
22
  import {
23
+ computeContextPercent,
24
24
  getContextCompactionBadge,
25
25
  getContextHeaderBadge,
26
- getContextListBadge,
27
26
  } from "./context-usage.js";
28
27
  import { canonicalSystemTitle } from "./system-titles.js";
29
28
  import { normalizeArtifactEntries } from "./state.js";
@@ -217,19 +216,22 @@ function ownerInitials(owner) {
217
216
  }
218
217
 
219
218
  function shouldDecorateSessionOwners(state) {
220
- if (state.auth?.principal) return true;
219
+ // Decorate owners only when the list actually surfaces more than one
220
+ // distinct human owner — otherwise the owner chip is pure noise on every
221
+ // row (you are always "you"). A narrowed owner filter is an explicit
222
+ // multi-user context, so honor that too.
221
223
  if (state.sessions?.ownerFilter && state.sessions.ownerFilter.all !== true) return true;
222
- if (Object.values(state.sessions?.byId || {}).some((session) => session?.owner)) return true;
224
+ const owners = new Set();
225
+ for (const session of Object.values(state.sessions?.byId || {})) {
226
+ if (!session || session.isSystem || session.isGroup) continue;
227
+ const key = ownerKeyForOwner(session.owner);
228
+ if (!key) continue;
229
+ owners.add(key);
230
+ if (owners.size > 1) return true;
231
+ }
223
232
  return false;
224
233
  }
225
234
 
226
- function buildSessionListTitle(session, brandingTitle, decorateOwners = false) {
227
- const title = buildSessionTitle(session, brandingTitle);
228
- if (!decorateOwners || session?.isSystem) return title;
229
- const prefix = session?.owner ? ownerInitials(session.owner) : "?";
230
- return `(${prefix}) ${title}`;
231
- }
232
-
233
235
  function groupMemberSessions(group, byId = {}) {
234
236
  if (!group?.isGroup || !group?.groupId) return [];
235
237
  return Object.values(byId || {}).filter((session) => (
@@ -575,68 +577,118 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
575
577
 
576
578
  const mainColor = session?.isGroup ? "cyan" : session?.isSystem ? "yellow" : sessionStatusColor(session, mode);
577
579
  const effectiveOwner = effectiveSessionOwner(session, state.sessions?.byId || {});
578
- const titleText = buildSessionListTitle(
579
- effectiveOwner !== (session?.owner ?? null) ? { ...session, owner: effectiveOwner } : session,
580
- state.branding?.title || "PilotSwarm",
581
- shouldDecorateSessionOwners(state),
582
- );
583
- // Show the session's last-updated time at the end of the row. For groups,
584
- // keep the member-count badge but append the last-updated timestamp so the
585
- // row reflects the same value the list is sorted by. `formatDisplayDateTime`
586
- // uses the system locale/timezone, so the timestamp renders in the
587
- // browser/client local zone.
580
+
581
+ const shortId = shortSessionId(session?.sessionId);
582
+
583
+ // Row age — last-updated (the value the list is sorted by). Coarse buckets
584
+ // so it doesn't tick every second: <1min · Nmin · NhMMm · NdHHh · Nw.
588
585
  const rowTimestampMs = session?.updatedAt
589
586
  || session?.summaryUpdatedAt
590
587
  || session?.latestSummaryUpdatedAt
591
588
  || session?.createdAt
592
589
  || 0;
593
- const formattedRowTimestamp = rowTimestampMs ? formatDisplayDateTime(rowTimestampMs) : "";
594
- const titleRuns = [
595
- ...prefixRuns,
596
- {
597
- text: titleText,
598
- color: mainColor,
599
- bold: Boolean(session?.isSystem),
600
- },
601
- ];
590
+ const relTime = rowTimestampMs ? formatSessionAge(rowTimestampMs) : "";
591
+ const modelLabel = shortModelReasoningLabel(session?.model, session?.reasoningEffort);
592
+
593
+ // A regular session with no human title otherwise renders as a bare
594
+ // "(guid)" — an empty, ugly line. For those, pull the meta (id · age ·
595
+ // model) UP onto the main line so it carries information. Titled rows keep
596
+ // the title on the line and expand id·age·model·ctx only when selected.
597
+ const rawTitle = session?.isSystem
598
+ ? canonicalSystemTitle(session, state.branding?.title || "PilotSwarm")
599
+ : buildSessionDisplayTitle(session);
600
+ const hasRealTitle = Boolean(session?.isSystem || session?.isGroup || (rawTitle && rawTitle.trim()));
601
+
602
+ const titleRuns = [...prefixRuns];
603
+ // Owner chip — only when the list actually surfaces more than one human
604
+ // owner (shouldDecorateSessionOwners). Otherwise it's noise on every row.
605
+ if (shouldDecorateSessionOwners(state) && !session?.isSystem && !session?.isGroup) {
606
+ const initials = effectiveOwner ? ownerInitials(effectiveOwner) : "?";
607
+ titleRuns.push({ text: `${initials} · `, color: "cyan" });
608
+ }
609
+ if (hasRealTitle) {
610
+ titleRuns.push({ text: rawTitle, color: mainColor, bold: Boolean(session?.isSystem || session?.isGroup) });
611
+ } else {
612
+ // Untitled → id · age · model on one line (no wasted title line).
613
+ titleRuns.push({ text: shortId, color: mainColor });
614
+ if (relTime) { titleRuns.push({ text: " · ", color: "gray" }); titleRuns.push({ text: relTime, color: "gray" }); }
615
+ if (modelLabel) { titleRuns.push({ text: " · ", color: "gray" }); titleRuns.push({ text: modelLabel, color: "green" }); }
616
+ }
617
+
602
618
  const collapseBadge = getCollapseBadge(session?.sessionId, entry, totalDescendantCounts, visibleDescendantCounts);
603
619
  if (collapseBadge) {
604
620
  titleRuns.push({ text: ` ${collapseBadge.text}`, color: collapseBadge.color, bold: collapseBadge.bold });
605
621
  }
622
+ // Scheduled sessions keep a compact clock glyph on the title; the full
623
+ // cron cadence rides in the detail line.
624
+ const cronBadge = getCronBadge(session);
625
+ if (cronBadge) {
626
+ titleRuns.push({ text: " ⏱", color: "magenta" });
627
+ }
606
628
 
629
+ // Right-column context %: on every row that has usage. Compaction in
630
+ // flight takes precedence; else green normally, amber ≥70, red ≥85.
631
+ const ctxRuns = [];
632
+ const compactionState = session?.contextUsage?.compaction?.state;
633
+ const ctxPercent = computeContextPercent(session?.contextUsage);
634
+ if (session?.isGroup) {
635
+ if (session?.memberCount != null) ctxRuns.push({ text: `${session.memberCount}`, color: "gray" });
636
+ } else if (compactionState === "running") {
637
+ ctxRuns.push({ text: "⇊", color: "magenta" });
638
+ } else if (compactionState === "failed") {
639
+ ctxRuns.push({ text: "!", color: "red" });
640
+ } else if (ctxPercent != null) {
641
+ ctxRuns.push({ text: `${ctxPercent}%`, color: ctxPercent >= 85 ? "red" : ctxPercent >= 70 ? "yellow" : "green" });
642
+ } else {
643
+ ctxRuns.push({ text: "—", color: "gray" });
644
+ }
645
+
646
+ // Kept for backward-compat consumers; groups carry a member/time meta.
607
647
  const metaRuns = [];
608
648
  if (session?.isGroup && session?.memberCount != null) {
609
649
  metaRuns.push({ text: `${session.memberCount} member${session.memberCount === 1 ? "" : "s"}`, color: "gray" });
650
+ if (relTime) { metaRuns.push({ text: " · ", color: "gray" }); metaRuns.push({ text: relTime, color: "gray" }); }
610
651
  }
611
- if (formattedRowTimestamp) {
612
- if (metaRuns.length > 0) metaRuns.push({ text: " · ", color: "gray" });
613
- metaRuns.push({ text: formattedRowTimestamp, color: "gray" });
614
- }
615
-
616
- const badgeRuns = [];
617
652
 
618
- for (const badge of [
619
- getCronBadge(session),
620
- getContextListBadge(session?.contextUsage),
621
- ]) {
622
- if (!badge) continue;
623
- if (badgeRuns.length > 0) badgeRuns.push({ text: " ", color: "gray" });
624
- badgeRuns.push({ text: badge.text, color: badge.color, bold: badge.bold });
653
+ // Detail — expanded under the SELECTED row. Titled rows repeat
654
+ // id · age · model here; untitled rows already carry those on the main
655
+ // line, so their detail starts at the ctx breakdown to avoid repetition.
656
+ const detailRuns = [];
657
+ const pushSep = () => { if (detailRuns.length) detailRuns.push({ text: " · ", color: "gray" }); };
658
+ if (session?.isGroup) {
659
+ detailRuns.push(...metaRuns);
660
+ } else {
661
+ if (hasRealTitle) {
662
+ detailRuns.push({ text: shortId, color: "cyan" });
663
+ if (relTime) { pushSep(); detailRuns.push({ text: relTime, color: "gray" }); }
664
+ if (modelLabel) { pushSep(); detailRuns.push({ text: modelLabel, color: "green" }); }
665
+ }
666
+ const cu = session?.contextUsage;
667
+ if (cu && Number.isFinite(cu.currentTokens) && Number.isFinite(cu.tokenLimit) && cu.tokenLimit > 0) {
668
+ pushSep();
669
+ detailRuns.push({ text: `ctx ${formatCompactNumber(cu.currentTokens)}/${formatCompactNumber(cu.tokenLimit)}`, color: "gray" });
670
+ }
671
+ const childCount = totalDescendantCounts?.[session?.sessionId];
672
+ if (childCount) { pushSep(); detailRuns.push({ text: `${childCount} child${childCount === 1 ? "" : "ren"}`, color: "gray" }); }
673
+ if (cronBadge) { pushSep(); detailRuns.push({ text: cronBadge.text, color: cronBadge.color }); }
625
674
  }
626
675
 
627
- const selectedMetaRuns = buildSelectedSessionMetaRuns(session, mode);
676
+ // Flat runs for the TUI: title + (for titled rows) dim age + context.
628
677
  const runs = [
629
678
  ...titleRuns,
630
- ...(metaRuns.length > 0 ? [{ text: " ", color: "gray" }, ...metaRuns] : []),
631
- ...(badgeRuns.length > 0 ? [{ text: " ", color: "gray" }, ...badgeRuns] : []),
679
+ ...(hasRealTitle && relTime && !session?.isGroup ? [{ text: " ", color: "gray" }, { text: relTime, color: "gray" }] : []),
680
+ ...(ctxRuns.length && !session?.isGroup ? [{ text: " · ", color: "gray" }, ...ctxRuns] : []),
681
+ ...(session?.isGroup && metaRuns.length ? [{ text: " ", color: "gray" }, ...metaRuns] : []),
632
682
  ];
633
683
 
634
684
  return {
635
685
  runs,
636
686
  titleRuns,
687
+ ctxRuns,
637
688
  metaRuns,
638
- badgeRuns,
639
- selectedMetaRuns,
689
+ badgeRuns: [],
690
+ selectedMetaRuns: detailRuns,
691
+ detailRuns,
640
692
  };
641
693
  }
642
694
 
@@ -1245,9 +1297,16 @@ function buildChatMessagePrefix(message) {
1245
1297
  glyph = "x";
1246
1298
  glyphColor = "red";
1247
1299
  } else if (message?.role === "user" && !message?.optimistic && !message?.pendingPhase) {
1248
- // Real durable user.message in transcript — show the "sent" double-check.
1249
- glyph = "✓✓";
1250
- glyphColor = "green";
1300
+ if (message?.stopped) {
1301
+ // Delivered, but its turn was user-stopped mid-flight — the model
1302
+ // may not have acted on it. Amber prohibition ("no parking") sign.
1303
+ glyph = "⊘";
1304
+ glyphColor = "yellow";
1305
+ } else {
1306
+ // Real durable user.message in transcript — show the "sent" double-check.
1307
+ glyph = "✓✓";
1308
+ glyphColor = "green";
1309
+ }
1251
1310
  }
1252
1311
 
1253
1312
  const roleColor = message?.pendingPhase === "pending"
@@ -1887,60 +1946,6 @@ const LIVE_ACTIVITY_SKIP = new Set([
1887
1946
  "tool.execution_progress",
1888
1947
  ]);
1889
1948
 
1890
- // Consecutive events of these types collapse into a single line (streaming
1891
- // reasoning/intent fires many events per turn).
1892
- const LIVE_ACTIVITY_COLLAPSE = new Set(["assistant.reasoning", "assistant.intent"]);
1893
-
1894
- // Friendly verbs for the live activity lines, keyed by event type. An empty
1895
- // string means "show the detail as-is" (e.g. tool calls, where the tool name is
1896
- // the action). Types absent from the map fall back to their cleaned detail.
1897
- const LIVE_ACTIVITY_VERBS = {
1898
- "tool.execution_start": "",
1899
- "tool.execution_complete": "",
1900
- "assistant.reasoning": "Thinking",
1901
- "assistant.intent": "Planning",
1902
- "session.agent_spawned": "Spawning sub-agent",
1903
- "session.wait_started": "Waiting",
1904
- "session.input_required_started": "Awaiting input",
1905
- "session.command_received": "Command",
1906
- "session.command_completed": "Command",
1907
- "session.compaction_start": "Compacting context",
1908
- "session.compaction_complete": "Context compacted",
1909
- "session.dehydrated": "Dehydrating",
1910
- "session.hydrated": "Rehydrated",
1911
- "session.rehydrated": "Rehydrated",
1912
- "session.lossy_handoff": "Handed off to a new worker",
1913
- "session.error": "Error",
1914
- "session.cron_started": "Scheduled",
1915
- "session.cron_scheduled": "Scheduled",
1916
- "session.cron_fired": "Woke on schedule",
1917
- "session.cron_at_scheduled": "Scheduled",
1918
- "session.cron_at_started": "Scheduled",
1919
- "session.cron_at_fired": "Woke on schedule",
1920
- "system.message": "",
1921
- };
1922
-
1923
- // Turn a raw activity item into a friendly one-line action label: strip the
1924
- // timestamp and [bracket] tag, then prefix a human verb where one applies.
1925
- function friendlyLiveActivity(item, maxLen = 96) {
1926
- const raw = String(item?.text || "")
1927
- .replace(/^\s*\d{1,2}:\d{2}(?::\d{2})?\s*/, "")
1928
- .replace(/^\[[^\]]+\]\s*/, "")
1929
- .replace(/\s+/g, " ")
1930
- .trim();
1931
- const verb = LIVE_ACTIVITY_VERBS[item?.eventType];
1932
- let text;
1933
- if (verb === undefined || verb === "") {
1934
- text = raw;
1935
- } else if (raw) {
1936
- text = `${verb} \u2014 ${raw}`;
1937
- } else {
1938
- text = verb;
1939
- }
1940
- text = text || "Working";
1941
- return text.length > maxLen ? `${text.slice(0, maxLen - 1)}\u2026` : text;
1942
- }
1943
-
1944
1949
  // Format an elapsed duration for the live activity card header (e.g. "12s",
1945
1950
  // "1m 05s", "1h 02m").
1946
1951
  function formatElapsed(ms) {
@@ -1954,12 +1959,87 @@ function formatElapsed(ms) {
1954
1959
  return `${hours}h ${String(mins).padStart(2, "0")}m`;
1955
1960
  }
1956
1961
 
1957
- // Claude-style live activity: while a turn is running, surface the last few
1958
- // actions as a short text scrollback. Assistant messages can land before the
1959
- // turn is actually done (streamed/tool-interleaved replies), so the card is
1960
- // governed by session status rather than by the newest transcript role.
1961
- // `options.spinnerFrame` animates the active (latest) line's marker;
1962
- // `options.maxActions` caps the line count.
1962
+ // Fact-store, graph-store, and skill tools get first-class phases instead of
1963
+ // the generic "running tool: X". Classification is pattern-based so new tools
1964
+ // in these families inherit the right phase without a table update.
1965
+ const FACTS_READ_TOOLS = new Set(["read_facts", "facts_search", "facts_similar", "facts_read_uncrawled", "facts_tombstone_stats"]);
1966
+ const FACTS_WRITE_TOOLS = new Set(["store_fact", "delete_fact", "facts_set_crawled", "facts_purge_tombstones", "facts_force_purge"]);
1967
+ function knowledgeToolPhase(name, starting) {
1968
+ const n = String(name || "").toLowerCase();
1969
+ if (n === "search_skills" || n.includes("skill")) {
1970
+ return starting ? "loading skills\u2026" : "loaded skills";
1971
+ }
1972
+ if (FACTS_READ_TOOLS.has(n)) return starting ? "reading facts\u2026" : "read facts";
1973
+ if (FACTS_WRITE_TOOLS.has(n)) return starting ? "writing facts\u2026" : "wrote facts";
1974
+ if (n.startsWith("graph_")) {
1975
+ const writes = /upsert|delete|archive|merge|remove|set_/.test(n);
1976
+ if (writes) return starting ? "writing to the graph\u2026" : "wrote to the graph";
1977
+ return starting ? "reading the graph\u2026" : "read the graph";
1978
+ }
1979
+ if (n.startsWith("facts_") || n.endsWith("_fact") || n.endsWith("_facts")) {
1980
+ // Unrecognized fact-family tool: read/write by verb.
1981
+ const writes = /store|write|set|delete|purge|update/.test(n);
1982
+ return writes ? (starting ? "writing facts\u2026" : "wrote facts") : (starting ? "reading facts\u2026" : "read facts");
1983
+ }
1984
+ return "";
1985
+ }
1986
+
1987
+ // High-level phase for the live activity status line. Maps the newest
1988
+ // activity event to a short human description ("thinking\u2026", "running tool:
1989
+ // read_file\u2026") — never raw payloads; detail lives in the Inspector.
1990
+ function liveActivityPhaseText(item) {
1991
+ const type = String(item?.eventType || "");
1992
+ if (type === "tool.execution_start" || type === "tool.execution_complete") {
1993
+ const raw = String(item?.text || "")
1994
+ .replace(/^\s*\d{1,2}:\d{2}(?::\d{2})?\s*/, "")
1995
+ .replace(/^\[[^\]]+\]\s*/, "")
1996
+ .replace(/^[^A-Za-z0-9_]+/, "")
1997
+ .trim();
1998
+ const name = (raw.match(/^[A-Za-z0-9_.:-]+/) || [""])[0];
1999
+ if (!name) return "running tools\u2026";
2000
+ const knowledge = knowledgeToolPhase(name, type === "tool.execution_start");
2001
+ if (knowledge) return knowledge;
2002
+ const shortName = name.length > 28 ? `${name.slice(0, 27)}\u2026` : name;
2003
+ return type === "tool.execution_start"
2004
+ ? `running tool: ${shortName}\u2026`
2005
+ : `finished tool: ${shortName}`;
2006
+ }
2007
+ switch (type) {
2008
+ case "skill.invoked": return "loading skills\u2026";
2009
+ case "learned_skill.read": return "loading skills\u2026";
2010
+ case "skills.searched": return "loading skills\u2026";
2011
+ case "facts.searched":
2012
+ case "facts.similar": return "reading facts\u2026";
2013
+ case "graph.searched":
2014
+ case "graph.node_searched":
2015
+ case "graph.node_loaded": return "reading the graph\u2026";
2016
+ case "graph.namespace_mutated": return "writing to the graph\u2026";
2017
+ case "assistant.reasoning": return "thinking\u2026";
2018
+ case "assistant.intent": return "planning\u2026";
2019
+ case "assistant.message_start":
2020
+ case "assistant.message": return "writing reply\u2026";
2021
+ case "session.compaction_start": return "compacting context\u2026";
2022
+ case "session.compaction_complete": return "context compacted";
2023
+ case "session.dehydrated": return "dehydrating\u2026";
2024
+ case "session.hydrated":
2025
+ case "session.rehydrated": return "rehydrating\u2026";
2026
+ case "session.agent_spawned": return "coordinating agents\u2026";
2027
+ case "session.wait_started": return "waiting\u2026";
2028
+ case "session.input_required_started": return "awaiting input\u2026";
2029
+ case "session.lossy_handoff": return "moving to a new worker\u2026";
2030
+ case "session.error": return "recovering from an error\u2026";
2031
+ default: return "";
2032
+ }
2033
+ }
2034
+
2035
+ // Single-line live activity status: while a turn is running, one dim line —
2036
+ // spinner + "Working" + elapsed + a high-level phase. Replaces the old
2037
+ // bordered multi-line card: hosts pin it OUTSIDE the scrolling transcript
2038
+ // (portal: bottom-sticky strip above the composer; TUI: appended below the
2039
+ // transcript, which autoscroll keeps at the bottom). Assistant messages can
2040
+ // land before the turn is actually done (streamed/tool-interleaved replies),
2041
+ // so visibility is governed by session status rather than transcript role.
2042
+ // `options.spinnerFrame` animates the marker.
1963
2043
  export function selectLiveActivityLines(state, options = {}) {
1964
2044
  if (state?.ui?.chatViewMode === "summary") return [];
1965
2045
  const session = selectActiveSession(state);
@@ -1967,76 +2047,60 @@ export function selectLiveActivityLines(state, options = {}) {
1967
2047
  const status = String(session?.status || "").toLowerCase();
1968
2048
  if (status !== "running") return [];
1969
2049
  const history = state.history?.bySessionId?.get(session.sessionId) || null;
1970
- // Use the latest user message as the elapsed-time anchor when available,
1971
- // but do not hide the card just because assistant text has already landed;
1972
- // the session may still be running tools or follow-up model work.
2050
+ // Latest user message anchors the elapsed clock when available.
1973
2051
  const chat = Array.isArray(history?.chat) ? history.chat : [];
1974
2052
  let turnStartAt = 0;
1975
2053
  for (let i = chat.length - 1; i >= 0; i -= 1) {
1976
- const role = chat[i]?.role;
1977
- if (role === "user") {
2054
+ if (chat[i]?.role === "user") {
1978
2055
  turnStartAt = Number(chat[i]?.createdAt || 0);
1979
2056
  break;
1980
2057
  }
1981
2058
  }
1982
2059
  const activity = Array.isArray(history?.activity) ? history.activity : [];
1983
- const maxActions = Number.isFinite(options.maxActions) ? options.maxActions : 3;
1984
- const recent = [];
1985
- let acceptedType = null;
1986
- let oldestGatheredAt = 0;
1987
- for (let i = activity.length - 1; i >= 0 && recent.length < maxActions; i -= 1) {
2060
+ // Current-turn boundaries. When a new turn starts, status flips to
2061
+ // "running" BEFORE fresh history lands; anchoring the clock on stale
2062
+ // data flashes a huge elapsed (the whole idle gap) that then snaps to
2063
+ // the real value. Anchor only on evidence belonging to THIS turn — a
2064
+ // user message or turn_start newer than the last turn end — and show
2065
+ // no timer (and no phase) until such evidence exists.
2066
+ let lastTurnStartAt = 0;
2067
+ let lastTurnEndAt = 0;
2068
+ for (let i = activity.length - 1; i >= 0; i -= 1) {
2069
+ const item = activity[i];
2070
+ const type = item?.eventType;
2071
+ const at = Number(item?.createdAt || 0);
2072
+ if (!lastTurnStartAt && type === "assistant.turn_start") lastTurnStartAt = at;
2073
+ if (!lastTurnEndAt && (type === "assistant.turn_end" || type === "session.turn_completed")) lastTurnEndAt = at;
2074
+ if (lastTurnStartAt && lastTurnEndAt) break;
2075
+ }
2076
+ let startAt = 0;
2077
+ if (turnStartAt > lastTurnEndAt) {
2078
+ startAt = turnStartAt; // user-triggered turn
2079
+ } else if (lastTurnStartAt > lastTurnEndAt) {
2080
+ startAt = lastTurnStartAt; // cron/command-triggered turn
2081
+ }
2082
+ let latest = null;
2083
+ for (let i = activity.length - 1; i >= 0; i -= 1) {
1988
2084
  const item = activity[i];
1989
2085
  if (!item || LIVE_ACTIVITY_SKIP.has(item.eventType)) continue;
1990
- if (item.eventType === acceptedType && LIVE_ACTIVITY_COLLAPSE.has(item.eventType)) continue;
1991
- const detail = friendlyLiveActivity(item);
1992
- if (!detail) continue;
1993
- recent.unshift(detail);
1994
- acceptedType = item.eventType;
1995
- const at = Number(item.createdAt || 0);
1996
- if (at > 0) oldestGatheredAt = oldestGatheredAt ? Math.min(oldestGatheredAt, at) : at;
1997
- }
1998
- if (recent.length === 0) return [];
1999
- // Render as a bordered card: the portal turns box-drawing lines whose top
2000
- // row has >2 runs into a `ps-chat-card`; the terminal shows the box art.
2001
- // Header carries the animated spinner + "Working"; body lists the recent
2002
- // actions, most recent highlighted.
2003
- const borderColor = "cyan";
2004
- const spinner = String(options.spinnerFrame || "").trim() || "\u25cf";
2086
+ // Phase must come from the current turn — a stale "finished tool"
2087
+ // from the previous turn must not flash on turn start.
2088
+ if (Number(item.createdAt || 0) <= lastTurnEndAt) break;
2089
+ latest = item;
2090
+ break;
2091
+ }
2005
2092
  const nowMs = Number.isFinite(options.now) ? options.now : Date.now();
2006
- const startAt = turnStartAt || oldestGatheredAt;
2007
2093
  const elapsedLabel = startAt > 0 ? formatElapsed(nowMs - startAt) : "";
2008
- const titleRuns = [
2009
- { text: ` ${spinner} Working `, color: borderColor, bold: true },
2010
- ...(elapsedLabel ? [{ text: `\u00b7 ${elapsedLabel} `, color: "gray" }] : []),
2094
+ const spinner = String(options.spinnerFrame || "").trim() || "\u25cf";
2095
+ const phase = latest ? liveActivityPhaseText(latest) : "";
2096
+ const runs = [
2097
+ { text: `${spinner} `, color: "cyan" },
2098
+ { text: "Working", color: "cyan" },
2099
+ ...(elapsedLabel ? [{ text: ` \u00b7 ${elapsedLabel}`, color: "gray" }] : []),
2100
+ ...(phase ? [{ text: ` \u2014 ${phase}`, color: "gray" }] : []),
2011
2101
  ];
2012
- const titleWidth = titleRuns.reduce((sum, run) => sum + String(run.text || "").length, 0);
2013
- const maxWidth = Math.max(24, Math.min(96, Math.floor(Number(options.maxWidth) || 64)));
2014
- const widestBody = recent.reduce((max, detail) => Math.max(max, detail.length + 2), 0);
2015
- const cardWidth = Math.max(20, Math.min(maxWidth, Math.max(titleWidth + 3, widestBody + 4)));
2016
- const contentWidth = Math.max(1, cardWidth - 4);
2017
- const bodyLines = recent.map((detail, index) => {
2018
- const isLatest = index === recent.length - 1;
2019
- return fitRuns([
2020
- { text: isLatest ? "\u25cf " : "\u00b7 ", color: isLatest ? "cyan" : "gray", bold: isLatest },
2021
- { text: detail, color: isLatest ? "cyan" : "gray" },
2022
- ], contentWidth);
2023
- });
2024
- const topFill = Math.max(0, cardWidth - titleWidth - 3);
2025
- const card = [[
2026
- { text: "\u250c\u2500", color: borderColor },
2027
- ...titleRuns,
2028
- { text: `${"\u2500".repeat(topFill)}\u2510`, color: borderColor },
2029
- ]];
2030
- for (const runs of bodyLines) {
2031
- card.push([
2032
- { text: "\u2502 ", color: borderColor },
2033
- ...padRunsToDisplayWidth(runs, contentWidth),
2034
- { text: " \u2502", color: borderColor },
2035
- ]);
2036
- }
2037
- card.push([{ text: `\u2514${"\u2500".repeat(Math.max(1, cardWidth - 2))}\u2518`, color: borderColor }]);
2038
- card.push([{ text: "", color: null }]);
2039
- return card;
2102
+ const maxWidth = Math.max(24, Math.min(120, Math.floor(Number(options.maxWidth) || 80)));
2103
+ return [fitRuns(runs, maxWidth)];
2040
2104
  }
2041
2105
 
2042
2106
  export function selectChatLines(state, maxWidth = 80, options = {}) {
@@ -2735,19 +2799,34 @@ export function selectAdminConsole(state) {
2735
2799
  }
2736
2800
  : (state.auth?.principal || null);
2737
2801
 
2802
+ const systemGhcpKey = admin.systemGhcpKey || { supported: false, loading: false, configured: false, changedBy: null, changedAt: null, error: null };
2803
+ const isAdmin = Boolean(profile?.isAdmin);
2804
+ // Admin-only target switch: when on, Set/Replace/Clear act on the
2805
+ // SYSTEM user's key (ownerless system sessions run on it).
2806
+ const storeAsSystem = isAdmin && Boolean(ghcpKey.storeAsSystem);
2807
+ const targetConfigured = storeAsSystem ? Boolean(systemGhcpKey.configured) : Boolean(profile?.githubCopilotKeySet);
2808
+ const keyNoun = storeAsSystem ? "System key" : "key";
2809
+
2810
+ const systemProvenance = systemGhcpKey.configured && systemGhcpKey.changedBy
2811
+ ? ` (set by ${systemGhcpKey.changedBy})`
2812
+ : "";
2738
2813
  const ghcpStatusText = ghcpKey.saving
2739
2814
  ? "Saving..."
2740
2815
  : ghcpKey.editing
2741
2816
  ? "Editing — Enter to save, Esc to cancel"
2742
- : (profile?.githubCopilotKeySet
2743
- ? "Configured (overrides env GITHUB_TOKEN for this user)"
2744
- : "Not configured (using env GITHUB_TOKEN fallback)");
2817
+ : storeAsSystem
2818
+ ? (systemGhcpKey.configured
2819
+ ? `System key configured — ownerless system sessions use it for GitHub Copilot models${systemProvenance}`
2820
+ : "System key not configured — ownerless system sessions fall back to env GITHUB_TOKEN")
2821
+ : (profile?.githubCopilotKeySet
2822
+ ? "Configured (overrides env GITHUB_TOKEN for this user)"
2823
+ : "Not configured (using env GITHUB_TOKEN fallback)");
2745
2824
 
2746
2825
  const actions = [];
2747
2826
  if (!ghcpKey.editing) {
2748
- actions.push({ id: "edit", label: profile?.githubCopilotKeySet ? "Replace key" : "Set key", key: "e" });
2749
- if (profile?.githubCopilotKeySet) {
2750
- actions.push({ id: "clear", label: "Clear key", key: "c" });
2827
+ actions.push({ id: "edit", label: targetConfigured ? `Replace ${keyNoun}` : `Set ${keyNoun}`, key: "e" });
2828
+ if (targetConfigured) {
2829
+ actions.push({ id: "clear", label: `Clear ${keyNoun}`, key: "c" });
2751
2830
  }
2752
2831
  actions.push({ id: "refresh", label: "Refresh", key: "r" });
2753
2832
  } else {
@@ -2761,8 +2840,11 @@ export function selectAdminConsole(state) {
2761
2840
  loading: Boolean(admin.loading),
2762
2841
  loadError: admin.loadError || null,
2763
2842
  principal,
2843
+ isAdmin,
2764
2844
  ghcpKey: {
2765
2845
  configured: Boolean(profile?.githubCopilotKeySet),
2846
+ targetConfigured,
2847
+ storeAsSystem,
2766
2848
  editing: Boolean(ghcpKey.editing),
2767
2849
  draft: ghcpKey.draft || "",
2768
2850
  cursorIndex: Math.max(0, Math.min(Number(ghcpKey.cursorIndex) || 0, String(ghcpKey.draft || "").length)),
@@ -2770,6 +2852,14 @@ export function selectAdminConsole(state) {
2770
2852
  error: ghcpKey.error || null,
2771
2853
  statusText: ghcpStatusText,
2772
2854
  },
2855
+ systemGhcpKey: {
2856
+ supported: Boolean(systemGhcpKey.supported),
2857
+ loading: Boolean(systemGhcpKey.loading),
2858
+ configured: Boolean(systemGhcpKey.configured),
2859
+ changedBy: systemGhcpKey.changedBy || null,
2860
+ changedAt: systemGhcpKey.changedAt || null,
2861
+ error: systemGhcpKey.error || null,
2862
+ },
2773
2863
  actions,
2774
2864
  };
2775
2865
  }
@@ -4607,6 +4697,23 @@ function formatRelativeTime(ts) {
4607
4697
  return `${Math.floor(ms / 86_400_000)}d ago`;
4608
4698
  }
4609
4699
 
4700
+ // Coarse session-age buckets for the list. Seconds tick too fast and just
4701
+ // distract, so the smallest bucket is "<1min"; then whole minutes to an hour,
4702
+ // then hours+minutes, then days+hours, then weeks. No "ago" suffix — the age
4703
+ // column context makes it clear.
4704
+ function formatSessionAge(ts) {
4705
+ if (!ts) return "—";
4706
+ const ms = Date.now() - ts;
4707
+ if (ms < 60_000) return "<1min";
4708
+ const totalMin = Math.floor(ms / 60_000);
4709
+ if (totalMin < 60) return `${totalMin}min`;
4710
+ const totalHours = Math.floor(totalMin / 60);
4711
+ if (totalHours < 24) return `${totalHours}h${String(totalMin % 60).padStart(2, "0")}m`;
4712
+ const days = Math.floor(totalHours / 24);
4713
+ if (days < 14) return `${days}d${String(totalHours % 24).padStart(2, "0")}h`;
4714
+ return `${Math.floor(days / 7)}w`;
4715
+ }
4716
+
4610
4717
  function formatLocalTimestamp(ts) {
4611
4718
  if (!ts) return "—";
4612
4719
  const date = ts instanceof Date ? ts : new Date(ts);
@@ -247,6 +247,21 @@ export function createInitialState({ mode = "local", branding = null, themeId =
247
247
  saving: false,
248
248
  error: null,
249
249
  lastSavedAt: 0,
250
+ // Admin-only target switch: when true, Set/Replace/Clear
251
+ // act on the SYSTEM user's key (used by ownerless system
252
+ // sessions) instead of the caller's own key.
253
+ storeAsSystem: false,
254
+ },
255
+ // Status of the SYSTEM user's Copilot key (admins only; never
256
+ // the key itself). `supported` flips on the first successful
257
+ // status load so non-admin/legacy transports hide the toggle.
258
+ systemGhcpKey: {
259
+ supported: false,
260
+ loading: false,
261
+ configured: false,
262
+ changedBy: null,
263
+ changedAt: null,
264
+ error: null,
250
265
  },
251
266
  },
252
267
  };