pilotswarm 0.5.13 → 0.5.15

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 (51) hide show
  1. package/README.md +6 -0
  2. package/mcp/README.md +14 -2
  3. package/mcp/dist/src/context.d.ts +13 -0
  4. package/mcp/dist/src/context.d.ts.map +1 -1
  5. package/mcp/dist/src/context.js +20 -0
  6. package/mcp/dist/src/context.js.map +1 -1
  7. package/mcp/dist/src/server.d.ts.map +1 -1
  8. package/mcp/dist/src/server.js +5 -1
  9. package/mcp/dist/src/server.js.map +1 -1
  10. package/mcp/dist/src/tools/capabilities.d.ts +4 -0
  11. package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/capabilities.js +13 -0
  13. package/mcp/dist/src/tools/capabilities.js.map +1 -1
  14. package/mcp/dist/src/tools/groups.d.ts +5 -4
  15. package/mcp/dist/src/tools/groups.d.ts.map +1 -1
  16. package/mcp/dist/src/tools/groups.js +43 -20
  17. package/mcp/dist/src/tools/groups.js.map +1 -1
  18. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  19. package/mcp/dist/src/tools/sessions.js +118 -2
  20. package/mcp/dist/src/tools/sessions.js.map +1 -1
  21. package/package.json +3 -2
  22. package/tui/src/app.js +19 -2
  23. package/tui/src/auth/cli.js +13 -0
  24. package/tui/src/node-sdk-transport.js +99 -13
  25. package/tui/tui-splash-mobile.txt +5 -7
  26. package/tui/tui-splash.txt +13 -9
  27. package/ui/core/src/commands.js +2 -0
  28. package/ui/core/src/controller.js +454 -35
  29. package/ui/core/src/history.js +19 -1
  30. package/ui/core/src/reducer.js +121 -8
  31. package/ui/core/src/selectors.js +204 -14
  32. package/ui/core/src/state.js +3 -0
  33. package/ui/core/src/themes/helpers.js +4 -0
  34. package/ui/react/src/components.js +95 -6
  35. package/ui/react/src/web-app.js +798 -157
  36. package/web/api/router.js +7 -6
  37. package/web/api/ws.js +9 -0
  38. package/web/auth/index.js +5 -0
  39. package/web/auth/providers/dev.js +119 -0
  40. package/web/authz.js +142 -0
  41. package/web/dist/assets/index-CZizkB5Z.js +24 -0
  42. package/web/dist/assets/index-D9e2TGjO.css +1 -0
  43. package/web/dist/assets/pilotswarm-KMqn3ZJs.js +90 -0
  44. package/web/dist/assets/react-l0sNRNKZ.js +1 -0
  45. package/web/dist/index.html +3 -4
  46. package/web/runtime.js +553 -37
  47. package/web/server.js +2 -2
  48. package/web/dist/assets/index-bQ2QInMX.js +0 -24
  49. package/web/dist/assets/index-oldX95Tp.css +0 -1
  50. package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
  51. package/web/dist/assets/react-C9iQPS2h.js +0 -1
@@ -353,16 +353,33 @@ export function dedupeChatMessages(chat = []) {
353
353
  return deduped;
354
354
  }
355
355
 
