pilotswarm 0.5.14 → 0.5.16

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 (34) hide show
  1. package/mcp/README.md +2 -2
  2. package/mcp/dist/src/server.d.ts.map +1 -1
  3. package/mcp/dist/src/server.js +5 -1
  4. package/mcp/dist/src/server.js.map +1 -1
  5. package/mcp/dist/src/tools/capabilities.d.ts +1 -0
  6. package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
  7. package/mcp/dist/src/tools/capabilities.js +5 -0
  8. package/mcp/dist/src/tools/capabilities.js.map +1 -1
  9. package/mcp/dist/src/tools/groups.d.ts +5 -4
  10. package/mcp/dist/src/tools/groups.d.ts.map +1 -1
  11. package/mcp/dist/src/tools/groups.js +43 -20
  12. package/mcp/dist/src/tools/groups.js.map +1 -1
  13. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  14. package/mcp/dist/src/tools/sessions.js +4 -2
  15. package/mcp/dist/src/tools/sessions.js.map +1 -1
  16. package/package.json +2 -2
  17. package/tui/plugins/.mcp.json +7 -1
  18. package/tui/plugins/agents/deepwiki.agent.md +44 -0
  19. package/tui/src/node-sdk-transport.js +61 -8
  20. package/ui/core/src/controller.js +264 -40
  21. package/ui/core/src/formatting.js +25 -0
  22. package/ui/core/src/reducer.js +117 -8
  23. package/ui/core/src/selectors.js +76 -3
  24. package/ui/core/src/state.js +3 -0
  25. package/ui/react/src/web-app.js +363 -127
  26. package/web/api/router.js +3 -1
  27. package/web/dist/assets/index-Df1mD9p0.js +24 -0
  28. package/web/dist/assets/index-whIft7M6.css +1 -0
  29. package/web/dist/assets/pilotswarm-DR5j-z8y.js +90 -0
  30. package/web/dist/index.html +3 -3
  31. package/web/runtime.js +114 -42
  32. package/web/dist/assets/index-BnxC8cNG.js +0 -24
  33. package/web/dist/assets/index-Bx6KHIaj.css +0 -1
  34. package/web/dist/assets/pilotswarm-NE7H63ha.js +0 -90
@@ -92,7 +92,9 @@ function normalizeSessionListRow(session) {
92
92
  if (session.isGroup) return session;
93
93
  return {
94
94
  ...session,
95
- groupId: session.groupId ?? null,
95
+ // The wire DTO carries the viewer-private placement as viewerGroupId;
96
+ // the local groupId field is what the tree/selectors key off.
97
+ groupId: session.viewerGroupId ?? null,
96
98
  parentSessionId: session.parentSessionId ?? null,
97
99
  };
98
100
  }
@@ -101,6 +103,30 @@ function sessionGroupIdFromRowId(sessionId) {
101
103
  return String(sessionId || "").startsWith("group:") ? String(sessionId).slice("group:".length) : null;
102
104
  }
103
105
 
