pilotswarm 0.5.13 → 0.5.14

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 (42) hide show
  1. package/README.md +6 -0
  2. package/mcp/README.md +12 -0
  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/tools/capabilities.d.ts +3 -0
  8. package/mcp/dist/src/tools/capabilities.d.ts.map +1 -1
  9. package/mcp/dist/src/tools/capabilities.js +8 -0
  10. package/mcp/dist/src/tools/capabilities.js.map +1 -1
  11. package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
  12. package/mcp/dist/src/tools/sessions.js +114 -0
  13. package/mcp/dist/src/tools/sessions.js.map +1 -1
  14. package/package.json +3 -2
  15. package/tui/src/app.js +19 -2
  16. package/tui/src/auth/cli.js +13 -0
  17. package/tui/src/node-sdk-transport.js +53 -8
  18. package/tui/tui-splash-mobile.txt +5 -7
  19. package/tui/tui-splash.txt +13 -9
  20. package/ui/core/src/commands.js +2 -0
  21. package/ui/core/src/controller.js +275 -6
  22. package/ui/core/src/history.js +19 -1
  23. package/ui/core/src/reducer.js +4 -0
  24. package/ui/core/src/selectors.js +139 -14
  25. package/ui/core/src/themes/helpers.js +4 -0
  26. package/ui/react/src/components.js +95 -6
  27. package/ui/react/src/web-app.js +555 -117
  28. package/web/api/router.js +7 -6
  29. package/web/api/ws.js +9 -0
  30. package/web/auth/index.js +5 -0
  31. package/web/auth/providers/dev.js +119 -0
  32. package/web/authz.js +142 -0
  33. package/web/dist/assets/index-BnxC8cNG.js +24 -0
  34. package/web/dist/assets/{index-oldX95Tp.css → index-Bx6KHIaj.css} +1 -1
  35. package/web/dist/assets/pilotswarm-NE7H63ha.js +90 -0
  36. package/web/dist/assets/react-l0sNRNKZ.js +1 -0
  37. package/web/dist/index.html +3 -4
  38. package/web/runtime.js +453 -9
  39. package/web/server.js +2 -2
  40. package/web/dist/assets/index-bQ2QInMX.js +0 -24
  41. package/web/dist/assets/pilotswarm-DRs6o-lA.js +0 -90
  42. package/web/dist/assets/react-C9iQPS2h.js +0 -1
@@ -710,10 +710,48 @@ export class NodeSdkTransport {
710
710
  return this.mgmt.listSessionsPage(opts);
711
711
  }
712
712
 
713
+ async listSessionsVisible(viewer) {
714
+ return this.mgmt.listSessionsVisible(viewer);
715
+ }
716
+
717
+ async listKnownUsers(opts) {
718
+ return this.mgmt.listKnownUsers(opts);
719
+ }
720
+
713
721
  async getSession(sessionId) {
714
722
  return this.mgmt.getSession(sessionId);
715
723
  }
716
724
 
725
+ // ── Session sharing / access (security model) ────────────────
726
+
727
+ async getSessionAccess(sessionId, viewer) {
728
+ return this.mgmt.getSessionAccess(sessionId, viewer);
729
+ }
730
+
731
+ async setSessionVisibility(sessionId, visibility) {
732
+ return this.mgmt.setSessionVisibility(sessionId, visibility);
733
+ }
734
+
735
+ async grantSessionShare(sessionId, grantee, access, grantedBy) {
736
+ return this.mgmt.grantSessionShare(sessionId, grantee, access, grantedBy);
737
+ }
738
+
739
+ async revokeSessionShare(sessionId, grantee) {
740
+ return this.mgmt.revokeSessionShare(sessionId, grantee);
741
+ }
742
+
743
+ async listSessionShares(sessionId) {
744
+ return this.mgmt.listSessionShares(sessionId);
745
+ }
746
+
747
+ async recordAuthzAudit(entry) {
748
+ return this.mgmt.recordAuthzAudit(entry);
749
+ }
750
+
751
+ async listAuthzAudit(opts) {
752
+ return this.mgmt.listAuthzAudit(opts);
753
+ }
754
+
717
755
  async getOrchestrationStats(sessionId) {
718
756
  return this.mgmt.getOrchestrationStats(sessionId);
719
757
  }
