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
@@ -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();
@@ -506,6 +532,12 @@ function buildSessionOwnerFilterItems(state) {
506
532
  ownerKey: principalKey,
507
533
  description: "Show sessions owned by the authenticated user currently signed in.",
508
534
  });
535
+ items.push({
536
+ id: "shared",
537
+ kind: "shared",
538
+ label: "Shared with me",
539
+ description: "Show sessions other users have shared with you.",
540
+ });
509
541
  }
510
542
 
511
543
  const ownersByKey = new Map();
@@ -542,6 +574,7 @@ export function defaultOwnerFilterForPrincipal(principal) {
542
574
  includeSystem: true,
543
575
  includeUnowned: false,
544
576
  includeMe: true,
577
+ includeShared: true,
545
578
  ownerKeys: [],
546
579
  }
547
580
  : {
@@ -549,6 +582,7 @@ export function defaultOwnerFilterForPrincipal(principal) {
549
582
  includeSystem: false,
550
583
  includeUnowned: false,
551
584
  includeMe: false,
585
+ includeShared: false,
552
586
  ownerKeys: [],
553
587
  };
554
588
  }
@@ -558,6 +592,7 @@ function ownerFilterHasSelections(filter) {
558
592
  filter?.includeSystem
559
593
  || filter?.includeUnowned
560
594
  || filter?.includeMe
595
+ || filter?.includeShared
561
596
  || (Array.isArray(filter?.ownerKeys) && filter.ownerKeys.length > 0),
562
597
  );
563
598
  }
@@ -571,6 +606,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
571
606
  includeSystem: false,
572
607
  includeUnowned: false,
573
608
  includeMe: false,
609
+ includeShared: false,
574
610
  ownerKeys: [],
575
611
  };
576
612
  }
@@ -583,6 +619,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
583
619
  if (item.kind === "system") next.includeSystem = !next.includeSystem;
584
620
  if (item.kind === "unowned") next.includeUnowned = !next.includeUnowned;
585
621
  if (item.kind === "me") next.includeMe = !next.includeMe;