106
+ // Per-row placement skip reasons ('system', 'not_found') folded into a short
107
+ // status-bar suffix, e.g. "2 system, 1 not found".
108
+ function summarizePlacementSkips(rows) {
109
+ const counts = new Map();
110
+ for (const row of rows || []) {
111
+ const label = row?.reason === "system" ? "system" : "not found";
112
+ counts.set(label, (counts.get(label) || 0) + 1);
113
+ }
114
+ return [...counts.entries()].map(([label, count]) => `${count} ${label}`).join(", ");
115
+ }
116
+
117
+ // Deep-link load failures split into two renderable kinds: a definitive
118
+ // not-found/no-access answer from the server (the API deliberately returns
119
+ // identical shapes for unknown vs unshared sessions), and everything else —
120
+ // network/server faults — which stays retryable.
121
+ function classifyNavigationLoadError(error) {
122
+ const status = Number(error?.status);
123
+ const code = String(error?.code || "").toUpperCase();
124
+ if (status === 404 || status === 403 || code === "NOT_FOUND" || code === "FORBIDDEN") {
125
+ return "not_found";
126
+ }
127
+ return "network";
128
+ }
129
+
104
130
  async function loadSessionCatalogPageWindow(transport) {
105
131
  if (typeof transport.listSessionsPage !== "function") {
106
132
  return transport.listSessions();
@@ -190,6 +216,22 @@ const CONTEXT_TIER_LABELS = {
190
216
  long_context: "Long context (larger window, higher cost)",
191
217
  };
192
218
 
219
+ function formatContextWindowSize(value) {
220
+ const tokens = Number(value);
221
+ if (!Number.isSafeInteger(tokens) || tokens <= 0) return null;
222
+ if (tokens % 1_000_000 === 0) return `${tokens / 1_000_000}M`;
223
+ if (tokens % 1_000 === 0) return `${tokens / 1_000}K`;
224
+ return tokens.toLocaleString("en-US");
225
+ }
226
+
227
+ function formatContextTierLabel(tier, tokenLimit) {
228
+ const size = formatContextWindowSize(tokenLimit);
229
+ if (!size) return CONTEXT_TIER_LABELS[tier] || tier;
230
+ return tier === "long_context"
231
+ ? `Long context (${size} tokens, higher cost)`
232
+ : `Default (${size} tokens)`;
233
+ }
234
+
193
235
  function extractSessionModelFromEvents(events = []) {
194
236
  // Only explicit model-change events may update the session's model.
195
237
  // Deriving it from any event that happens to carry a `model` field lets
@@ -506,6 +548,12 @@ function buildSessionOwnerFilterItems(state) {
506
548
  ownerKey: principalKey,
507
549
  description: "Show sessions owned by the authenticated user currently signed in.",
508
550
  });
551
+ items.push({
552
+ id: "shared",
553
+ kind: "shared",
554
+ label: "Shared with me",
555
+ description: "Show sessions other users have shared with you.",
556
+ });
509
557
  }
510
558
 
511
559
  const ownersByKey = new Map();
@@ -542,6 +590,7 @@ export function defaultOwnerFilterForPrincipal(principal) {
542
590
  includeSystem: true,
543
591
  includeUnowned: false,
544
592
  includeMe: true,
593
+ includeShared: true,
545
594
  ownerKeys: [],
546
595
  }
547
596
  : {
@@ -549,6 +598,7 @@ export function defaultOwnerFilterForPrincipal(principal) {
549
598
  includeSystem: false,
550
599
  includeUnowned: false,
551
600
  includeMe: false,
601
+ includeShared: false,
552
602
  ownerKeys: [],
553
603
  };
554
604
  }
@@ -558,6 +608,7 @@ function ownerFilterHasSelections(filter) {
558
608
  filter?.includeSystem
559
609
  || filter?.includeUnowned
560
610
  || filter?.includeMe
611
+ || filter?.includeShared
561
612
  || (Array.isArray(filter?.ownerKeys) && filter.ownerKeys.length > 0),
562
613
  );
563
614
  }
@@ -571,6 +622,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
571
622
  includeSystem: false,
572
623
  includeUnowned: false,
573
624
  includeMe: false,
625
+ includeShared: false,
574
626
  ownerKeys: [],
575
627
  };
576
628
  }
@@ -583,6 +635,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
583
635
  if (item.kind === "system") next.includeSystem = !next.includeSystem;
584
636
  if (item.kind === "unowned") next.includeUnowned = !next.includeUnowned;
585
637
  if (item.kind === "me") next.includeMe = !next.includeMe;
638
+ if (item.kind === "shared") next.includeShared = !next.includeShared;
586
639
  if (item.kind === "owner" && item.ownerKey) {
587
640
  const existingIndex = next.ownerKeys.indexOf(item.ownerKey);
588
641
  if (existingIndex >= 0) {
@@ -594,7 +647,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
594
647
 
595
648
  return ownerFilterHasSelections(next)
596
649
  ? next
597
- : { all: true, includeSystem: false, includeUnowned: false, includeMe: false, ownerKeys: [] };
650
+ : { all: true, includeSystem: false, includeUnowned: false, includeMe: false, includeShared: false, ownerKeys: [] };
598
651
  }
599
652
 
600
653
  function getRenameSessionPrefix(session) {
@@ -1066,6 +1119,7 @@ export class PilotSwarmUiController {
1066
1119
  text: String(prompt || ""),
1067
1120
  createdAt: Date.now(),
1068
1121
  phase: normalizedPhase,
1122
+ attempted: false,
1069
1123
  clientMessageIds: [id],
1070
1124
  };
1071
1125
  }
@@ -1085,9 +1139,9 @@ export class PilotSwarmUiController {
1085
1139
  }
1086
1140
 
1087
1141
  getEditableOutboxItems(sessionId) {
1088
- // Only pending items are editable/cancelable on the client; queued items
1089
- // are durable and need a durable cancel API to remove.
1090
- return this.getPendingOutboxItems(sessionId);
1142
+ // Attempted envelopes are immutable because the server may already
1143
+ // have accepted their IDs even when the client saw a transport error.
1144
+ return this.getPendingOutboxItems(sessionId).filter((item) => item?.attempted !== true);
1091
1145
  }
1092
1146
 
1093
1147
  getPromptEditSessionMatch(sessionId = this.getState().sessions.activeSessionId) {
@@ -1375,7 +1429,11 @@ export class PilotSwarmUiController {
1375
1429
  return false;
1376
1430
  }
1377
1431
 
1378
- const pendingItems = this.getPendingOutboxItems(sessionId);
1432
+ const allPendingItems = this.getPendingOutboxItems(sessionId);
1433
+ const attemptedItem = allPendingItems.find((item) => item?.attempted === true);
1434
+ const pendingItems = attemptedItem
1435
+ ? [attemptedItem]
1436
+ : allPendingItems.filter((item) => item?.attempted !== true);
1379
1437
  if (pendingItems.length === 0) return false;
1380
1438
 
1381
1439
  // Merge all current pending items into a single durable envelope.
@@ -1393,6 +1451,7 @@ export class PilotSwarmUiController {
1393
1451
  text: mergedText,
1394
1452
  createdAt: pendingItems[0].createdAt || Date.now(),
1395
1453
  phase: "pending",
1454
+ attempted: true,
1396
1455
  clientMessageIds: mergedClientMessageIds,
1397
1456
  };
1398
1457
  const pendingIdSet = new Set(pendingItems.map((item) => item.id));
@@ -1443,10 +1502,11 @@ export class PilotSwarmUiController {
1443
1502
  }
1444
1503
  }, 6000);