@@ -963,7 +1001,7 @@ export class NodeSdkTransport {
963
1001
  );
964
1002
  }
965
1003
 
966
- async createSession({ model, reasoningEffort, contextTier, owner, groupId } = {}) {
1004
+ async createSession({ model, reasoningEffort, contextTier, owner, groupId, visibility } = {}) {
967
1005
  const effectiveModel = await this.assertSessionModelCreatable({ model, owner });
968
1006
  const session = await this.client.createSession({
969
1007
  ...(effectiveModel ? { model: effectiveModel } : {}),
@@ -971,12 +1009,13 @@ export class NodeSdkTransport {
971
1009
  ...(contextTier ? { contextTier } : {}),
972
1010
  ...(owner ? { owner } : {}),
973
1011
  ...(groupId ? { groupId } : {}),
1012
+ ...(visibility ? { visibility } : {}),
974
1013
  });
975
1014
  this.sessionHandles.set(session.sessionId, session);
976
1015
  return { sessionId: session.sessionId, model: effectiveModel, reasoningEffort: reasoningEffort || undefined, contextTier: contextTier || undefined };
977
1016
  }
978
1017
 
979
- async createSessionForAgent(agentName, { model, reasoningEffort, contextTier, title, splash, splashMobile, initialPrompt, owner, groupId } = {}) {
1018
+ async createSessionForAgent(agentName, { model, reasoningEffort, contextTier, title, splash, splashMobile, initialPrompt, owner, groupId, visibility } = {}) {
980
1019
  const effectiveModel = await this.assertSessionModelCreatable({ model, owner });
981
1020
  const session = await this.client.createSessionForAgent(agentName, {
982
1021
  ...(effectiveModel ? { model: effectiveModel } : {}),
@@ -988,6 +1027,7 @@ export class NodeSdkTransport {
988
1027
  ...(initialPrompt ? { initialPrompt } : {}),
989
1028
  ...(owner ? { owner } : {}),
990
1029
  ...(groupId ? { groupId } : {}),
1030
+ ...(visibility ? { visibility } : {}),
991
1031
  });
992
1032
  this.sessionHandles.set(session.sessionId, session);
993
1033
  return {
@@ -1027,9 +1067,14 @@ export class NodeSdkTransport {
1027
1067
  throw new Error(buildTerminalSendError(sessionId, session));
1028
1068
  }
1029
1069
 
1030
- const sendOptions = options?.clientMessageIds && Array.isArray(options.clientMessageIds) && options.clientMessageIds.length > 0
1031
- ? { clientMessageIds: options.clientMessageIds }
1032
- : undefined;
1070
+ const sendOptions = {
1071
+ ...(options?.clientMessageIds && Array.isArray(options.clientMessageIds) && options.clientMessageIds.length > 0
1072
+ ? { clientMessageIds: options.clientMessageIds }
1073
+ : {}),
1074
+ // Server-stamped sender identity (security model) — set by the
1075
+ // portal runtime from the validated auth context.
1076
+ ...(options?.sender && typeof options.sender === "object" ? { sender: options.sender } : {}),
1077
+ };
1033
1078
 
1034
1079
  if (options?.enqueueOnly) {
1035
1080
  // enqueueOnly originally routed through mgmt.sendMessage to skip
@@ -1054,11 +1099,11 @@ export class NodeSdkTransport {
1054
1099
  // "Working…" forever. Propagate the error so the caller can retry
1055
1100
  // through the full sessionHandle.send path that owns the start.
1056
1101
  const sessionHandle = await this.getSessionHandle(sessionId);
1057
- await sessionHandle.send(prompt);
1102
+ await sessionHandle.send(prompt, sendOptions);
1058
1103
  }
1059
1104
 
1060
- async sendAnswer(sessionId, answer) {
1061
- await this.mgmt.sendAnswer(sessionId, answer);
1105
+ async sendAnswer(sessionId, answer, options = {}) {
1106
+ await this.mgmt.sendAnswer(sessionId, answer, options?.sender ? { sender: options.sender } : undefined);
1062
1107
  }
1063
1108
 
1064
1109
  async sendSessionEvent(sessionId, eventName, data) {
@@ -1,7 +1,5 @@
1
- {bold}{cyan-fg}█▀█ █ █ █▀█ ▀█▀{/cyan-fg} {magenta-fg}█▀ █ █ █ ▄▀█ █▀█ █▀▄▀█{/magenta-fg}{/bold}
2
- {bold}{cyan-fg}█▀▀ █ █▄▄ █▄█ █ {/cyan-fg} {magenta-fg}▄█ ▀▄▀▄▀ █▀█ █▀▄ █ ▀ █{/magenta-fg}{/bold}
3
- {yellow-fg}░▒▓██████████████████████████████████▓▒░{/yellow-fg}
4
- {bold}{white-fg}Durable AI Agent Orchestration{/white-fg}{/bold}
5
- {cyan-fg}Crash recovery{/cyan-fg} · {magenta-fg}Durable timers{/magenta-fg}
6
- {yellow-fg}Sub-agents{/yellow-fg} · {green-fg}Multi-node scaling{/green-fg}
7
- {gray-fg}Powered by duroxide + Copilot SDK{/gray-fg}
1
+ {bold}{green-fg}█▀█ █ █ █▀█ ▀█▀ █▀ █ █ █ ▄▀█ █▀█ █▀▄▀█{/green-fg}{/bold}
2
+ {bold}{green-fg}█▀▀ █ █▄▄ █▄█ █ ▄█ ▀▄▀▄▀ █▀█ █▀▄ █ ▀ █{/green-fg}{/bold}
3
+ {green-fg}░▒▓██████████████████████████████████▓▒░{/green-fg}
4
+ {bold}{white-fg}Cluster Orchestrator{/white-fg}{/bold}
5
+ {green-fg}Agents{/green-fg} · {yellow-fg}Infra{/yellow-fg} · {cyan-fg}Maintenance{/cyan-fg}
@@ -1,11 +1,15 @@
1
- {bold}{cyan-fg}
2
- ____ _ __ __ _____
3
- / __ \(_) /___ / /_/ ___/ ______ __________ ___
4
- {/cyan-fg}{magenta-fg} / /_/ / / / __ \/ __/\__ \ | /| / / __ `/ ___/ __ `__ \
5
- / ____/ / / /_/ / /_ ___/ / |/ |/ / /_/ / / / / / / / /{/magenta-fg}
6
- {yellow-fg}/_/ /_/_/\____/\__//____/|__/|__/\__,_/_/ /_/ /_/ /_/ {/yellow-fg}
1
+ {bold}
2
+ {red-fg}██████╗ ██╗██╗ ██████╗ ████████╗███████╗██╗ ██╗ █████╗ ██████╗ ███╗ ███╗{/red-fg}
3
+ {yellow-fg}██╔══██╗██║██║ ██╔═══██╗╚══██╔══╝██╔════╝██║ ██║██╔══██╗██╔══██╗████╗ ████║{/yellow-fg}
4
+ {green-fg}██████╔╝██║██║ ██║ ██║ ██║ ███████╗██║ █╗ ██║███████║██████╔╝██╔████╔██║{/green-fg}
5
+ {cyan-fg}██╔═══╝ ██║██║ ██║ ██║ ██║ ╚════██║██║███╗██║██╔══██║██╔══██╗██║╚██╔╝██║{/cyan-fg}
6
+ {blue-fg}██║ ██║███████╗╚██████╔╝ ██║ ███████║╚███╔███╔╝██║ ██║██║ ██║██║ ╚═╝ ██║{/blue-fg}
7
+ {magenta-fg}╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ╚══════╝ ╚══╝╚══╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝{/magenta-fg}
7
8
  {/bold}
9
+ {cyan-fg} ╔═════════════════════════════════════════════════════════════════════╗{/cyan-fg}
10
+ {cyan-fg} ║{/cyan-fg}{bold}{white-fg} C l u s t e r O r c h e s t r a t o r {/white-fg}{/bold}{cyan-fg}║{/cyan-fg}
11
+ {cyan-fg} ╚═════════════════════════════════════════════════════════════════════╝{/cyan-fg}
8
12
 
9
- {bold}{white-fg}Durable AI Agent Orchestration{/white-fg}{/bold}
10
- {cyan-fg}Crash recovery{/cyan-fg} · {magenta-fg}Durable timers{/magenta-fg} · {yellow-fg}Sub-agents{/yellow-fg} · {green-fg}Multi-node scaling{/green-fg}
11
- {gray-fg}Powered by duroxide + GitHub Copilot SDK{/gray-fg}
13
+ {bold}{cyan-fg}Agents{/cyan-fg} · {yellow-fg}Infrastructure{/yellow-fg} · {green-fg}Maintenance{/green-fg} · {magenta-fg}Monitoring{/magenta-fg}{/bold}
14
+ {bold}{red-fg}Spawn{/red-fg} · {cyan-fg}Steer{/cyan-fg} · {yellow-fg}Sweep{/yellow-fg} · {blue-fg}Scale{/blue-fg}{/bold}
15
+ {gray-fg}Many agents. One formation.{/gray-fg}
@@ -76,6 +76,8 @@ export const UI_COMMANDS = {
76
76
  STOP_TURN: "stopTurn",
77
77
  DELETE_SESSION: "deleteSession",
78
78
  PIN_SESSION: "pinSession",
79
+ CYCLE_SESSION_VISIBILITY: "cycleSessionVisibility",
80
+ OPEN_SHARE_SESSION: "openShareSession",
79
81
  TOGGLE_SELECT_MODE: "toggleSelectMode",
80
82
  TOGGLE_SESSION_SELECTION: "toggleSessionSelection",
81
83
  CLEAR_SESSION_SELECTION: "clearSessionSelection",
@@ -1423,13 +1423,33 @@ export class PilotSwarmUiController {
1423
1423
  this.scheduleSessionsRefresh(250);
1424
1424
  return true;
1425
1425
  })().catch((error) => {
1426
- // Revert the merged envelope back to the original pending items so
1427
- // the user can edit/retry them.
1426
+ const authRefused = error?.code === "FORBIDDEN" || error?.code === "UNAUTHORIZED"
1427
+ || error?.status === 403 || error?.status === 401;
1428
1428
  const items = this.getSessionOutbox(sessionId);
1429
- const reverted = items.flatMap((item) => (
1430
- item.id === mergedItem.id ? pendingItems : [item]
1431
- ));
1432
- this.setSessionOutboxItems(sessionId, reverted);
1429
+ if (authRefused) {
1430
+ // Authorization refusals are terminal retrying can't succeed.
1431
+ // Mark the envelope rejected (renders as the red ✗, same as a
1432
+ // cancelled send) and drop it shortly after instead of leaving
1433
+ // a forever-pending item.
1434
+ const rejected = items.map((item) => (
1435
+ item.id === mergedItem.id ? { ...item, phase: "rejected" } : item
1436
+ ));
1437
+ this.setSessionOutboxItems(sessionId, rejected);
1438
+ setTimeout(() => {
1439
+ const current = this.getSessionOutbox(sessionId);
1440
+ const remaining = current.filter((item) => !(item.id === mergedItem.id && item.phase === "rejected"));
1441
+ if (remaining.length !== current.length) {
1442
+ this.setSessionOutboxItems(sessionId, remaining);
1443
+ }
1444
+ }, 6000);
1445
+ } 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]
1450
+ ));
1451
+ this.setSessionOutboxItems(sessionId, reverted);
1452
+ }
1433
1453
  this.dispatch({
1434
1454
  type: "ui/status",
1435
1455
  text: error?.message || String(error),
@@ -4351,6 +4371,245 @@ export class PilotSwarmUiController {
4351
4371
  }
4352
4372
  }
4353
4373
 
4374
+ /**
4375
+ * Cycle the active session's general visibility:
4376
+ * private → shared_read → shared_write → private. Visibility is a
4377
+ * per-session property, so system sessions and group rows are refused.
4378
+ */
4379
+ async cycleSessionVisibility() {
4380
+ const state = this.getState();
4381
+ const sessionId = state.sessions.activeSessionId;
4382
+ const session = sessionId ? state.sessions.byId[sessionId] : null;
4383
+ if (!session) {
4384
+ this.dispatch({ type: "ui/status", text: "No session selected" });
4385
+ return;
4386
+ }
4387
+ if (session.isSystem) {
4388
+ this.dispatch({ type: "ui/status", text: "System sessions are always private" });
4389
+ return;
4390
+ }
4391
+ if (session.isGroup) {
4392
+ this.dispatch({ type: "ui/status", text: "Groups don't have visibility" });
4393
+ return;
4394
+ }
4395
+ if (typeof this.transport.setSessionVisibility !== "function") {
4396
+ this.dispatch({ type: "ui/status", text: "Not supported by this transport" });
4397
+ return;
4398
+ }
4399
+ const next = cycleValue(
4400
+ ["private", "shared_read", "shared_write"],
4401
+ session.visibility || "private",
4402
+ 1,
4403
+ );
4404
+ try {
4405
+ await this.transport.setSessionVisibility(sessionId, next);
4406
+ await this.refreshSessions();
4407
+ this.dispatch({ type: "ui/status", text: `Visibility → ${next}` });
4408
+ } catch (error) {
4409
+ this.dispatch({ type: "ui/status", text: error?.message || String(error) });
4410
+ }
4411
+ }
4412
+
4413
+ /**
4414
+ * Open the Share modal for the active session: shows the general
4415
+ * visibility plus the per-person grant list, with a single input line
4416
+ * for edits — `name [r|w]` grants, `-name` revokes. The current grants
4417
+ * are loaded up front so the modal (and revoke matching) can render
4418
+ * them without another round trip.
4419
+ */
4420
+ async openShareSessionModal() {
4421
+ const state = this.getState();
4422
+ const sessionId = state.sessions.activeSessionId;
4423
+ const session = sessionId ? state.sessions.byId[sessionId] : null;
4424
+ if (!session) {
4425
+ this.dispatch({ type: "ui/status", text: "No session selected" });
4426
+ return;
4427
+ }
4428
+ if (session.isSystem) {
4429
+ this.dispatch({ type: "ui/status", text: "System sessions are always private" });
4430
+ return;
4431
+ }
4432
+ if (session.isGroup) {
4433
+ this.dispatch({ type: "ui/status", text: "Groups can't be shared" });
4434
+ return;
4435
+ }
4436
+ if (typeof this.transport.listSessionShares !== "function") {
4437
+ this.dispatch({ type: "ui/status", text: "Session sharing is not supported by this transport" });
4438
+ return;
4439
+ }
4440
+ let shares;
4441
+ try {
4442
+ shares = await this.transport.listSessionShares(sessionId);
4443
+ } catch (error) {
4444
+ this.dispatch({ type: "ui/status", text: error?.message || String(error) });
4445
+ return;
4446
+ }
4447
+ this.dispatch({
4448
+ type: "ui/modal",
4449
+ modal: {
4450
+ type: "shareSession",
4451
+ title: `Share (${shortSessionIdValue(sessionId)})`,
4452
+ sessionId,
4453
+ previousFocus: state.ui.focusRegion,
4454
+ value: "",
4455
+ cursorIndex: 0,
4456
+ shares: Array.isArray(shares) ? shares : [],
4457
+ visibility: session.visibility || "private",
4458
+ },
4459
+ });
4460
+ this.dispatch({ type: "ui/status", text: "name [r|w] grants · -name revokes · Enter apply" });
4461
+ }
4462
+
4463
+ updateShareSessionModal(updater) {
4464
+ const modal = this.getState().ui.modal;
4465
+ if (!modal || modal.type !== "shareSession") return null;
4466
+ const nextModal = typeof updater === "function" ? updater(modal) : updater;
4467
+ if (!nextModal) return null;
4468
+ this.dispatch({ type: "ui/modal", modal: { ...modal, ...nextModal } });
4469
+ return this.getState().ui.modal;
4470
+ }
4471
+
4472
+ setShareSessionValue(value, cursorIndex = String(value || "").length) {
4473
+ const modal = this.getState().ui.modal;
4474
+ if (!modal || modal.type !== "shareSession") return;
4475
+ const safeValue = clampRenameSessionValue(value, 120);
4476
+ const safeCursor = clampPromptCursor(safeValue, cursorIndex);
4477
+ this.updateShareSessionModal({ value: safeValue, cursorIndex: safeCursor });
4478
+ }
4479
+
4480
+ insertShareSessionText(text) {
4481
+ const modal = this.getState().ui.modal;
4482
+ if (!modal || modal.type !== "shareSession") return;
4483
+ const next = insertPromptTextAtCursor(modal.value || "", modal.cursorIndex || 0, clampRenameSessionValue(text, 120));
4484
+ this.setShareSessionValue(next.prompt, next.cursor);
4485
+ }
4486
+
4487
+ deleteShareSessionChar() {
4488
+ const modal = this.getState().ui.modal;
4489
+ if (!modal || modal.type !== "shareSession") return;
4490
+ const next = deletePromptCharBackward(modal.value || "", modal.cursorIndex || 0);
4491
+ this.setShareSessionValue(next.prompt, next.cursor);
4492
+ }
4493
+
4494
+ moveShareSessionCursor(delta) {
4495
+ const modal = this.getState().ui.modal;
4496
+ if (!modal || modal.type !== "shareSession") return;
4497
+ this.setShareSessionValue(modal.value || "", clampPromptCursor(modal.value || "", (modal.cursorIndex || 0) + delta));
4498
+ }
4499
+
4500
+ moveShareSessionCursorToBoundary(kind) {
4501
+ const modal = this.getState().ui.modal;
4502
+ if (!modal || modal.type !== "shareSession") return;
4503
+ this.setShareSessionValue(modal.value || "", kind === "start" ? 0 : String(modal.value || "").length);
4504
+ }
4505
+
4506
+ /**
4507
+ * Apply the typed share command. Empty input just closes the modal.
4508
+ * `-name` (or `revoke name`) revokes an existing grant; anything else
4509
+ * grants, with an optional trailing r/read/w/write access token
4510
+ * (default read). The grantee is resolved against the member directory
4511
+ * when the transport exposes it; unmatched text falls back to a raw
4512
+ * subject so a grant can target someone who has never signed in —
4513
+ * an email-keyed grant binds at their first sign-in.
4514
+ */
4515
+ async confirmShareSessionModal() {
4516
+ const modal = this.getState().ui.modal;
4517
+ if (!modal || modal.type !== "shareSession") return;
4518
+ const sessionId = modal.sessionId;
4519
+ if (!sessionId) return;
4520
+
4521
+ const raw = String(modal.value || "").trim();
4522
+ const previousFocus = modal.previousFocus;
4523
+ if (!raw) {
4524
+ this.dispatch({ type: "ui/modal", modal: null });
4525
+ if (previousFocus) this.setFocus(previousFocus);
4526
+ return;
4527
+ }
4528
+
4529
+ // Revoke: `-name` or `revoke name` targets an existing grant by
4530
+ // subject, email, or display name (case-insensitive).
4531
+ const revokeWho = raw.startsWith("-")
4532
+ ? raw.slice(1).trim()
4533
+ : (raw.toLowerCase().startsWith("revoke ") ? raw.slice("revoke ".length).trim() : null);
4534
+ if (revokeWho !== null) {
4535
+ const needle = revokeWho.toLowerCase();
4536
+ const grant = (modal.shares || []).find((row) =>
4537
+ (row.subject && String(row.subject).toLowerCase() === needle)
4538
+ || (row.email && String(row.email).toLowerCase() === needle)
4539
+ || (row.displayName && String(row.displayName).toLowerCase() === needle));
4540
+ if (!grant) {
4541
+ this.dispatch({ type: "ui/status", text: `No grant matching "${revokeWho}"` });
4542
+ return;
4543
+ }
4544
+ this.dispatch({ type: "ui/modal", modal: null });
4545
+ if (previousFocus) this.setFocus(previousFocus);
4546
+ try {
4547
+ await this.transport.revokeSessionShare(sessionId, { provider: grant.provider, subject: grant.subject });
4548
+ this.dispatch({ type: "ui/status", text: `Revoked ${revokeWho}` });
4549
+ } catch (error) {
4550
+ this.dispatch({ type: "ui/status", text: error?.message || String(error) });
4551
+ }
4552
+ return;
4553
+ }
4554
+
4555
+ // Grant: an optional trailing access token picks read/write.
4556
+ const tokens = raw.split(/\s+/);
4557
+ const accessByToken = { r: "read", read: "read", w: "write", write: "write" };
4558
+ const lastToken = tokens.length > 1 ? tokens[tokens.length - 1].toLowerCase() : null;
4559
+ const access = lastToken && accessByToken[lastToken] ? accessByToken[lastToken] : "read";
4560
+ const who = lastToken && accessByToken[lastToken] ? tokens.slice(0, -1).join(" ") : raw;
4561
+
4562
+ // Resolve the typed text against the member directory: an exact
4563
+ // displayName/email/subject match wins; otherwise a unique partial
4564
+ // match is accepted and multiple partial matches are ambiguous.
4565
+ const needle = who.toLowerCase();
4566
+ let grantee = null;
4567
+ if (typeof this.transport.listKnownUsers === "function") {
4568
+ let users = [];
4569
+ try {
4570
+ users = await this.transport.listKnownUsers({ limit: 500 });
4571
+ } catch {
4572
+ users = [];
4573
+ }
4574
+ const candidates = Array.isArray(users) ? users : [];
4575
+ grantee = candidates.find((user) =>
4576
+ (user.displayName && user.displayName.toLowerCase() === needle)
4577
+ || (user.email && user.email.toLowerCase() === needle)
4578
+ || (user.subject && user.subject.toLowerCase() === needle)) || null;
4579
+ if (!grantee) {
4580
+ const partial = candidates.filter((user) =>
4581
+ (user.displayName && user.displayName.toLowerCase().includes(needle))
4582
+ || (user.email && user.email.toLowerCase().includes(needle))
4583
+ || (user.subject && user.subject.toLowerCase().includes(needle)));
4584
+ if (partial.length === 1) {
4585
+ grantee = partial[0];
4586
+ } else if (partial.length > 1) {
4587
+ this.dispatch({ type: "ui/status", text: `"${who}" is ambiguous (${partial.length} matches)` });
4588
+ return;
4589
+ }
4590
+ }
4591
+ }
4592
+ if (!grantee) {
4593
+ // Not-yet-seen user: treat the text as a raw subject under the
4594
+ // caller's provider (mirrors the portal's resolveGrantee).
4595
+ const principal = this.getState().auth?.principal || null;
4596
+ grantee = { provider: principal?.provider || "dev", subject: who, email: null, displayName: null };
4597
+ }
4598
+
4599
+ this.dispatch({ type: "ui/modal", modal: null });
4600
+ if (previousFocus) this.setFocus(previousFocus);
4601
+ try {
4602
+ await this.transport.grantSessionShare(
4603
+ sessionId,
4604
+ { provider: grantee.provider, subject: grantee.subject, email: grantee.email ?? null, displayName: grantee.displayName ?? null },
4605
+ access,
4606
+ );
4607
+ this.dispatch({ type: "ui/status", text: `Granted ${access} to ${who}` });
4608
+ } catch (error) {
4609
+ this.dispatch({ type: "ui/status", text: error?.message || String(error) });
4610
+ }
4611
+ }
4612
+
4354
4613
  closeModal() {
4355
4614
  const modal = this.getState().ui.modal;
4356
4615
  if (!modal) return;
@@ -4455,6 +4714,10 @@ export class PilotSwarmUiController {
4455
4714
  await this.confirmRenameSessionModal();
4456
4715
  return;
4457
4716
  }
4717
+ if (modal.type === "shareSession") {
4718
+ await this.confirmShareSessionModal();
4719
+ return;
4720
+ }
4458
4721
  if (modal.type === "sessionGroupPicker") {
4459
4722
  await this.confirmSessionGroupPickerModal();
4460
4723
  return;
@@ -6261,6 +6524,12 @@ export class PilotSwarmUiController {
6261
6524
  case UI_COMMANDS.OPEN_RENAME_SESSION:
6262
6525
  this.openRenameSessionModal();
6263
6526
  return;
6527
+ case UI_COMMANDS.CYCLE_SESSION_VISIBILITY:
6528
+ await this.cycleSessionVisibility();
6529
+ return;
6530
+ case UI_COMMANDS.OPEN_SHARE_SESSION:
6531
+ await this.openShareSessionModal();
6532
+ return;
6264
6533
  case UI_COMMANDS.OPEN_SESSION_FILTER:
6265
6534
  this.openSessionOwnerFilter();
6266
6535
  return;
@@ -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
 
@@ -1000,6 +1000,10 @@ export function appReducer(state, action) {
1000
1000
  ui: {
1001
1001
  ...state.ui,
1002
1002
  chatScrollBySession: savedChatScroll,
1003
+ // Status notices are per-moment, usually per-session (send
1004
+ // refusals, access hints) — a stale one must not follow the
1005
+ // user to the next session.
1006
+ statusText: previousActiveId !== action.sessionId ? "" : state.ui.statusText,
1003
1007
  scroll: {
1004
1008
  ...state.ui.scroll,
1005
1009
  chat: Number(savedChatScroll[action.sessionId]) || 0,