622
+ if (item.kind === "shared") next.includeShared = !next.includeShared;
586
623
  if (item.kind === "owner" && item.ownerKey) {
587
624
  const existingIndex = next.ownerKeys.indexOf(item.ownerKey);
588
625
  if (existingIndex >= 0) {
@@ -594,7 +631,7 @@ function toggleOwnerFilterItem(currentFilter, item, principal) {
594
631
 
595
632
  return ownerFilterHasSelections(next)
596
633
  ? next
597
- : { all: true, includeSystem: false, includeUnowned: false, includeMe: false, ownerKeys: [] };
634
+ : { all: true, includeSystem: false, includeUnowned: false, includeMe: false, includeShared: false, ownerKeys: [] };
598
635
  }
599
636
 
600
637
  function getRenameSessionPrefix(session) {
@@ -1423,13 +1460,33 @@ export class PilotSwarmUiController {
1423
1460
  this.scheduleSessionsRefresh(250);
1424
1461
  return true;
1425
1462
  })().catch((error) => {
1426
- // Revert the merged envelope back to the original pending items so
1427
- // the user can edit/retry them.
1463
+ const authRefused = error?.code === "FORBIDDEN" || error?.code === "UNAUTHORIZED"
1464
+ || error?.status === 403 || error?.status === 401;
1428
1465
  const items = this.getSessionOutbox(sessionId);
1429
- const reverted = items.flatMap((item) => (
1430
- item.id === mergedItem.id ? pendingItems : [item]
1431
- ));
1432
- this.setSessionOutboxItems(sessionId, reverted);
1466
+ if (authRefused) {
1467
+ // Authorization refusals are terminal retrying can't succeed.
1468
+ // Mark the envelope rejected (renders as the red ✗, same as a
1469
+ // cancelled send) and drop it shortly after instead of leaving
1470
+ // a forever-pending item.
1471
+ const rejected = items.map((item) => (
1472
+ item.id === mergedItem.id ? { ...item, phase: "rejected" } : item
1473
+ ));
1474
+ this.setSessionOutboxItems(sessionId, rejected);
1475
+ setTimeout(() => {
1476
+ const current = this.getSessionOutbox(sessionId);
1477
+ const remaining = current.filter((item) => !(item.id === mergedItem.id && item.phase === "rejected"));
1478
+ if (remaining.length !== current.length) {
1479
+ this.setSessionOutboxItems(sessionId, remaining);
1480
+ }
1481
+ }, 6000);
1482
+ } else {
1483
+ // Transient failure: revert the merged envelope back to the
1484
+ // original pending items so the user can edit/retry them.
1485
+ const reverted = items.flatMap((item) => (
1486
+ item.id === mergedItem.id ? pendingItems : [item]
1487
+ ));
1488
+ this.setSessionOutboxItems(sessionId, reverted);
1489
+ }
1433
1490
  this.dispatch({
1434
1491
  type: "ui/status",
1435
1492
  text: error?.message || String(error),
@@ -1676,7 +1733,7 @@ export class PilotSwarmUiController {
1676
1733
  }).catch(() => {});
1677
1734
  }
1678
1735
 
1679
- async start() {
1736
+ async start({ initialSessionId = null } = {}) {
1680
1737
  await this.transport.start();
1681
1738
  const authContext = typeof this.transport.getAuthContext === "function"
1682
1739
  ? this.transport.getAuthContext()
@@ -1707,6 +1764,13 @@ export class PilotSwarmUiController {
1707
1764
  workersOnline: typeof this.transport.getWorkerCount === "function" ? this.transport.getWorkerCount() : null,
1708
1765
  statusText: "Connected",
1709
1766
  });
1767
+ // Latch the deep-link intent AFTER the default owner-filter dispatch
1768
+ // (a filter change releases the latch) and BEFORE the first refresh,
1769
+ // so the first catalog load resolves selection onto the link target.
1770
+ const deepLinkSessionId = String(initialSessionId || "").trim();
1771
+ if (deepLinkSessionId) {
1772
+ this.dispatch({ type: "sessions/navigationIntent", sessionId: deepLinkSessionId });
1773
+ }
1710
1774
  await this.refreshSessions();
1711
1775
  this.catalogTimer = setInterval(() => {
1712
1776
  this.refreshSessions().catch((error) => {
@@ -1762,6 +1826,36 @@ export class PilotSwarmUiController {
1762
1826
  sessions = [...groupRows, ...sessions];
1763
1827
  }
1764
1828
  }
1829
+ // A pending deep-link target may be readable but absent from the
1830
+ // paged catalog window — fetch it explicitly. A definitive failure
1831
+ // (unknown or unshared: identical 404 shapes) fails the intent; the
1832
+ // reducer then refuses fallback selection so the renderer can show
1833
+ // the nav error instead of silently landing somewhere else.
1834
+ const pendingIntent = preRefreshState.sessions.navigationIntent;
1835
+ if (
1836
+ pendingIntent?.status === "pending"
1837
+ && !sessions.some((session) => session?.sessionId === pendingIntent.sessionId)
1838
+ && typeof this.transport.getSession === "function"
1839
+ ) {
1840
+ try {
1841
+ const intentSession = await this.transport.getSession(pendingIntent.sessionId);
1842
+ if (intentSession?.sessionId) {
1843
+ sessions = [...sessions, normalizeSessionListRow(intentSession)];
1844
+ } else {
1845
+ this.dispatch({
1846
+ type: "sessions/navigationIntentFailed",
1847
+ sessionId: pendingIntent.sessionId,
1848
+ errorKind: "not_found",
1849
+ });
1850
+ }
1851
+ } catch (error) {
1852
+ this.dispatch({
1853
+ type: "sessions/navigationIntentFailed",
1854
+ sessionId: pendingIntent.sessionId,
1855
+ errorKind: classifyNavigationLoadError(error),
1856
+ });
1857
+ }
1858
+ }
1765
1859
  const active = previousActive;
1766
1860
  if (
1767
1861
  active
@@ -1787,7 +1881,20 @@ export class PilotSwarmUiController {
1787
1881
  if (selected) {
1788
1882
  if (selected !== previousActive) {
1789
1883
  if (!this.getState().sessions.byId[selected]?.isGroup) {
1790
- await this.loadSession(selected);
1884
+ const selectedIntent = this.getState().sessions.navigationIntent;
1885
+ if (selectedIntent && selectedIntent.sessionId === selected && selectedIntent.status !== "failed") {
1886
+ try {
1887
+ await this.loadSession(selected);
1888
+ } catch (error) {
1889
+ this.dispatch({
1890
+ type: "sessions/navigationIntentFailed",
1891
+ sessionId: selected,
1892
+ errorKind: classifyNavigationLoadError(error),
1893
+ });
1894
+ }
1895
+ } else {
1896
+ await this.loadSession(selected);
1897
+ }
1791
1898
  }
1792
1899
  return;
1793
1900
  }
@@ -2999,6 +3106,32 @@ export class PilotSwarmUiController {
2999
3106
  });
3000
3107
  }
3001
3108
 
3109
+ /**
3110
+ * Latch a navigation intent (deep link) onto a session id. The intent
3111
+ * outranks in-memory selection and the profile's activeSessionId until
3112
+ * the user navigates manually or changes a filter. Also the retry path
3113
+ * for a failed intent: re-latching resets it to pending.
3114
+ */
3115
+ setNavigationIntent(sessionId) {
3116
+ const id = String(sessionId || "").trim();
3117
+ if (!id) return;
3118
+ this.dispatch({ type: "sessions/navigationIntent", sessionId: id });
3119
+ const state = this.getState();
3120
+ if (state.sessions.byId[id]) {
3121
+ this.loadSession(id).catch((error) => {
3122
+ this.dispatch({
3123
+ type: "sessions/navigationIntentFailed",
3124
+ sessionId: id,
3125
+ errorKind: classifyNavigationLoadError(error),
3126
+ });
3127
+ });
3128
+ return;
3129
+ }
3130
+ if (Object.keys(state.sessions.byId).length > 0) {
3131
+ this.scheduleSessionsRefresh(0);
3132
+ }
3133
+ }
3134
+
3002
3135
  async loadSession(sessionId) {
3003
3136
  if (!sessionId) return;
3004
3137
  const active = this.getState().sessions.activeSessionId;
@@ -3174,7 +3307,9 @@ export class PilotSwarmUiController {
3174
3307
 
3175
3308
  async createSession(options = {}) {
3176
3309
  try {
3177
- const created = await this.transport.createSession(this.applyActiveGroupDefault(options));
3310
+ const requestOptions = this.applyActiveGroupDefault(options);
3311
+ const created = await this.transport.createSession(requestOptions);
3312
+ await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
3178
3313
  await this.refreshSessions();
3179
3314
  await this.loadSession(created.sessionId);
3180
3315
  this.setFocus(FOCUS_REGIONS.PROMPT);
@@ -3191,7 +3326,9 @@ export class PilotSwarmUiController {
3191
3326
  throw new Error("Named-agent session creation is not supported by this transport");
3192
3327
  }
3193
3328
  try {
3194
- const created = await this.transport.createSessionForAgent(agentName, this.applyActiveGroupDefault(options));
3329
+ const requestOptions = this.applyActiveGroupDefault(options);
3330
+ const created = await this.transport.createSessionForAgent(agentName, requestOptions);
3331
+ await this.placeCreatedSessionInGroup(created, requestOptions.groupId ?? null);
3195
3332
  await this.refreshSessions();
3196
3333
  await this.loadSession(created.sessionId);
3197
3334
  this.setFocus(FOCUS_REGIONS.PROMPT);
@@ -3206,6 +3343,20 @@ export class PilotSwarmUiController {
3206
3343
  }
3207
3344
  }
3208
3345
 
3346
+ /**
3347
+ * Post-create placement follow-up. createSession's groupId only places
3348
+ * when an owner principal reaches the catalog (web mode places
3349
+ * server-side; the local TUI passes no owner), so when a group was
3350
+ * requested, place explicitly — idempotent where the server already did.
3351
+ * Skipped when the create response already reports the placement.
3352
+ */
3353
+ async placeCreatedSessionInGroup(created, groupId) {
3354
+ if (!groupId || !created?.sessionId) return;
3355
+ if (typeof this.transport.placeSessionsInGroup !== "function") return;
3356
+ if ((created.viewerGroupId ?? null) === groupId) return;
3357
+ await this.transport.placeSessionsInGroup([created.sessionId], groupId).catch(() => {});
3358
+ }
3359
+
3209
3360
  getMovableGroupSessionSelection() {
3210
3361
  const state = this.getState();
3211
3362
  const selectedIds = Array.isArray(state.sessions.selectedIds) && state.sessions.selectedIds.length > 0
@@ -3229,16 +3380,12 @@ export class PilotSwarmUiController {
3229
3380
  return null;
3230
3381
  }
3231
3382
 
3383
+ // The server returns only the viewer's own groups, and placement is
3384
+ // viewer-private — any readable non-system selection is movable, so
3385
+ // mixed-owner selections are allowed and every group is offered.
3232
3386
  const groups = await this.transport.listSessionGroups().catch(() => []);
3233
3387
  const sessionIds = eligible.map((session) => session.sessionId);
3234
3388
  const firstGroupId = eligible.length === 1 ? eligible[0].groupId || null : null;
3235
- const selectedOwnerKeys = new Set(eligible.map((session) => ownerKeyForPrincipal(session.owner) || ""));
3236
- const singleOwnerKey = selectedOwnerKeys.size === 1 ? [...selectedOwnerKeys][0] : null;
3237
- const singleOwner = selectedOwnerKeys.size === 1 ? eligible[0]?.owner ?? null : null;
3238
- const compatibleGroups = selectedOwnerKeys.size === 1
3239
- ? (Array.isArray(groups) ? groups : []).filter((group) => (ownerKeyForPrincipal(group.owner) || "") === singleOwnerKey)
3240
- : [];
3241
- const canCreateOrAssignGroup = selectedOwnerKeys.size === 1;
3242
3389
  const items = [
3243
3390
  {
3244
3391
  id: "__no_group__",
@@ -3248,22 +3395,20 @@ export class PilotSwarmUiController {
3248
3395
  groupId: null,
3249
3396
  memberCount: 0,
3250
3397
  },
3251
- ...(canCreateOrAssignGroup ? [{
3398
+ {
3252
3399
  id: "__new_group__",
3253
3400
  kind: "newGroup",
3254
3401
  label: "[New Group]",
3255
- description: "Create a new group for this owner, then move the selected session(s) into it.",
3402
+ description: "Create a new group, then move the selected session(s) into it.",
3256
3403
  groupId: null,
3257
3404
  memberCount: 0,
3258
- owner: singleOwner,
3259
- }] : []),
3260
- ...compatibleGroups.map((group) => ({
3405
+ },
3406
+ ...(Array.isArray(groups) ? groups : []).map((group) => ({
3261
3407
  id: group.groupId,
3262
3408
  kind: "group",
3263
3409
  label: group.title || group.groupId,
3264
3410
  description: group.description || "Move selected session(s) into this group.",
3265
3411
  groupId: group.groupId,
3266
- owner: group.owner ?? null,
3267
3412
  memberCount: group.memberCount ?? 0,
3268
3413
  })),
3269
3414
  ];
@@ -3285,9 +3430,7 @@ export class PilotSwarmUiController {
3285
3430
  });
3286
3431
  this.dispatch({
3287
3432
  type: "ui/status",
3288
- text: canCreateOrAssignGroup
3289
- ? "Choose a group, [New Group], or [No Group]"
3290
- : "Selected sessions have different owners; only [No Group] is available",
3433
+ text: "Choose a group, [New Group], or [No Group]",
3291
3434
  });
3292
3435
  return items;
3293
3436
  }
@@ -3302,7 +3445,10 @@ export class PilotSwarmUiController {
3302
3445
  this.dispatch({ type: "ui/status", text: "No sessions selected to move" });
3303
3446
  return null;
3304
3447
  }
3305
- if (typeof this.transport.moveSessionsToGroup === "function") {
3448
+ let placementResults = null;
3449
+ if (typeof this.transport.placeSessionsInGroup === "function") {
3450
+ placementResults = await this.transport.placeSessionsInGroup(ids, groupId ?? null);
3451
+ } else if (typeof this.transport.moveSessionsToGroup === "function") {
3306
3452
  await this.transport.moveSessionsToGroup(groupId ?? null, ids);
3307
3453
  } else if (groupId && typeof this.transport.assignSessionsToGroup === "function") {
3308
3454
  await this.transport.assignSessionsToGroup(groupId, ids);
@@ -3311,17 +3457,28 @@ export class PilotSwarmUiController {
3311
3457
  return null;
3312
3458
  }
3313
3459
 
3460
+ const resultRows = Array.isArray(placementResults) ? placementResults.filter(Boolean) : null;
3461
+ const skippedRows = resultRows ? resultRows.filter((row) => row.placed !== true) : [];
3462
+ const placedCount = resultRows
3463
+ ? resultRows.filter((row) => row.placed === true).length
3464
+ : ids.length;
3465
+
3314
3466
  this.dispatch({ type: "sessions/selectClear" });
3315
3467
  await this.refreshSessions();
3316
- if (groupId) {
3468
+ if (groupId && placedCount > 0) {
3317
3469
  await this.loadSession(`group:${groupId}`).catch(() => {});
3318
3470
  }
3319
3471
  const target = groupId ? `group ${statusTitle || groupId}` : "No Group";
3472
+ const skippedSummary = skippedRows.length > 0
3473
+ ? ` · skipped ${skippedRows.length} (${summarizePlacementSkips(skippedRows)})`
3474
+ : "";
3320
3475
  this.dispatch({
3321
3476
  type: "ui/status",
3322
- text: `Moved ${ids.length} session${ids.length === 1 ? "" : "s"} to ${target}`,
3477
+ text: placedCount > 0
3478
+ ? `Moved ${placedCount} session${placedCount === 1 ? "" : "s"} to ${target}${skippedSummary}`
3479
+ : `No sessions moved to ${target}${skippedSummary}`,
3323
3480
  });
3324
- return true;
3481
+ return placedCount > 0;
3325
3482
  }
3326
3483
 
3327
3484
  async confirmSessionGroupPickerModal() {
@@ -3340,7 +3497,6 @@ export class PilotSwarmUiController {
3340
3497
  title: "New Group",
3341
3498
  previousFocus: modal.previousFocus,
3342
3499
  sessionIds: modal.sessionIds || [],
3343
- owner: item.owner ?? null,
3344
3500
  value: baseTitle,
3345
3501
  cursorIndex: baseTitle.length,
3346
3502
  maxLength: 80,
@@ -3420,11 +3576,14 @@ export class PilotSwarmUiController {
3420
3576
  if (previousFocus) this.setFocus(previousFocus);
3421
3577
 
3422
3578
  try {
3579
+ // No owner key: the transport/server stamps the caller's
3580
+ // principal. The UI never infers group ownership from the
3581
+ // selected sessions (owner: null is reserved for the portal
3582
+ // runtime's anonymous path).
3423
3583
  const group = await this.transport.createSessionGroup({
3424
3584
  title,
3425
3585
  description: `${(modal.sessionIds || []).length} grouped session${(modal.sessionIds || []).length === 1 ? "" : "s"}`,
3426
3586
  sessionIds: modal.sessionIds || [],
3427
- owner: modal.owner ?? null,
3428
3587
  });
3429
3588
  await this.moveSessionsToGroup(group.groupId, modal.sessionIds || [], { statusTitle: group.title || title });
3430
3589
  } catch (error) {
@@ -3684,15 +3843,21 @@ export class PilotSwarmUiController {
3684
3843
  this.dispatch({ type: "ui/status", text: "Select a model and press Enter" });
3685
3844
  }
3686
3845
 
3687
- async openSwitchModelPicker() {
3846
+ async openSwitchModelPicker(onApplied = null) {
3847
+ // Callers (e.g. the portal Manage modal) can be notified when a switch
3848
+ // is actually applied — distinct from cancelling the picker — so they
3849
+ // can close/reopen their own chrome accordingly.
3850
+ this._onSwitchModelApplied = typeof onApplied === "function" ? onApplied : null;
3688
3851
  const state = this.getState();
3689
3852
  const sessionId = state.sessions.activeSessionId;
3690
3853
  const session = sessionId ? state.sessions.byId[sessionId] || null : null;
3691
3854
  if (!session || session.isGroup) {
3855
+ this._onSwitchModelApplied = null;
3692
3856
  this.dispatch({ type: "ui/status", text: "Select a session before switching model" });
3693
3857
  return;
3694
3858
  }
3695
3859
  if (typeof this.transport.setSessionModel !== "function") {
3860
+ this._onSwitchModelApplied = null;
3696
3861
  this.dispatch({ type: "ui/status", text: "Model switching is not supported by this transport" });
3697
3862
  return;
3698
3863
  }
@@ -3725,6 +3890,11 @@ export class PilotSwarmUiController {
3725
3890
  const tierSuffix = options.contextTier ? ` · ${CONTEXT_TIER_LABELS[options.contextTier] || options.contextTier}` : "";
3726
3891
  this.dispatch({ type: "ui/status", text: `Next turn will use ${modelLabel}${tierSuffix}` });
3727
3892
  await this.refreshSessions();
3893
+ // Notify a Manage-modal-style caller that the switch was applied (vs
3894
+ // cancelled), so it can close rather than reopen.
3895
+ const onApplied = this._onSwitchModelApplied;
3896
+ this._onSwitchModelApplied = null;
3897
+ if (typeof onApplied === "function") onApplied();
3728
3898
  }
3729
3899
 
3730
3900
  openHelpModal() {
@@ -4351,6 +4521,245 @@ export class PilotSwarmUiController {
4351
4521
  }
4352
4522
  }
4353
4523
 
4524
+ /**
4525
+ * Cycle the active session's general visibility:
4526
+ * private → shared_read → shared_write → private. Visibility is a
4527
+ * per-session property, so system sessions and group rows are refused.
4528
+ */
4529
+ async cycleSessionVisibility() {
4530
+ const state = this.getState();
4531
+ const sessionId = state.sessions.activeSessionId;
4532
+ const session = sessionId ? state.sessions.byId[sessionId] : null;
4533
+ if (!session) {
4534
+ this.dispatch({ type: "ui/status", text: "No session selected" });
4535
+ return;
4536
+ }
4537
+ if (session.isSystem) {
4538
+ this.dispatch({ type: "ui/status", text: "System sessions are always private" });
4539
+ return;
4540
+ }
4541
+ if (session.isGroup) {
4542
+ this.dispatch({ type: "ui/status", text: "Groups don't have visibility" });
4543
+ return;
4544
+ }
4545
+ if (typeof this.transport.setSessionVisibility !== "function") {
4546
+ this.dispatch({ type: "ui/status", text: "Not supported by this transport" });
4547
+ return;
4548
+ }
4549
+ const next = cycleValue(
4550
+ ["private", "shared_read", "shared_write"],
4551
+ session.visibility || "private",
4552
+ 1,
4553
+ );
4554
+ try {
4555
+ await this.transport.setSessionVisibility(sessionId, next);
4556
+ await this.refreshSessions();
4557
+ this.dispatch({ type: "ui/status", text: `Visibility → ${next}` });
4558
+ } catch (error) {
4559
+ this.dispatch({ type: "ui/status", text: error?.message || String(error) });
4560
+ }
4561
+ }
4562
+
4563
+ /**
4564
+ * Open the Share modal for the active session: shows the general
4565
+ * visibility plus the per-person grant list, with a single input line
4566
+ * for edits — `name [r|w]` grants, `-name` revokes. The current grants
4567
+ * are loaded up front so the modal (and revoke matching) can render
4568
+ * them without another round trip.
4569
+ */
4570
+ async openShareSessionModal() {
4571
+ const state = this.getState();
4572
+ const sessionId = state.sessions.activeSessionId;
4573
+ const session = sessionId ? state.sessions.byId[sessionId] : null;
4574
+ if (!session) {
4575
+ this.dispatch({ type: "ui/status", text: "No session selected" });
4576
+ return;
4577
+ }
4578
+ if (session.isSystem) {
4579
+ this.dispatch({ type: "ui/status", text: "System sessions are always private" });
4580
+ return;
4581
+ }
4582
+ if (session.isGroup) {
4583
+ this.dispatch({ type: "ui/status", text: "Groups can't be shared" });
4584
+ return;
4585
+ }
4586
+ if (typeof this.transport.listSessionShares !== "function") {
4587
+ this.dispatch({ type: "ui/status", text: "Session sharing is not supported by this transport" });
4588
+ return;
4589
+ }
4590
+ let shares;
4591
+ try {
4592
+ shares = await this.transport.listSessionShares(sessionId);
4593
+ } catch (error) {
4594
+ this.dispatch({ type: "ui/status", text: error?.message || String(error) });
4595
+ return;
4596
+ }
4597
+ this.dispatch({
4598
+ type: "ui/modal",
4599
+ modal: {
4600
+ type: "shareSession",
4601
+ title: `Share (${shortSessionIdValue(sessionId)})`,
4602
+ sessionId,
4603
+ previousFocus: state.ui.focusRegion,
4604
+ value: "",
4605
+ cursorIndex: 0,
4606
+ shares: Array.isArray(shares) ? shares : [],
4607
+ visibility: session.visibility || "private",
4608
+ },
4609
+ });
4610
+ this.dispatch({ type: "ui/status", text: "name [r|w] grants · -name revokes · Enter apply" });
4611
+ }
4612
+
4613
+ updateShareSessionModal(updater) {
4614
+ const modal = this.getState().ui.modal;
4615
+ if (!modal || modal.type !== "shareSession") return null;
4616
+ const nextModal = typeof updater === "function" ? updater(modal) : updater;
4617
+ if (!nextModal) return null;
4618
+ this.dispatch({ type: "ui/modal", modal: { ...modal, ...nextModal } });
4619
+ return this.getState().ui.modal;
4620
+ }
4621
+
4622
+ setShareSessionValue(value, cursorIndex = String(value || "").length) {
4623
+ const modal = this.getState().ui.modal;
4624
+ if (!modal || modal.type !== "shareSession") return;
4625
+ const safeValue = clampRenameSessionValue(value, 120);
4626
+ const safeCursor = clampPromptCursor(safeValue, cursorIndex);
4627
+ this.updateShareSessionModal({ value: safeValue, cursorIndex: safeCursor });
4628
+ }
4629
+
4630
+ insertShareSessionText(text) {
4631
+ const modal = this.getState().ui.modal;
4632
+ if (!modal || modal.type !== "shareSession") return;
4633
+ const next = insertPromptTextAtCursor(modal.value || "", modal.cursorIndex || 0, clampRenameSessionValue(text, 120));
4634
+ this.setShareSessionValue(next.prompt, next.cursor);
4635
+ }
4636
+
4637
+ deleteShareSessionChar() {
4638
+ const modal = this.getState().ui.modal;
4639
+ if (!modal || modal.type !== "shareSession") return;
4640
+ const next = deletePromptCharBackward(modal.value || "", modal.cursorIndex || 0);
4641
+ this.setShareSessionValue(next.prompt, next.cursor);
4642
+ }
4643
+
4644
+ moveShareSessionCursor(delta) {
4645
+ const modal = this.getState().ui.modal;
4646
+ if (!modal || modal.type !== "shareSession") return;
4647
+ this.setShareSessionValue(modal.value || "", clampPromptCursor(modal.value || "", (modal.cursorIndex || 0) + delta));
4648
+ }
4649
+
4650
+ moveShareSessionCursorToBoundary(kind) {
4651
+ const modal = this.getState().ui.modal;
4652
+ if (!modal || modal.type !== "shareSession") return;
4653
+ this.setShareSessionValue(modal.value || "", kind === "start" ? 0 : String(modal.value || "").length);
4654
+ }
4655
+
4656
+ /**
4657
+ * Apply the typed share command. Empty input just closes the modal.
4658
+ * `-name` (or `revoke name`) revokes an existing grant; anything else
4659
+ * grants, with an optional trailing r/read/w/write access token
4660
+ * (default read). The grantee is resolved against the member directory
4661
+ * when the transport exposes it; unmatched text falls back to a raw
4662
+ * subject so a grant can target someone who has never signed in —
4663
+ * an email-keyed grant binds at their first sign-in.
4664
+ */
4665
+ async confirmShareSessionModal() {
4666
+ const modal = this.getState().ui.modal;
4667
+ if (!modal || modal.type !== "shareSession") return;
4668
+ const sessionId = modal.sessionId;
4669
+ if (!sessionId) return;
4670
+
4671
+ const raw = String(modal.value || "").trim();
4672
+ const previousFocus = modal.previousFocus;
4673
+ if (!raw) {
4674
+ this.dispatch({ type: "ui/modal", modal: null });
4675
+ if (previousFocus) this.setFocus(previousFocus);
4676
+ return;
4677
+ }
4678
+
4679
+ // Revoke: `-name` or `revoke name` targets an existing grant by
4680
+ // subject, email, or display name (case-insensitive).
4681
+ const revokeWho = raw.startsWith("-")
4682
+ ? raw.slice(1).trim()
4683
+ : (raw.toLowerCase().startsWith("revoke ") ? raw.slice("revoke ".length).trim() : null);
4684
+ if (revokeWho !== null) {
4685
+ const needle = revokeWho.toLowerCase();
4686
+ const grant = (modal.shares || []).find((row) =>
4687
+ (row.subject && String(row.subject).toLowerCase() === needle)
4688
+ || (row.email && String(row.email).toLowerCase() === needle)
4689
+ || (row.displayName && String(row.displayName).toLowerCase() === needle));
4690
+ if (!grant) {
4691
+ this.dispatch({ type: "ui/status", text: `No grant matching "${revokeWho}"` });
4692
+ return;
4693
+ }
4694
+ this.dispatch({ type: "ui/modal", modal: null });
4695
+ if (previousFocus) this.setFocus(previousFocus);
4696
+ try {
4697
+ await this.transport.revokeSessionShare(sessionId, { provider: grant.provider, subject: grant.subject });
4698
+ this.dispatch({ type: "ui/status", text: `Revoked ${revokeWho}` });
4699
+ } catch (error) {
4700
+ this.dispatch({ type: "ui/status", text: error?.message || String(error) });
4701
+ }
4702
+ return;
4703
+ }
4704
+
4705
+ // Grant: an optional trailing access token picks read/write.
4706
+ const tokens = raw.split(/\s+/);
4707
+ const accessByToken = { r: "read", read: "read", w: "write", write: "write" };
4708
+ const lastToken = tokens.length > 1 ? tokens[tokens.length - 1].toLowerCase() : null;
4709
+ const access = lastToken && accessByToken[lastToken] ? accessByToken[lastToken] : "read";
4710
+ const who = lastToken && accessByToken[lastToken] ? tokens.slice(0, -1).join(" ") : raw;
4711
+
4712
+ // Resolve the typed text against the member directory: an exact
4713
+ // displayName/email/subject match wins; otherwise a unique partial
4714
+ // match is accepted and multiple partial matches are ambiguous.
4715
+ const needle = who.toLowerCase();
4716
+ let grantee = null;
4717
+ if (typeof this.transport.listKnownUsers === "function") {
4718
+ let users = [];
4719
+ try {
4720
+ users = await this.transport.listKnownUsers({ limit: 500 });
4721
+ } catch {
4722
+ users = [];
4723
+ }
4724
+ const candidates = Array.isArray(users) ? users : [];
4725
+ grantee = candidates.find((user) =>
4726
+ (user.displayName && user.displayName.toLowerCase() === needle)
4727
+ || (user.email && user.email.toLowerCase() === needle)
4728
+ || (user.subject && user.subject.toLowerCase() === needle)) || null;
4729
+ if (!grantee) {
4730
+ const partial = candidates.filter((user) =>
4731
+ (user.displayName && user.displayName.toLowerCase().includes(needle))
4732
+ || (user.email && user.email.toLowerCase().includes(needle))
4733
+ || (user.subject && user.subject.toLowerCase().includes(needle)));
4734
+ if (partial.length === 1) {
4735
+ grantee = partial[0];
4736
+ } else if (partial.length > 1) {
4737
+ this.dispatch({ type: "ui/status", text: `"${who}" is ambiguous (${partial.length} matches)` });
4738
+ return;
4739
+ }
4740
+ }
4741
+ }
4742
+ if (!grantee) {
4743
+ // Not-yet-seen user: treat the text as a raw subject under the
4744
+ // caller's provider (mirrors the portal's resolveGrantee).
4745
+ const principal = this.getState().auth?.principal || null;
4746
+ grantee = { provider: principal?.provider || "dev", subject: who, email: null, displayName: null };
4747
+ }
4748
+
4749
+ this.dispatch({ type: "ui/modal", modal: null });
4750
+ if (previousFocus) this.setFocus(previousFocus);
4751
+ try {
4752
+ await this.transport.grantSessionShare(
4753
+ sessionId,
4754
+ { provider: grantee.provider, subject: grantee.subject, email: grantee.email ?? null, displayName: grantee.displayName ?? null },
4755
+ access,
4756
+ );
4757
+ this.dispatch({ type: "ui/status", text: `Granted ${access} to ${who}` });
4758
+ } catch (error) {
4759
+ this.dispatch({ type: "ui/status", text: error?.message || String(error) });
4760
+ }
4761
+ }
4762
+
4354
4763
  closeModal() {
4355
4764
  const modal = this.getState().ui.modal;
4356
4765
  if (!modal) return;
@@ -4455,6 +4864,10 @@ export class PilotSwarmUiController {
4455
4864
  await this.confirmRenameSessionModal();
4456
4865
  return;
4457
4866
  }
4867
+ if (modal.type === "shareSession") {
4868
+ await this.confirmShareSessionModal();
4869
+ return;
4870
+ }
4458
4871
  if (modal.type === "sessionGroupPicker") {
4459
4872
  await this.confirmSessionGroupPickerModal();
4460
4873
  return;
@@ -6261,6 +6674,12 @@ export class PilotSwarmUiController {
6261
6674
  case UI_COMMANDS.OPEN_RENAME_SESSION:
6262
6675
  this.openRenameSessionModal();
6263
6676
  return;
6677
+ case UI_COMMANDS.CYCLE_SESSION_VISIBILITY:
6678
+ await this.cycleSessionVisibility();
6679
+ return;
6680
+ case UI_COMMANDS.OPEN_SHARE_SESSION:
6681
+ await this.openShareSessionModal();
6682
+ return;
6264
6683
  case UI_COMMANDS.OPEN_SESSION_FILTER:
6265
6684
  this.openSessionOwnerFilter();
6266
6685
  return;