1445
1504
  } else {
1446
- // Transient failure: revert the merged envelope back to the
1447
- // original pending items so the user can edit/retry them.
1448
- const reverted = items.flatMap((item) => (
1449
- item.id === mergedItem.id ? pendingItems : [item]
1505
+ // Transient failure: preserve the exact attempted envelope.
1506
+ // Re-merging it with fresh messages could make server-side
1507
+ // duplicate suppression drop the fresh content too.
1508
+ const reverted = items.map((item) => (
1509
+ item.id === mergedItem.id ? { ...mergedItem, phase: "pending", attempted: true } : item
1450
1510
  ));
1451
1511
  this.setSessionOutboxItems(sessionId, reverted);
1452
1512
  }
@@ -1696,7 +1756,7 @@ export class PilotSwarmUiController {
1696
1756
  }).catch(() => {});
1697
1757
  }
1698
1758
 
1699
- async start() {
1759
+ async start({ initialSessionId = null } = {}) {
1700
1760
  await this.transport.start();
1701
1761
  const authContext = typeof this.transport.getAuthContext === "function"
1702
1762
  ? this.transport.getAuthContext()
@@ -1727,6 +1787,13 @@ export class PilotSwarmUiController {
1727
1787
  workersOnline: typeof this.transport.getWorkerCount === "function" ? this.transport.getWorkerCount() : null,
1728
1788
  statusText: "Connected",
1729
1789
  });
1790
+ // Latch the deep-link intent AFTER the default owner-filter dispatch
1791
+ // (a filter change releases the latch) and BEFORE the first refresh,
1792
+ // so the first catalog load resolves selection onto the link target.
1793
+ const deepLinkSessionId = String(initialSessionId || "").trim();
1794
+ if (deepLinkSessionId) {
1795
+ this.dispatch({ type: "sessions/navigationIntent", sessionId: deepLinkSessionId });
1796
+ }
1730
1797
  await this.refreshSessions();
1731
1798
  this.catalogTimer = setInterval(() => {
1732
1799
  this.refreshSessions().catch((error) => {
@@ -1782,6 +1849,36 @@ export class PilotSwarmUiController {
1782
1849
  sessions = [...groupRows, ...sessions];
1783
1850
  }
1784
1851
  }
1852
+ // A pending deep-link target may be readable but absent from the
1853
+ // paged catalog window — fetch it explicitly. A definitive failure
1854
+ // (unknown or unshared: identical 404 shapes) fails the intent; the
1855
+ // reducer then refuses fallback selection so the renderer can show
1856
+ // the nav error instead of silently landing somewhere else.
1857
+ const pendingIntent = preRefreshState.sessions.navigationIntent;
1858
+ if (
1859
+ pendingIntent?.status === "pending"
1860
+ && !sessions.some((session) => session?.sessionId === pendingIntent.sessionId)
1861
+ && typeof this.transport.getSession === "function"
1862
+ ) {
1863
+ try {
1864
+ const intentSession = await this.transport.getSession(pendingIntent.sessionId);
1865
+ if (intentSession?.sessionId) {
1866
+ sessions = [...sessions, normalizeSessionListRow(intentSession)];
1867
+ } else {
1868
+ this.dispatch({
1869
+ type: "sessions/navigationIntentFailed",
1870
+ sessionId: pendingIntent.sessionId,
1871
+ errorKind: "not_found",
1872
+ });
1873
+ }
1874
+ } catch (error) {
1875
+ this.dispatch({
1876
+ type: "sessions/navigationIntentFailed",
1877
+ sessionId: pendingIntent.sessionId,
1878
+ errorKind: classifyNavigationLoadError(error),
1879
+ });
1880
+ }
1881
+ }
1785
1882
  const active = previousActive;
1786
1883
  if (
1787
1884
  active
@@ -1807,7 +1904,20 @@ export class PilotSwarmUiController {
1807
1904
  if (selected) {
1808
1905
  if (selected !== previousActive) {
1809
1906
  if (!this.getState().sessions.byId[selected]?.isGroup) {
1810
- await this.loadSession(selected);
1907
+ const selectedIntent = this.getState().sessions.navigationIntent;
1908
+ if (selectedIntent && selectedIntent.sessionId === selected && selectedIntent.status !== "failed") {
1909
+ try {
1910
+ await this.loadSession(selected);
1911
+ } catch (error) {
1912
+ this.dispatch({
1913
+ type: "sessions/navigationIntentFailed",
1914
+ sessionId: selected,
1915
+ errorKind: classifyNavigationLoadError(error),
1916
+ });
1917
+ }
1918
+ } else {
1919
+ await this.loadSession(selected);
1920
+ }
1811
1921
  }
1812
1922
  return;
1813
1923
  }
@@ -3019,6 +3129,32 @@ export class PilotSwarmUiController {
3019
3129
  });
3020
3130
  }
3021
3131
 
3132
+ /**
3133
+ * Latch a navigation intent (deep link) onto a session id. The intent
3134
+ * outranks in-memory selection and the profile's activeSessionId until
3135
+ * the user navigates manually or changes a filter. Also the retry path
3136
+ * for a failed intent: re-latching resets it to pending.
3137
+ */
3138
+ setNavigationIntent(sessionId) {
3139
+ const id = String(sessionId || "").trim();
3140
+ if (!id) return;
3141
+ this.dispatch({ type: "sessions/navigationIntent", sessionId: id });
3142
+ const state = this.getState();
3143
+ if (state.sessions.byId[id]) {
3144
+ this.loadSession(id).catch((error) => {
3145
+ this.dispatch({
3146
+ type: "sessions/navigationIntentFailed",
3147
+ sessionId: id,
3148
+ errorKind: classifyNavigationLoadError(error),
3149
+ });
3150
+ });
3151
+ return;
3152
+ }
3153
+ if (Object.keys(state.sessions.byId).length > 0) {
3154
+ this.scheduleSessionsRefresh(0);
3155
+ }
3156
+ }
3157
+
3022
3158
  async loadSession(sessionId) {
3023
3159
  if (!sessionId) return;
3024
3160
  const active = this.getState().sessions.activeSessionId;
@@ -3194,7 +3330,9 @@ export class PilotSwarmUiController {
3194
3330
 
3195
3331
  async createSession(options = {}) {
3196
3332
  try {
3197
- const created = await this.transport.createSession(this.applyActiveGroupDefault(options));
3333
+ const requestOptions = this.applyActiveGroupDefault(options);
3334
+ const created = await this.transport.createSession(requestOptions);
3335
+ await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
3198
3336
  await this.refreshSessions();
3199
3337
  await this.loadSession(created.sessionId);
3200
3338
  this.setFocus(FOCUS_REGIONS.PROMPT);
@@ -3211,7 +3349,9 @@ export class PilotSwarmUiController {
3211
3349
  throw new Error("Named-agent session creation is not supported by this transport");
3212
3350
  }
3213
3351
  try {
3214
- const created = await this.transport.createSessionForAgent(agentName, this.applyActiveGroupDefault(options));
3352
+ const requestOptions = this.applyActiveGroupDefault(options);
3353
+ const created = await this.transport.createSessionForAgent(agentName, requestOptions);
3354
+ await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
3215
3355
  await this.refreshSessions();
3216
3356
  await this.loadSession(created.sessionId);
3217
3357
  this.setFocus(FOCUS_REGIONS.PROMPT);
@@ -3226,6 +3366,20 @@ export class PilotSwarmUiController {
3226
3366
  }
3227
3367
  }
3228
3368
 
3369
+ /**
3370
+ * Post-create placement follow-up. createSession's groupId only places
3371
+ * when an owner principal reaches the catalog (web mode places
3372
+ * server-side; the local TUI passes no owner), so when a group was
3373
+ * requested, place explicitly — idempotent where the server already did.
3374
+ * Skipped when the create response already reports the placement.
3375
+ */
3376
+ async placeCreatedSessionInGroup(created, groupId) {
3377
+ if (!groupId || !created?.sessionId) return;
3378
+ if (typeof this.transport.placeSessionsInGroup !== "function") return;
3379
+ if ((created.viewerGroupId ?? null) === groupId) return;
3380
+ await this.transport.placeSessionsInGroup([created.sessionId], groupId).catch(() => {});
3381
+ }
3382
+
3229
3383
  getMovableGroupSessionSelection() {
3230
3384
  const state = this.getState();
3231
3385
  const selectedIds = Array.isArray(state.sessions.selectedIds) && state.sessions.selectedIds.length > 0
@@ -3249,16 +3403,12 @@ export class PilotSwarmUiController {
3249
3403
  return null;
3250
3404
  }
3251
3405
 
3406
+ // The server returns only the viewer's own groups, and placement is
3407
+ // viewer-private — any readable non-system selection is movable, so
3408
+ // mixed-owner selections are allowed and every group is offered.
3252
3409
  const groups = await this.transport.listSessionGroups().catch(() => []);
3253
3410
  const sessionIds = eligible.map((session) => session.sessionId);
3254
3411
  const firstGroupId = eligible.length === 1 ? eligible[0].groupId || null : null;
3255
- const selectedOwnerKeys = new Set(eligible.map((session) => ownerKeyForPrincipal(session.owner) || ""));
3256
- const singleOwnerKey = selectedOwnerKeys.size === 1 ? [...selectedOwnerKeys][0] : null;
3257
- const singleOwner = selectedOwnerKeys.size === 1 ? eligible[0]?.owner ?? null : null;
3258
- const compatibleGroups = selectedOwnerKeys.size === 1
3259
- ? (Array.isArray(groups) ? groups : []).filter((group) => (ownerKeyForPrincipal(group.owner) || "") === singleOwnerKey)
3260
- : [];
3261
- const canCreateOrAssignGroup = selectedOwnerKeys.size === 1;
3262
3412
  const items = [
3263
3413
  {
3264
3414
  id: "__no_group__",
@@ -3268,22 +3418,20 @@ export class PilotSwarmUiController {
3268
3418
  groupId: null,
3269
3419
  memberCount: 0,
3270
3420
  },
3271
- ...(canCreateOrAssignGroup ? [{
3421
+ {
3272
3422
  id: "__new_group__",
3273
3423
  kind: "newGroup",
3274
3424
  label: "[New Group]",
3275
- description: "Create a new group for this owner, then move the selected session(s) into it.",
3425
+ description: "Create a new group, then move the selected session(s) into it.",
3276
3426
  groupId: null,
3277
3427
  memberCount: 0,
3278
- owner: singleOwner,
3279
- }] : []),
3280
- ...compatibleGroups.map((group) => ({
3428
+ },
3429
+ ...(Array.isArray(groups) ? groups : []).map((group) => ({
3281
3430
  id: group.groupId,
3282
3431
  kind: "group",
3283
3432
  label: group.title || group.groupId,
3284
3433
  description: group.description || "Move selected session(s) into this group.",
3285
3434
  groupId: group.groupId,
3286
- owner: group.owner ?? null,
3287
3435
  memberCount: group.memberCount ?? 0,
3288
3436
  })),
3289
3437
  ];
@@ -3305,9 +3453,7 @@ export class PilotSwarmUiController {
3305
3453
  });
3306
3454
  this.dispatch({
3307
3455
  type: "ui/status",
3308
- text: canCreateOrAssignGroup
3309
- ? "Choose a group, [New Group], or [No Group]"
3310
- : "Selected sessions have different owners; only [No Group] is available",
3456
+ text: "Choose a group, [New Group], or [No Group]",
3311
3457
  });
3312
3458
  return items;
3313
3459
  }
@@ -3322,7 +3468,10 @@ export class PilotSwarmUiController {
3322
3468
  this.dispatch({ type: "ui/status", text: "No sessions selected to move" });
3323
3469
  return null;
3324
3470
  }
3325
- if (typeof this.transport.moveSessionsToGroup === "function") {
3471
+ let placementResults = null;
3472
+ if (typeof this.transport.placeSessionsInGroup === "function") {
3473
+ placementResults = await this.transport.placeSessionsInGroup(ids, groupId ?? null);
3474
+ } else if (typeof this.transport.moveSessionsToGroup === "function") {
3326
3475
  await this.transport.moveSessionsToGroup(groupId ?? null, ids);
3327
3476
  } else if (groupId && typeof this.transport.assignSessionsToGroup === "function") {
3328
3477
  await this.transport.assignSessionsToGroup(groupId, ids);
@@ -3331,17 +3480,28 @@ export class PilotSwarmUiController {
3331
3480
  return null;
3332
3481
  }
3333
3482
 
3483
+ const resultRows = Array.isArray(placementResults) ? placementResults.filter(Boolean) : null;
3484
+ const skippedRows = resultRows ? resultRows.filter((row) => row.placed !== true) : [];
3485
+ const placedCount = resultRows
3486
+ ? resultRows.filter((row) => row.placed === true).length
3487
+ : ids.length;
3488
+
3334
3489
  this.dispatch({ type: "sessions/selectClear" });
3335
3490
  await this.refreshSessions();
3336
- if (groupId) {
3491
+ if (groupId && placedCount > 0) {
3337
3492
  await this.loadSession(`group:${groupId}`).catch(() => {});
3338
3493
  }
3339
3494
  const target = groupId ? `group ${statusTitle || groupId}` : "No Group";
3495
+ const skippedSummary = skippedRows.length > 0
3496
+ ? ` · skipped ${skippedRows.length} (${summarizePlacementSkips(skippedRows)})`
3497
+ : "";
3340
3498
  this.dispatch({
3341
3499
  type: "ui/status",
3342
- text: `Moved ${ids.length} session${ids.length === 1 ? "" : "s"} to ${target}`,
3500
+ text: placedCount > 0
3501
+ ? `Moved ${placedCount} session${placedCount === 1 ? "" : "s"} to ${target}${skippedSummary}`
3502
+ : `No sessions moved to ${target}${skippedSummary}`,
3343
3503
  });
3344
- return true;
3504
+ return placedCount > 0;
3345
3505
  }
3346
3506
 
3347
3507
  async confirmSessionGroupPickerModal() {
@@ -3360,7 +3520,6 @@ export class PilotSwarmUiController {
3360
3520
  title: "New Group",
3361
3521
  previousFocus: modal.previousFocus,
3362
3522
  sessionIds: modal.sessionIds || [],
3363
- owner: item.owner ?? null,
3364
3523
  value: baseTitle,
3365
3524
  cursorIndex: baseTitle.length,
3366
3525
  maxLength: 80,
@@ -3440,11 +3599,14 @@ export class PilotSwarmUiController {
3440
3599
  if (previousFocus) this.setFocus(previousFocus);
3441
3600
 
3442
3601
  try {
3602
+ // No owner key: the transport/server stamps the caller's
3603
+ // principal. The UI never infers group ownership from the
3604
+ // selected sessions (owner: null is reserved for the portal
3605
+ // runtime's anonymous path).
3443
3606
  const group = await this.transport.createSessionGroup({
3444
3607
  title,
3445
3608
  description: `${(modal.sessionIds || []).length} grouped session${(modal.sessionIds || []).length === 1 ? "" : "s"}`,
3446
3609
  sessionIds: modal.sessionIds || [],
3447
- owner: modal.owner ?? null,
3448
3610
  });
3449
3611
  await this.moveSessionsToGroup(group.groupId, modal.sessionIds || [], { statusTitle: group.title || title });
3450
3612
  } catch (error) {
@@ -3624,7 +3786,8 @@ export class PilotSwarmUiController {
3624
3786
  const items = supported.map((tier) => ({
3625
3787
  id: tier,
3626
3788
  tier,
3627
- label: CONTEXT_TIER_LABELS[tier] || tier,
3789
+ tokenLimit: modelItem?.contextWindowSizes?.[tier] || null,
3790
+ label: formatContextTierLabel(tier, modelItem?.contextWindowSizes?.[tier]),
3628
3791
  isDefault: selectedTier === tier,
3629
3792
  }));
3630
3793
  const selectedIndex = Math.max(0, items.findIndex((item) => item.id === selectedTier));
@@ -3661,25 +3824,38 @@ export class PilotSwarmUiController {
3661
3824
  const groupedModels = typeof this.transport.getModelsByProvider === "function"
3662
3825
  ? this.transport.getModelsByProvider()
3663
3826
  : groupModelsByProvider(models);
3827
+ // Whether the current user has a per-user GitHub Copilot key:
3828
+ // true/false when known, null when the transport can't tell (then
3829
+ // no model gets disabled — never lock models out on a guess).
3830
+ const ghcpUserKeySet = await this._resolveGhcpUserKeySet();
3664
3831
  const items = [];
3665
3832
  const groups = groupedModels
3666
3833
  .map((group) => ({
3667
3834
  providerId: group.providerId,
3668
3835
  providerType: group.type || group.providerType,
3669
3836
  models: (group.models || []).map((model) => {
3837
+ const providerType = model.providerType || group.type || group.providerType;
3838
+ // Copilot models with no worker-level token are unusable
3839
+ // for users who haven't stored their own GitHub key.
3840
+ const ghcpKeyMissing = providerType === "github"
3841
+ && model.credentialAvailable === false
3842
+ && ghcpUserKeySet === false;
3670
3843
  const item = {
3671
3844
  id: model.qualifiedName,
3672
3845
  qualifiedName: model.qualifiedName,
3673
3846
  modelName: model.modelName || model.qualifiedName,
3674
3847
  providerId: model.providerId || group.providerId,
3675
- providerType: model.providerType || group.type || group.providerType,
3848
+ providerType,
3676
3849
  description: model.description || "",
3677
3850
  cost: model.cost || null,
3678
3851
  supportedReasoningEfforts: normalizeReasoningEfforts(model.supportedReasoningEfforts),
3679
3852
  defaultReasoningEffort: model.defaultReasoningEffort || null,
3680
3853
  supportedContextTiers: normalizeContextTiers(model.supportedContextTiers),
3681
3854
  defaultContextTier: model.defaultContextTier || null,
3855
+ contextWindowSizes: model.contextWindowSizes || null,
3682
3856
  isDefault: defaultModel === model.qualifiedName,
3857
+ ghcpKeyMissing,
3858
+ disabled: ghcpKeyMissing,
3683
3859
  };
3684
3860
  items.push(item);
3685
3861
  return item;
@@ -3688,7 +3864,12 @@ export class PilotSwarmUiController {
3688
3864
  .filter((group) => group.models.length > 0);
3689
3865
 
3690
3866
  const preferredModel = sessionOptions?.model || defaultModel;
3691
- const selectedIndex = Math.max(0, items.findIndex((model) => model.qualifiedName === preferredModel));
3867
+ // Never preselect a key-blocked Copilot model: when the preferred
3868
+ // model (often the catalog default) is unusable, land on the first
3869
+ // usable model instead.
3870
+ let selectedIndex = items.findIndex((model) => model.qualifiedName === preferredModel && !model.disabled);
3871
+ if (selectedIndex < 0) selectedIndex = items.findIndex((model) => !model.disabled);
3872
+ if (selectedIndex < 0) selectedIndex = 0;
3692
3873
  this.dispatch({
3693
3874
  type: "ui/modal",
3694
3875
  modal: {
@@ -3704,15 +3885,42 @@ export class PilotSwarmUiController {
3704
3885
  this.dispatch({ type: "ui/status", text: "Select a model and press Enter" });
3705
3886
  }
3706
3887
 
3707
- async openSwitchModelPicker() {
3888
+ async _resolveGhcpUserKeySet() {
3889
+ // Freshest signal first: the profile the Admin console loaded in this
3890
+ // session (updates immediately after the user saves a key there).
3891
+ const adminProfile = this.getState().admin?.profile;
3892
+ if (typeof adminProfile?.githubCopilotKeySet === "boolean") {
3893
+ return adminProfile.githubCopilotKeySet;
3894
+ }
3895
+ if (typeof this.transport.getCurrentUserProfile !== "function") return null;
3896
+ try {
3897
+ const profile = await this.transport.getCurrentUserProfile();
3898
+ if (profile && typeof profile.githubCopilotKeySet === "boolean") {
3899
+ return profile.githubCopilotKeySet;
3900
+ }
3901
+ // Profile fetch succeeded but no stored flag — a user with no
3902
+ // profile row has no key.
3903
+ return false;
3904
+ } catch {
3905
+ return null;
3906
+ }
3907
+ }
3908
+
3909
+ async openSwitchModelPicker(onApplied = null) {
3910
+ // Callers (e.g. the portal Manage modal) can be notified when a switch
3911
+ // is actually applied — distinct from cancelling the picker — so they
3912
+ // can close/reopen their own chrome accordingly.
3913
+ this._onSwitchModelApplied = typeof onApplied === "function" ? onApplied : null;
3708
3914
  const state = this.getState();
3709
3915
  const sessionId = state.sessions.activeSessionId;
3710
3916
  const session = sessionId ? state.sessions.byId[sessionId] || null : null;
3711
3917
  if (!session || session.isGroup) {
3918
+ this._onSwitchModelApplied = null;
3712
3919
  this.dispatch({ type: "ui/status", text: "Select a session before switching model" });
3713
3920
  return;
3714
3921
  }
3715
3922
  if (typeof this.transport.setSessionModel !== "function") {
3923
+ this._onSwitchModelApplied = null;
3716
3924
  this.dispatch({ type: "ui/status", text: "Model switching is not supported by this transport" });
3717
3925
  return;
3718
3926
  }
@@ -3745,6 +3953,11 @@ export class PilotSwarmUiController {
3745
3953
  const tierSuffix = options.contextTier ? ` · ${CONTEXT_TIER_LABELS[options.contextTier] || options.contextTier}` : "";
3746
3954
  this.dispatch({ type: "ui/status", text: `Next turn will use ${modelLabel}${tierSuffix}` });
3747
3955
  await this.refreshSessions();
3956
+ // Notify a Manage-modal-style caller that the switch was applied (vs
3957
+ // cancelled), so it can close rather than reopen.
3958
+ const onApplied = this._onSwitchModelApplied;
3959
+ this._onSwitchModelApplied = null;
3960
+ if (typeof onApplied === "function") onApplied();
3748
3961
  }
3749
3962
 
3750
3963
  openHelpModal() {
@@ -4753,6 +4966,16 @@ export class PilotSwarmUiController {
4753
4966
  }
4754
4967
  if (modal.type === "modelPicker") {
4755
4968
  const item = modal.items?.[modal.selectedIndex || 0];
4969
+ if (item?.disabled) {
4970
+ // Keep the picker open — the selection is unusable, not wrong.
4971
+ this.dispatch({
4972
+ type: "ui/status",
4973
+ text: item.ghcpKeyMissing
4974
+ ? "This model needs a GitHub Copilot key — add yours in the Admin console first"
4975
+ : "This model is not available",
4976
+ });
4977
+ return;
4978
+ }
4756
4979
  const previousFocus = modal.previousFocus;
4757
4980
  this.dispatch({ type: "ui/modal", modal: null });
4758
4981
  if (previousFocus) {
@@ -5093,6 +5316,7 @@ export class PilotSwarmUiController {
5093
5316
  const items = this.getSessionOutbox(currentUi.promptEdit.sessionId);
5094
5317
  const nextItems = items.map((item) => (
5095
5318
  item.id === currentUi.promptEdit.itemId && item.phase === "pending"
5319
+ && item.attempted !== true
5096
5320
  ? { ...item, text: nextPrompt }
5097
5321
  : item
5098
5322
  ));
@@ -992,6 +992,7 @@ export function buildMessageCardLines({
992
992
  borderColor = "gray",
993
993
  bodyColor = null,
994
994
  fitToContent = false,
995
+ tableMode = null,
995
996
  } = {}) {
996
997
  const maxWidth = Math.max(fitToContent ? 12 : 20, Number(width) || (fitToContent ? 12 : 20));
997
998
  const maxContentWidth = Math.max(1, maxWidth - 4);
@@ -999,6 +1000,30 @@ export function buildMessageCardLines({
999
1000
  { text: ` ${String(title || "SYSTEM").toUpperCase()} `, color: titleColor, bold: true },
1000
1001
  ...(timestamp ? [{ text: ` ${String(timestamp)} `, color: "gray" }] : []),
1001
1002
  ];
1003
+
1004
+ // Sentinel mode (browser portal): the card is laid out with CSS, so
1005
+ // emit cardStart/cardEnd bounds around the UNWRAPPED markdown body
1006
+ // instead of drawing box art. Hard-wrapping here would mangle
1007
+ // preformatted content (box-drawn tables, ASCII art) that the browser
1008
+ // renders as real HTML tables or scrollable lines.
1009
+ if (tableMode === "sentinel") {
1010
+ const sentinelBodyLines = parseMarkdownLines(String(body || ""), {
1011
+ width: maxContentWidth,
1012
+ tableMode,
1013
+ });
1014
+ const sentinelTinted = bodyColor
1015
+ ? sentinelBodyLines.map((lineRuns) => (Array.isArray(lineRuns)
1016
+ ? recolorRuns(lineRuns, bodyColor)
1017
+ : lineRuns))
1018
+ : sentinelBodyLines;
1019
+ return [
1020
+ { kind: "cardStart", runs: titleRuns, borderColor },
1021
+ ...sentinelTinted,
1022
+ { kind: "cardEnd" },
1023
+ [{ text: "", color: null }],
1024
+ ];
1025
+ }
1026
+
1002
1027
  const bodyLines = parseMarkdownLines(String(body || ""), { width: maxContentWidth });
1003
1028
  const normalizedBodyLines = bodyLines.length > 0
1004
1029
  ? bodyLines.flatMap((lineRuns) => wrapRunsToDisplayWidth(lineRuns, maxContentWidth))