356
+ // In a multi-writer session the runtime prepends a `[FROM: name (relation)]`
357
+ // attribution line to the prompt so the agent knows who is speaking. The chat
358
+ // pane conveys the same thing through the message's speaker label + color, so
359
+ // strip the raw marker from the DISPLAY text (the structured `sender` below
360
+ // drives the label). Only a leading marker is removed.
361
+ function stripLeadingSenderMarker(text) {
362
+ return typeof text === "string"
363
+ ? text.replace(/^\[FROM:[^\]\n]*\]\n?/, "")
364
+ : text;
365
+ }
366
+
356
367
  function buildChatMessage(event, role) {
357
368
  const rawText = messageTextFromEvent(event);
358
369
  const sessionMessageCard = buildSessionMessageChatCard(event, rawText);
359
370
  if (sessionMessageCard) return sessionMessageCard;
360
371
 
361
- const text = extractVisibleChatText(rawText, role);
372
+ const text = stripLeadingSenderMarker(extractVisibleChatText(rawText, role));
362
373
  if (!hasVisibleMessageText(text)) return null;
363
374
  const clientMessageIds = Array.isArray(event?.data?.clientMessageIds)
364
375
  ? event.data.clientMessageIds.filter((id) => typeof id === "string" && id)
365
376
  : [];
377
+ // Structured sender identity (security model): who sent this message. The
378
+ // chat selector uses it to label the line with the sender's name and a
379
+ // distinct color when it is not the current viewer.
380
+ const sender = event?.data?.sender && typeof event.data.sender === "object"
381
+ ? event.data.sender
382
+ : null;
366
383
  return {
367
384
  id: `${event.sessionId}:${event.seq}`,
368
385
  role: deriveChatRole(event, role, text),
@@ -370,6 +387,7 @@ function buildChatMessage(event, role) {
370
387
  time: formatTimestamp(event.createdAt),
371
388
  createdAt: event.createdAt instanceof Date ? event.createdAt.getTime() : new Date(event.createdAt).getTime(),
372
389
  ...(clientMessageIds.length > 0 ? { clientMessageIds } : {}),
390
+ ...(sender ? { sender } : {}),
373
391
  };
374
392
  }
375
393
 
@@ -324,6 +324,12 @@ function resolveVisibleActiveSessionId(state, fallbackSessions = []) {
324
324
  if (currentSessionId && visibleRows.some((row) => row.sessionId === currentSessionId)) {
325
325
  return currentSessionId;
326
326
  }
327
+ // A navigation intent (deep link) owns selection until it is cleared by
328
+ // manual navigation or a filter change. While one exists — pending target
329
+ // still loading, or failed — never fall back to a default selection.
330
+ if (state.sessions?.navigationIntent) {
331
+ return currentSessionId;
332
+ }
327
333
  if (currentSessionId && state.sessions?.byId?.[currentSessionId] && !hasSessionVisibilityFilter(state.sessions)) {
328
334
  return currentSessionId;
329
335
  }
@@ -361,20 +367,67 @@ function updateUiForSessionSelection(state, nextActiveSessionId) {
361
367
  }
362
368
 
363
369
  function applyVisibleSessionSelection(state, nextSessions) {
370
+ let sessions = nextSessions;
371
+
372
+ // Navigation intent (deep link) outranks in-memory selection and profile
373
+ // activeSessionId: once its target session is present, latch the selection
374
+ // onto it and mark the intent resolved.
375
+ const intent = sessions.navigationIntent || null;
376
+ const intentTargetId = intent && intent.status !== "failed" && sessions.byId?.[intent.sessionId]
377
+ ? intent.sessionId
378
+ : null;
379
+ if (intentTargetId) {
380
+ sessions = {
381
+ ...sessions,
382
+ activeSessionId: intentTargetId,
383
+ navigationIntent: intent.status === "resolved"
384
+ ? intent
385
+ : { sessionId: intent.sessionId, status: "resolved" },
386
+ };
387
+ }
388
+
389
+ // Expand ancestors BEFORE resolving visibility: a selected session inside
390
+ // a collapsed group/parent is absent from the flat tree, so resolving
391
+ // first would drop the selection instead of revealing it.
392
+ const selectionCandidateId = sessions.activeSessionId || null;
393
+ if (selectionCandidateId && sessions.byId?.[selectionCandidateId]) {
394
+ const expandedCollapsedIds = reconcileCollapsedIdsForActiveSession(sessions.collapsedIds, sessions.byId, selectionCandidateId);
395
+ if (!setsEqual(expandedCollapsedIds, sessions.collapsedIds)) {
396
+ sessions = {
397
+ ...sessions,
398
+ collapsedIds: expandedCollapsedIds,
399
+ flat: buildSessionTree(Object.values(sessions.byId || {}), expandedCollapsedIds, sessions.orderById, sessions.pinnedIds),
400
+ };
401
+ }
402
+ }
403
+
404
+ // A resolved intent target excluded by the current filters gets a
405
+ // transient exception so the linked session is shown anyway. Never
406
+ // persisted; cleared on manual navigation or filter change.
407
+ if (intentTargetId && sessions.filterExceptionId !== intentTargetId) {
408
+ const probeRows = selectSessionRows({ ...state, sessions });
409
+ if (!probeRows.some((row) => row.sessionId === intentTargetId)) {
410
+ sessions = {
411
+ ...sessions,
412
+ filterExceptionId: intentTargetId,
413
+ };
414
+ }
415
+ }
416
+
364
417
  const nextState = {
365
418
  ...state,
366
- sessions: nextSessions,
419
+ sessions,
367
420
  };
368
- const nextActiveSessionId = resolveVisibleActiveSessionId(nextState, Object.values(nextSessions.byId || {}));
369
- const nextCollapsedIds = reconcileCollapsedIdsForActiveSession(nextSessions.collapsedIds, nextSessions.byId, nextActiveSessionId);
370
- const collapsedChanged = !setsEqual(nextCollapsedIds, nextSessions.collapsedIds);
421
+ const nextActiveSessionId = resolveVisibleActiveSessionId(nextState, Object.values(sessions.byId || {}));
422
+ const nextCollapsedIds = reconcileCollapsedIdsForActiveSession(sessions.collapsedIds, sessions.byId, nextActiveSessionId);
423
+ const collapsedChanged = !setsEqual(nextCollapsedIds, sessions.collapsedIds);
371
424
  const reconciledSessions = collapsedChanged
372
425
  ? {
373
- ...nextSessions,
426
+ ...sessions,
374
427
  collapsedIds: nextCollapsedIds,
375
- flat: buildSessionTree(Object.values(nextSessions.byId || {}), nextCollapsedIds, nextSessions.orderById, nextSessions.pinnedIds),
428
+ flat: buildSessionTree(Object.values(sessions.byId || {}), nextCollapsedIds, sessions.orderById, sessions.pinnedIds),
376
429
  }
377
- : nextSessions;
430
+ : sessions;
378
431
  return {
379
432
  sessions: {
380
433
  ...reconciledSessions,
@@ -515,7 +568,15 @@ export function appReducer(state, action) {
515
568
  && (settings.chatViewMode === "summary" || settings.chatViewMode === "transcript");
516
569
  const hasPins = Object.prototype.hasOwnProperty.call(settings, "pinnedSessionIds");
517
570
  const hasCollapsed = Object.prototype.hasOwnProperty.call(settings, "collapsedSessionIds");
518
- const hasActive = Object.prototype.hasOwnProperty.call(settings, "activeSessionId");
571
+ // A pending/resolved deep-link intent outranks the profile's
572
+ // persisted activeSessionId — a remote profile poll must not
573
+ // yank selection away from the linked session.
574
+ const navigationIntentLatched = Boolean(
575
+ state.sessions.navigationIntent
576
+ && state.sessions.navigationIntent.status !== "failed",
577
+ );
578
+ const hasActive = Object.prototype.hasOwnProperty.call(settings, "activeSessionId")
579
+ && !navigationIntentLatched;
519
580
  const hasLoadedSessions = Object.keys(state.sessions.byId || {}).length > 0;
520
581
  const nextLayout = hasLayout
521
582
  ? {
@@ -693,6 +754,10 @@ export function appReducer(state, action) {
693
754
  const nextSessions = {
694
755
  ...state.sessions,
695
756
  filterQuery: typeof action.query === "string" ? action.query : "",
757
+ // A filter change is an explicit user action: it releases
758
+ // the deep-link latch and its transient filter exception.
759
+ navigationIntent: null,
760
+ filterExceptionId: null,
696
761
  };
697
762
  const selection = applyVisibleSessionSelection(state, nextSessions);
698
763
  return {
@@ -708,6 +773,8 @@ export function appReducer(state, action) {
708
773
  ...state.sessions,
709
774
  ownerFilterExplicit: true,
710
775
  ownerFilter: normalizeSessionOwnerFilter(action.filter),
776
+ navigationIntent: null,
777
+ filterExceptionId: null,
711
778
  };
712
779
  const selection = applyVisibleSessionSelection(state, nextSessions);
713
780
  return {
@@ -717,6 +784,39 @@ export function appReducer(state, action) {
717
784
  };
718
785
  }
719
786
 
787
+ case "sessions/navigationIntent": {
788
+ const sessionId = String(action.sessionId || "").trim();
789
+ if (!sessionId) return state;
790
+ const nextSessions = {
791
+ ...state.sessions,
792
+ navigationIntent: { sessionId, status: "pending" },
793
+ filterExceptionId: null,
794
+ };
795
+ const selection = applyVisibleSessionSelection(state, nextSessions);
796
+ return {
797
+ ...state,
798
+ sessions: selection.sessions,
799
+ ui: selection.ui,
800
+ };
801
+ }
802
+
803
+ case "sessions/navigationIntentFailed": {
804
+ const intent = state.sessions.navigationIntent;
805
+ const sessionId = String(action.sessionId || "").trim();
806
+ if (!intent || (sessionId && intent.sessionId !== sessionId)) return state;
807
+ return {
808
+ ...state,
809
+ sessions: {
810
+ ...state.sessions,
811
+ navigationIntent: {
812
+ sessionId: intent.sessionId,
813
+ status: "failed",
814
+ errorKind: action.errorKind === "not_found" ? "not_found" : "network",
815
+ },
816
+ },
817
+ };
818
+ }
819
+
720
820
  case "ui/modalSelection": {
721
821
  const modal = state.ui.modal;
722
822
  if (!modal) return state;
@@ -991,15 +1091,28 @@ export function appReducer(state, action) {
991
1091
  if (previousActiveId && previousActiveId !== action.sessionId) {
992
1092
  savedChatScroll[previousActiveId] = Number(state.ui.scroll?.chat) || 0;
993
1093
  }
1094
+ // Manual navigation to a different session releases the deep-link
1095
+ // latch and its transient filter exception.
1096
+ const releasesNavigationLatch = Boolean(
1097
+ state.sessions.navigationIntent
1098
+ && state.sessions.navigationIntent.sessionId !== action.sessionId,
1099
+ );
994
1100
  return {
995
1101
  ...state,
996
1102
  sessions: {
997
1103
  ...state.sessions,
998
1104
  activeSessionId: action.sessionId,
1105
+ ...(releasesNavigationLatch
1106
+ ? { navigationIntent: null, filterExceptionId: null }
1107
+ : {}),
999
1108
  },
1000
1109
  ui: {
1001
1110
  ...state.ui,
1002
1111
  chatScrollBySession: savedChatScroll,
1112
+ // Status notices are per-moment, usually per-session (send
1113
+ // refusals, access hints) — a stale one must not follow the
1114
+ // user to the next session.
1115
+ statusText: previousActiveId !== action.sessionId ? "" : state.ui.statusText,
1003
1116
  scroll: {
1004
1117
  ...state.ui.scroll,
1005
1118
  chat: Number(savedChatScroll[action.sessionId]) || 0,
@@ -32,6 +32,8 @@ export const ACTIVE_HIGHLIGHT_BACKGROUND = "activeHighlightBackground";
32
32
  export const ACTIVE_HIGHLIGHT_FOREGROUND = "activeHighlightForeground";
33
33
  const USER_CHAT_COLOR = "userChat";
34
34
  const USER_CHAT_LABEL_COLOR = "userChatLabel";
35
+ // Speaker label for a message from another person in a shared session.
36
+ const OTHER_PERSON_CHAT_LABEL_COLOR = "otherUserChatLabel";
35
37
 
36
38
  const totalDescendantCountsCache = new WeakMap();
37
39
  const visibleDescendantCountsCache = new WeakMap();
@@ -348,6 +350,10 @@ function matchesOwnerFilterDirect(session, ownerFilter = {}, auth = {}, ownerOve
348
350
  if (ownerKey === SYSTEM_OWNER_KEY) return ownerFilter.includeSystem === true;
349
351
  const currentUserKey = ownerKeyForOwner(auth?.principal);
350
352
  if (ownerFilter.includeMe && currentUserKey && ownerKey === currentUserKey) return true;
353
+ // "Shared with me": any non-system session owned by someone else. The
354
+ // catalog is viewer-scoped server-side, so a foreign owner implies the
355
+ // session was shared with (or is otherwise readable by) the viewer.
356
+ if (ownerFilter.includeShared === true && ownerKey !== currentUserKey) return true;
351
357
  return Array.isArray(ownerFilter.ownerKeys) && ownerFilter.ownerKeys.includes(ownerKey);
352
358
  }
353
359
 
@@ -613,9 +619,13 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
613
619
  const titleRuns = [...prefixRuns];
614
620
  // Owner chip — only when the list actually surfaces more than one human
615
621
  // owner (shouldDecorateSessionOwners). Otherwise it's noise on every row.
622
+ // Bracketed + bold so it reads as an owner badge, not part of the title.
623
+ // The current viewer's own sessions get a distinct color so "mine" pops.
616
624
  if (shouldDecorateSessionOwners(state) && !session?.isSystem && !session?.isGroup) {
617
625
  const initials = effectiveOwner ? ownerInitials(effectiveOwner) : "?";
618
- titleRuns.push({ text: `${initials} · `, color: "cyan" });
626
+ const viewerKey = ownerKeyForOwner(state?.auth?.principal);
627
+ const isMine = viewerKey && ownerKeyForOwner(effectiveOwner) === viewerKey;
628
+ titleRuns.push({ text: `[${initials}] `, color: isMine ? "green" : "cyan", bold: true });
619
629
  }
620
630
  if (hasRealTitle) {
621
631
  titleRuns.push({ text: rawTitle, color: mainColor, bold: Boolean(session?.isSystem || session?.isGroup) });
@@ -682,6 +692,15 @@ function buildSessionRowView(entry, session, state, totalDescendantCounts, visib
682
692
  const childCount = totalDescendantCounts?.[session?.sessionId];
683
693
  if (childCount) { pushSep(); detailRuns.push({ text: `${childCount} child${childCount === 1 ? "" : "ren"}`, color: "gray" }); }
684
694
  if (cronBadge) { pushSep(); detailRuns.push({ text: cronBadge.text, color: cronBadge.color }); }
695
+ // Shared-session marker (security model): private needs no marker;
696
+ // shared_read/shared_write surface here in the selected-row details.
697
+ if (session?.visibility === "shared_read" || session?.visibility === "shared_write") {
698
+ pushSep();
699
+ detailRuns.push({
700
+ text: session.visibility === "shared_write" ? "shared·write" : "shared·read",
701
+ color: "magenta",
702
+ });
703
+ }
685
704
  }
686
705
 
687
706
  // Flat runs for the TUI: title + (for titled rows) dim age + context.
@@ -713,6 +732,31 @@ function matchesSearchQuery(value, query) {
713
732
  return String(value || "").toLowerCase().includes(normalizedQuery);
714
733
  }
715
734
 
735
+ // The transient deep-link filter exception covers the linked session AND its
736
+ // ancestor chain (real parents plus the synthetic group:<id> row) so the
737
+ // linked row renders with its tree context instead of as an orphan.
738
+ function collectFilterExceptionIds(sessions) {
739
+ const exceptionId = sessions?.filterExceptionId || null;
740
+ const byId = sessions?.byId || {};
741
+ if (!exceptionId || !byId[exceptionId]) return null;
742
+ const ids = new Set([exceptionId]);
743
+ let current = byId[exceptionId];
744
+ let hops = 0;
745
+ while (current && hops < 16) {
746
+ let parentId = null;
747
+ if (current.parentSessionId && byId[current.parentSessionId]) {
748
+ parentId = current.parentSessionId;
749
+ } else if (!current.isGroup && current.groupId && byId[`group:${current.groupId}`]) {
750
+ parentId = `group:${current.groupId}`;
751
+ }
752
+ if (!parentId || ids.has(parentId)) break;
753
+ ids.add(parentId);
754
+ current = byId[parentId];
755
+ hops += 1;
756
+ }
757
+ return ids;
758
+ }
759
+
716
760
  export function selectSessionRows(state) {
717
761
  const totalDescendantCounts = getTotalDescendantCounts(state.sessions.byId);
718
762
  const visibleDescendantCounts = getVisibleDescendantCounts(state.sessions.flat, state.sessions.byId);
@@ -721,6 +765,7 @@ export function selectSessionRows(state) {
721
765
  const auth = state.auth || {};
722
766
  const pinnedSet = new Set(Array.isArray(state.sessions?.pinnedIds) ? state.sessions.pinnedIds : []);
723
767
  const selectedSet = new Set(Array.isArray(state.sessions?.selectedIds) ? state.sessions.selectedIds : []);
768
+ const filterExceptionIds = collectFilterExceptionIds(state.sessions);
724
769
 
725
770
  return state.sessions.flat.map((entry) => {
726
771
  const session = state.sessions.byId[entry.sessionId];
@@ -742,6 +787,7 @@ export function selectSessionRows(state) {
742
787
  canPin: canPinSessionRow(session),
743
788
  };
744
789
  }).filter((row) => {
790
+ if (filterExceptionIds?.has(row.sessionId)) return true;
745
791
  const session = state.sessions.byId[row.sessionId];
746
792
  if (!matchesOwnerFilter(session, ownerFilter, auth, state.sessions.byId)) return false;
747
793
  const effectiveOwner = effectiveSessionOwner(session, state.sessions.byId);
@@ -791,6 +837,39 @@ export function selectActiveSession(state) {
791
837
  return sessionId ? state.sessions.byId[sessionId] || null : null;
792
838
  }
793
839
 
840
+ export function selectNavigationIntent(state) {
841
+ return state.sessions?.navigationIntent || null;
842
+ }
843
+
844
+ /**
845
+ * Deep-link failure state for renderers. `not_found` deliberately covers both
846
+ * unknown and inaccessible sessions (no existence oracle); network/server
847
+ * failures keep a retryable flavor.
848
+ */
849
+ export function selectNavigationError(state) {
850
+ const intent = state.sessions?.navigationIntent;
851
+ if (!intent || intent.status !== "failed") return null;
852
+ const errorKind = intent.errorKind === "not_found" ? "not_found" : "network";
853
+ return {
854
+ sessionId: intent.sessionId,
855
+ errorKind,
856
+ retryable: errorKind === "network",
857
+ message: errorKind === "not_found"
858
+ ? "This session was not found or has not been shared with you."
859
+ : "Could not load the linked session. Check your connection and try again.",
860
+ };
861
+ }
862
+
863
+ /**
864
+ * Status copy for the transient deep-link filter exception — non-null while a
865
+ * linked session is being shown despite the current filters excluding it.
866
+ */
867
+ export function selectSessionFilterExceptionNotice(state) {
868
+ const exceptionId = state.sessions?.filterExceptionId || null;
869
+ if (!exceptionId || !state.sessions?.byId?.[exceptionId]) return null;
870
+ return "Showing linked session outside your current filters.";
871
+ }
872
+
794
873
  /**
795
874
  * True when the session row is actively running a turn that Stop can target.
796
875
  * Applies to user AND system sessions; group/container rows are not sessions.
@@ -868,9 +947,11 @@ function buildPendingOutboxMessage(sessionId, item) {
868
947
  createdAt: Number(item.createdAt) || Date.now(),
869
948
  pendingPhase: item.phase === "cancelling"
870
949
  ? "cancelling"
871
- : item.phase === "queued"
872
- ? "queued"
873
- : "pending",
950
+ : item.phase === "rejected"
951
+ ? "rejected"
952
+ : item.phase === "queued"
953
+ ? "queued"
954
+ : "pending",
874
955
  };
875
956
  }
876
957
 
@@ -1281,20 +1362,44 @@ function prefixRuns(text, color = "gray", options = {}) {
1281
1362
  }];
1282
1363
  }
1283
1364
 
1284
- function buildChatMessagePrefix(message) {
1365
+ function buildChatMessagePrefix(message, options = {}) {
1285
1366
  const time = formatTimestamp(message?.createdAt || message?.time);
1286
- const roleLabel = message?.role === "user"
1287
- ? "You"
1367
+ // A user.message is labeled from the CURRENT VIEWER's perspective: "You"
1368
+ // for the viewer's own messages, the sender's name for anyone else. When
1369
+ // the sender is the session owner, an "(owner)" tag is appended — so the
1370
+ // viewing owner sees "You (owner):" and everyone else sees "Alice (owner):".
1371
+ // A distinct color marks messages from other people.
1372
+ const sender = message?.sender;
1373
+ const senderKey = sender?.kind === "user" ? ownerKeyForOwner(sender) : null;
1374
+ const viewerKey = options?.viewerKey || null;
1375
+ const ownerKey = options?.ownerKey || null;
1376
+ // Only distinguish speakers / show the owner tag in a SHARED session — a
1377
+ // private solo session stays plain "You" / "Agent" (no noise).
1378
+ const sharedContext = options?.sharedContext === true;
1379
+ const isUser = message?.role === "user";
1380
+ const isSelf = isUser && senderKey && viewerKey && senderKey === viewerKey;
1381
+ const fromOtherPerson = sharedContext && isUser && senderKey && !isSelf;
1382
+ // Owner tag: the sender is the session owner (or, for a sender-less own
1383
+ // message, the viewer themselves is the owner).
1384
+ const senderIsOwner = sharedContext && isUser && ownerKey && (
1385
+ (senderKey && senderKey === ownerKey)
1386
+ || (!senderKey && viewerKey && viewerKey === ownerKey)
1387
+ );
1388
+ const ownerSuffix = senderIsOwner ? " (owner)" : "";
1389
+
1390
+ const roleLabel = isUser
1391
+ ? ((fromOtherPerson ? (sender.display || sender.subject || "User") : "You") + ownerSuffix)
1288
1392
  : message?.role === "assistant"
1289
1393
  ? "Agent"
1290
1394
  : message?.role === "system"
1291
1395
  ? "System"
1292
1396
  : "PilotSwarm";
1293
1397
 
1294
- // 3-state delivery glyph for user messages:
1398
+ // Delivery glyph for user messages:
1295
1399
  // ○ pending — client outbox, not yet durable
1296
1400
  // ✓ queued — durably enqueued, waiting for orchestration to drain
1297
1401
  // x cancelling — durable cancel requested, waiting for runtime outcome
1402
+ // x rejected — server refused the send (authz); auto-dropped shortly
1298
1403
  // ✓✓ sent — persisted as user.message in CMS, LLM has it
1299
1404
  let glyph = null;
1300
1405
  let glyphColor = null;
@@ -1304,7 +1409,7 @@ function buildChatMessagePrefix(message) {
1304
1409
  } else if (message?.pendingPhase === "queued") {
1305
1410
  glyph = "✓";
1306
1411
  glyphColor = "cyan";
1307
- } else if (message?.pendingPhase === "cancelling") {
1412
+ } else if (message?.pendingPhase === "cancelling" || message?.pendingPhase === "rejected") {
1308
1413
  glyph = "x";
1309
1414
  glyphColor = "red";
1310
1415
  } else if (message?.role === "user" && !message?.optimistic && !message?.pendingPhase) {
@@ -1324,8 +1429,10 @@ function buildChatMessagePrefix(message) {
1324
1429
  ? "yellow"
1325
1430
  : message?.pendingPhase === "queued"
1326
1431
  ? "cyan"
1327
- : message?.pendingPhase === "cancelling"
1432
+ : message?.pendingPhase === "cancelling" || message?.pendingPhase === "rejected"
1328
1433
  ? "red"
1434
+ : fromOtherPerson
1435
+ ? OTHER_PERSON_CHAT_LABEL_COLOR
1329
1436
  : message?.role === "user"
1330
1437
  ? USER_CHAT_LABEL_COLOR
1331
1438
  : message?.role === "assistant"
@@ -1907,7 +2014,7 @@ function buildChatMessageLines(message, maxWidth, options = {}) {
1907
2014
  ? "red"
1908
2015
  : message?.role === "user" ? USER_CHAT_COLOR : null,
1909
2016
  );
1910
- const prefix = options.skipPrefix ? [] : buildChatMessagePrefix(message);
2017
+ const prefix = options.skipPrefix ? [] : buildChatMessagePrefix(message, options);
1911
2018
 
1912
2019
  if (tintedMarkdownLines.length === 0) {
1913
2020
  return prefix.length > 0 ? [prefix] : [];
@@ -2120,7 +2227,26 @@ export function selectChatLines(state, maxWidth = 80, options = {}) {
2120
2227
  return [{ text: "No messages yet.", color: "gray" }];
2121
2228
  }
2122
2229
 
2123
- const buildOptions = options?.tableMode ? { tableMode: options.tableMode } : {};
2230
+ // The current viewer's identity key a user.message whose sender differs
2231
+ // is labeled with the sender's name (others) vs "You" (the viewer). The
2232
+ // session owner's messages additionally carry an "(owner)" tag.
2233
+ const viewerKey = ownerKeyForOwner(state?.auth?.principal);
2234
+ const activeSessionId = state?.sessions?.activeSessionId;
2235
+ const activeSession = activeSessionId ? state?.sessions?.byId?.[activeSessionId] : null;
2236
+ const ownerKey = ownerKeyForOwner(activeSession?.owner);
2237
+ // A shared context = the session is shared deployment-wide, or the viewer
2238
+ // is not its owner (someone shared it with them). Only then does the
2239
+ // transcript name speakers and tag the owner.
2240
+ const sharedContext = Boolean(activeSession && (
2241
+ (activeSession.visibility && activeSession.visibility !== "private")
2242
+ || (ownerKey && viewerKey && ownerKey !== viewerKey)
2243
+ ));
2244
+ const buildOptions = {
2245
+ ...(options?.tableMode ? { tableMode: options.tableMode } : {}),
2246
+ ...(viewerKey ? { viewerKey } : {}),
2247
+ ...(ownerKey ? { ownerKey } : {}),
2248
+ ...(sharedContext ? { sharedContext: true } : {}),
2249
+ };
2124
2250
  const lines = [];
2125
2251
  for (const [index, message] of messages.entries()) {
2126
2252
  const messageLines = buildChatMessageLines(message, maxWidth, buildOptions);
@@ -2145,10 +2271,12 @@ export function selectOutboxOverlayLines(state, maxWidth = 80, options = {}) {
2145
2271
  const queuedCount = messages.filter((message) => message.pendingPhase === "queued").length;
2146
2272
  const pendingCount = messages.filter((message) => message.pendingPhase === "pending").length;
2147
2273
  const cancellingCount = messages.filter((message) => message.pendingPhase === "cancelling").length;
2274
+ const rejectedCount = messages.filter((message) => message.pendingPhase === "rejected").length;
2148
2275
  const parts = [];
2149
2276
  if (pendingCount > 0) parts.push(`${pendingCount} pending`);
2150
2277
  if (queuedCount > 0) parts.push(`${queuedCount} queued`);
2151
2278
  if (cancellingCount > 0) parts.push(`${cancellingCount} cancelling`);
2279
+ if (rejectedCount > 0) parts.push(`${rejectedCount} rejected`);
2152
2280
  const label = parts.length > 0 ? parts.join(" · ") : "queued prompts";
2153
2281
  const labelText = ` queued prompts: ${label} `;
2154
2282
  const rightRule = Math.max(1, safeWidth - labelText.length);
@@ -2969,6 +3097,12 @@ export function selectStatusBar(state) {
2969
3097
  right: "type title · left/right move · enter save · esc cancel",
2970
3098
  };
2971
3099
  }
3100
+ if (state.ui.modal?.type === "shareSession") {
3101
+ return {
3102
+ left: "Share the selected session",
3103
+ right: "name [r|w] grants · -name revokes · enter apply · esc close",
3104
+ };
3105
+ }
2972
3106
  if (state.ui.modal?.type === "artifactPicker") {
2973
3107
  return {
2974
3108
  left: "Select a linked artifact or URL",
@@ -3040,7 +3174,7 @@ export function selectStatusBar(state) {
3040
3174
  const chatViewMode = state.ui?.chatViewMode === "summary" ? "summary" : "transcript";
3041
3175
  const chatViewHint = chatViewMode === "summary" ? "s transcript" : "s summary";
3042
3176
  const hints = {
3043
- [FOCUS_REGIONS.SESSIONS]: `up/down switch · ctrl-u/ctrl-d page · ctrl-g move group · f filter · P pin · V select · d done · D delete · r refresh · t title · ${fullscreenHint} · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
3177
+ [FOCUS_REGIONS.SESSIONS]: `up/down switch · ctrl-u/ctrl-d page · ctrl-g move group · f filter · P pin · V select · d done · D delete · r refresh · t title · v visibility · S share · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
3044
3178
  [FOCUS_REGIONS.CHAT]: `${chatViewHint} · j/k scroll · ctrl-u/ctrl-d page · e older history · g/G top/bottom · d done · ${fullscreenHint} · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane · p prompt`,
3045
3179
  [FOCUS_REGIONS.INSPECTOR]: state.ui.inspectorTab === "logs"
3046
3180
  ? `j/k scroll · ctrl-u/ctrl-d page · g/G top/bottom · d done · t tail · f filter · ${fullscreenHint} · left/right tab · [/] resize pane · {/} columns · T themes · ? help · a linked items · drag copy · tab next pane`
@@ -4324,6 +4458,59 @@ export function selectRenameSessionModal(state, maxWidth = 76) {
4324
4458
  };
4325
4459
  }
4326
4460
 
4461
+ export function selectShareSessionModal(state, maxWidth = 76) {
4462
+ const modal = state.ui.modal;
4463
+ if (!modal || modal.type !== "shareSession") return null;
4464
+
4465
+ const value = String(modal.value || "");
4466
+ const shares = Array.isArray(modal.shares) ? modal.shares : [];
4467
+ const detailsLines = [
4468
+ [{
4469
+ text: "General: ",
4470
+ color: "gray",
4471
+ }, {
4472
+ text: String(modal.visibility || "private"),
4473
+ color: "white",
4474
+ bold: true,
4475
+ }],
4476
+ ...(shares.length === 0
4477
+ ? [[{ text: "No individual grants.", color: "gray" }]]
4478
+ : shares.map((row) => [{
4479
+ text: `${row.displayName || row.subject} `,
4480
+ color: "white",
4481
+ }, {
4482
+ text: `can ${row.access}`,
4483
+ color: "gray",
4484
+ }])),
4485
+ ];
4486
+
4487
+ return {
4488
+ title: modal.title || "Share Session",
4489
+ value,
4490
+ cursorIndex: Math.max(0, Math.min(Number(modal.cursorIndex) || 0, value.length)),
4491
+ placeholder: "Name, email, or id",
4492
+ helpTitle: "Share Rules",
4493
+ helpLines: [
4494
+ [{
4495
+ text: "name-or-email [r|w] grants · -name revokes · Enter apply · Esc close",
4496
+ color: "gray",
4497
+ }],
4498
+ [{
4499
+ text: "Grantee needn't have signed in — an email grant binds at their first sign-in.",
4500
+ color: "gray",
4501
+ }],
4502
+ ],
4503
+ detailsLines,
4504
+ idealWidth: Math.min(
4505
+ Math.max(
4506
+ 56,
4507
+ ...detailsLines.map((line) => flattenRunsLength(line) + 6),
4508
+ ),
4509
+ maxWidth,
4510
+ ),
4511
+ };
4512
+ }
4513
+
4327
4514
  export function selectSessionGroupNameModal(state, maxWidth = 76) {
4328
4515
  const modal = state.ui.modal;
4329
4516
  if (!modal || modal.type !== "sessionGroupName") return null;
@@ -4517,6 +4704,7 @@ function isOwnerFilterItemSelected(item, ownerFilter = {}, auth = {}) {
4517
4704
  if (item.kind === "system") return ownerFilter?.includeSystem === true;
4518
4705
  if (item.kind === "unowned") return ownerFilter?.includeUnowned === true;
4519
4706
  if (item.kind === "me") return ownerFilter?.includeMe === true && Boolean(ownerKeyForOwner(auth?.principal));
4707
+ if (item.kind === "shared") return ownerFilter?.includeShared === true;
4520
4708
  if (item.kind === "owner") return Array.isArray(ownerFilter?.ownerKeys) && ownerFilter.ownerKeys.includes(item.ownerKey);
4521
4709
  return false;
4522
4710
  }
@@ -5825,7 +6013,7 @@ const KEYBINDING_HELP = [
5825
6013
  ["p", "focus the prompt"],
5826
6014
  ["[ ]", "shrink / grow the focused pane"],
5827
6015
  ["{ }", "grow left / right column"],
5828
- ["v", "fullscreen the focused pane"],
6016
+ ["v", "fullscreen the focused pane (sessions pane: cycle visibility)"],
5829
6017
  ["n / r", "new session / refresh"],
5830
6018
  ["a", "linked items — artifacts to download, links to open"],
5831
6019
  ["m", "cycle inspector tab"],
@@ -5843,6 +6031,8 @@ const KEYBINDING_HELP = [
5843
6031
  ["+ / -", "expand / collapse subtree"],
5844
6032
  ["t", "rename"],
5845
6033
  ["P", "pin / unpin"],
6034
+ ["v", "cycle visibility (private / shared read / shared write)"],
6035
+ ["S", "share — grant / revoke individual access"],
5846
6036
  ["V / space", "select mode / toggle selection"],
5847
6037
  ["f", "filter"],
5848
6038
  ] },
@@ -13,6 +13,7 @@ export function normalizeSessionOwnerFilter(filter) {
13
13
  includeSystem: filter?.includeSystem === true,
14
14
  includeUnowned: filter?.includeUnowned === true,
15
15
  includeMe: filter?.includeMe === true,
16
+ includeShared: filter?.includeShared === true,
16
17
  ownerKeys,
17
18
  };
18
19
  }
@@ -180,6 +181,8 @@ export function createInitialState({ mode = "local", branding = null, themeId =
180
181
  filterQuery: "",
181
182
  ownerFilterExplicit: hasStoredSessionOwnerFilter,
182
183
  ownerFilter: normalizeSessionOwnerFilter(sessionOwnerFilter),
184
+ navigationIntent: null,
185
+ filterExceptionId: null,
183
186
  },
184
187
  history: {
185
188
  bySessionId: new Map(),
@@ -57,6 +57,10 @@ export function createTheme({ id, label, description, page, terminal, tui = {} }
57
57
  cyan: terminal?.cyan || "#55ffff",
58
58
  userChat: "#ffd866",
59
59
  userChatLabel: "#ffec99",
60
+ // Speaker label for a message from ANOTHER person in a shared session
61
+ // (distinct from the viewer's gold "You", the agent's green, and
62
+ // System's yellow). Every theme inherits this; a theme may override it.
63
+ otherUserChatLabel: terminal?.brightMagenta || terminal?.magenta || "#c9a3ff",
60
64
  activeHighlightBackground: terminal?.blue || "#5555ff",
61
65
  activeHighlightForeground: terminal?.background || "#000000",
62
66
  selectionBackground: terminal?.cursor || terminal?.blue || "#5555ff",