agent-relay-server 0.30.0 → 0.31.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/public/index.html CHANGED
@@ -12425,6 +12425,9 @@ var useRelayStore = create$1()(persist((set, get) => ({
12425
12425
  spawnWorkspaceMode: "inherit",
12426
12426
  spawnPrompt: "",
12427
12427
  spawnSystemPromptAppend: "",
12428
+ spawnCount: 1,
12429
+ spawnCwdHistory: [],
12430
+ isSpawning: false,
12428
12431
  spawnDirBrowser: {
12429
12432
  open: false,
12430
12433
  loading: false,
@@ -13925,6 +13928,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
13925
13928
  spawnWorkspaceMode: "inherit",
13926
13929
  spawnPrompt: "",
13927
13930
  spawnSystemPromptAppend: "",
13931
+ spawnCount: 1,
13928
13932
  pendingForkImport: null,
13929
13933
  orchestratorSpawnOpen: true
13930
13934
  });
@@ -13974,6 +13978,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
13974
13978
  spawnWorkspaceMode: "inherit",
13975
13979
  spawnPrompt: "Continue from this previous session.",
13976
13980
  spawnSystemPromptAppend: systemPromptAppend,
13981
+ spawnCount: 1,
13977
13982
  pendingForkImport: {
13978
13983
  sourcePeerId: agent.id,
13979
13984
  sourceAgentId: agent.id,
@@ -14005,12 +14010,35 @@ var useRelayStore = create$1()(persist((set, get) => ({
14005
14010
  spawnWorkspaceMode: "inherit",
14006
14011
  spawnPrompt: "",
14007
14012
  spawnSystemPromptAppend: "",
14013
+ spawnCount: 1,
14008
14014
  pendingForkImport: null,
14009
14015
  orchestratorSpawnOpen: true
14010
14016
  });
14011
14017
  },
14018
+ selectSpawnHistory(orchId, cwd) {
14019
+ const s = get();
14020
+ const orch = s.orchestrators.find((o) => o.id === orchId && o.status === "online");
14021
+ if (!orch) {
14022
+ get().showError("Unavailable", "That orchestrator is no longer online.");
14023
+ return;
14024
+ }
14025
+ if (orchId === s.spawnOrchId) {
14026
+ set({ spawnCwd: cwd });
14027
+ return;
14028
+ }
14029
+ const provider = s.spawnProvider && orch.providers.includes(s.spawnProvider) ? s.spawnProvider : firstAvailableProvider(orch);
14030
+ const model = s.spawnProvider === provider && s.spawnModel ? s.spawnModel : defaultModelFor(provider);
14031
+ set({
14032
+ spawnOrchId: orchId,
14033
+ spawnProvider: provider,
14034
+ spawnModel: model,
14035
+ spawnEffort: s.spawnProvider === provider && s.spawnModel === model ? s.spawnEffort : "",
14036
+ spawnCwd: cwd
14037
+ });
14038
+ },
14012
14039
  async submitOrchestratorSpawn() {
14013
14040
  const s = get();
14041
+ if (s.isSpawning) return;
14014
14042
  if (!s.spawnOrchId) {
14015
14043
  get().showError("Validation", "Select an orchestrator.");
14016
14044
  return;
@@ -14029,34 +14057,42 @@ var useRelayStore = create$1()(persist((set, get) => ({
14029
14057
  get().showError("Validation", `Working directory must be under ${orch.baseDir}.`);
14030
14058
  return;
14031
14059
  }
14060
+ const pendingForkImport = s.pendingForkImport;
14061
+ const count = pendingForkImport ? 1 : Math.max(1, Math.min(10, Math.round(s.spawnCount) || 1));
14062
+ set({ isSpawning: true });
14032
14063
  try {
14033
- const payload = {
14034
- provider: s.spawnProvider,
14035
- approvalMode: s.spawnApproval,
14036
- workspaceMode: s.spawnWorkspaceMode || "inherit"
14037
- };
14038
- if (s.spawnModel) payload.model = s.spawnModel;
14039
- if (s.spawnEffort) payload.effort = s.spawnEffort;
14040
- if (s.spawnProfile) payload.profile = s.spawnProfile;
14041
- payload.cwd = cwd;
14042
- if (s.spawnLabel) payload.label = s.spawnLabel;
14043
- if (s.spawnPrompt) payload.prompt = s.spawnPrompt;
14044
- if (s.spawnSystemPromptAppend) payload.systemPromptAppend = s.spawnSystemPromptAppend;
14045
- const response = await api("POST", "/orchestrators/" + encodeURIComponent(s.spawnOrchId) + "/spawn", payload);
14046
- const pendingForkImport = s.pendingForkImport;
14047
- const targetSpawnRequestId = response.command?.params?.spawnRequestId;
14048
- if (pendingForkImport && targetSpawnRequestId && pendingForkImport.messageIds.length > 0) await api("POST", "/chat/history-imports", {
14049
- targetSpawnRequestId,
14050
- sourcePeerId: pendingForkImport.sourcePeerId,
14051
- sourceAgentId: pendingForkImport.sourceAgentId,
14052
- sourceThreadId: pendingForkImport.sourceThreadId,
14053
- sourceAgentLabel: pendingForkImport.sourceAgentLabel,
14054
- importedBy: INBOX_OPERATOR_ID,
14055
- messageIds: pendingForkImport.messageIds
14056
- });
14064
+ for (let i = 0; i < count; i++) {
14065
+ const payload = {
14066
+ provider: s.spawnProvider,
14067
+ approvalMode: s.spawnApproval,
14068
+ workspaceMode: s.spawnWorkspaceMode || "inherit"
14069
+ };
14070
+ if (s.spawnModel) payload.model = s.spawnModel;
14071
+ if (s.spawnEffort) payload.effort = s.spawnEffort;
14072
+ if (s.spawnProfile) payload.profile = s.spawnProfile;
14073
+ payload.cwd = cwd;
14074
+ if (s.spawnLabel) payload.label = count > 1 ? `${s.spawnLabel} ${i + 1}` : s.spawnLabel;
14075
+ if (s.spawnPrompt) payload.prompt = s.spawnPrompt;
14076
+ if (s.spawnSystemPromptAppend) payload.systemPromptAppend = s.spawnSystemPromptAppend;
14077
+ const targetSpawnRequestId = (await api("POST", "/orchestrators/" + encodeURIComponent(s.spawnOrchId) + "/spawn", payload)).command?.params?.spawnRequestId;
14078
+ if (pendingForkImport && targetSpawnRequestId && pendingForkImport.messageIds.length > 0) await api("POST", "/chat/history-imports", {
14079
+ targetSpawnRequestId,
14080
+ sourcePeerId: pendingForkImport.sourcePeerId,
14081
+ sourceAgentId: pendingForkImport.sourceAgentId,
14082
+ sourceThreadId: pendingForkImport.sourceThreadId,
14083
+ sourceAgentLabel: pendingForkImport.sourceAgentLabel,
14084
+ importedBy: INBOX_OPERATOR_ID,
14085
+ messageIds: pendingForkImport.messageIds
14086
+ });
14087
+ }
14057
14088
  set({
14058
14089
  orchestratorSpawnOpen: false,
14059
- pendingForkImport: null
14090
+ pendingForkImport: null,
14091
+ isSpawning: false,
14092
+ spawnCwdHistory: [{
14093
+ orchId: s.spawnOrchId,
14094
+ cwd
14095
+ }, ...s.spawnCwdHistory.filter((h) => !(h.orchId === s.spawnOrchId && h.cwd === cwd))].slice(0, 5)
14060
14096
  });
14061
14097
  await Promise.all([
14062
14098
  get().fetchAgents(),
@@ -14065,6 +14101,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
14065
14101
  get().fetchChatHistoryImports()
14066
14102
  ]);
14067
14103
  } catch (e) {
14104
+ set({ isSpawning: false });
14068
14105
  get().showError("Spawn Failed", e.message);
14069
14106
  }
14070
14107
  },
@@ -14710,7 +14747,8 @@ var useRelayStore = create$1()(persist((set, get) => ({
14710
14747
  spawnProfile: state.spawnProfile,
14711
14748
  spawnCwd: state.spawnCwd,
14712
14749
  spawnApproval: state.spawnApproval,
14713
- spawnWorkspaceMode: state.spawnWorkspaceMode
14750
+ spawnWorkspaceMode: state.spawnWorkspaceMode,
14751
+ spawnCwdHistory: state.spawnCwdHistory
14714
14752
  })
14715
14753
  }));
14716
14754
  var _voiceActiveChat = null;
@@ -77210,7 +77248,7 @@ function SidebarContent() {
77210
77248
  }
77211
77249
  function Sidebar() {
77212
77250
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("aside", {
77213
- className: "hidden xl:flex w-[250px] min-w-[250px] h-dvh sticky top-0 flex-col border-r border-border bg-card",
77251
+ className: "hidden xl:flex w-[250px] min-w-[250px] h-[calc(100dvh-var(--sat))] sticky top-[var(--sat)] flex-col border-r border-border bg-card",
77214
77252
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
77215
77253
  className: "px-5 py-4 font-bold text-base border-b border-border flex items-center gap-2",
77216
77254
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Radio, { className: "w-4 h-4 text-primary" }), "Agent Relay"]
@@ -77232,7 +77270,7 @@ function MobileDrawer() {
77232
77270
  className: cn$2("xl:hidden fixed inset-0 z-40 bg-black/50 transition-opacity duration-200", open ? "opacity-100" : "opacity-0 pointer-events-none"),
77233
77271
  onClick: () => set({ mobileMenuOpen: false })
77234
77272
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("aside", {
77235
- className: cn$2("xl:hidden fixed inset-y-0 left-0 z-50 w-[280px] flex flex-col bg-card border-r border-border transition-transform duration-200 ease-out", open ? "translate-x-0" : "-translate-x-full"),
77273
+ className: cn$2("xl:hidden fixed inset-y-0 left-0 z-50 w-[280px] flex flex-col pt-[var(--sat)] bg-card border-r border-border transition-transform duration-200 ease-out", open ? "translate-x-0" : "-translate-x-full"),
77236
77274
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
77237
77275
  className: "px-5 py-4 font-bold text-base border-b border-border flex items-center gap-2",
77238
77276
  children: [
@@ -77735,6 +77773,60 @@ function useAgentTerminal(agentId) {
77735
77773
  };
77736
77774
  }
77737
77775
  //#endregion
77776
+ //#region src/hooks/use-keyboard-viewport.ts
77777
+ var KEYBOARD_MIN_DELTA = 100;
77778
+ function isEditableTarget(el) {
77779
+ if (!el) return false;
77780
+ const tag = el.tagName;
77781
+ return tag === "INPUT" || tag === "TEXTAREA" || el.isContentEditable === true;
77782
+ }
77783
+ /**
77784
+ * Returns a pixel height to apply while the on-screen keyboard is open, or null
77785
+ * when it's closed (so the element falls back to its CSS height, e.g. 100dvh).
77786
+ *
77787
+ * iPad Safari ignores `interactive-widget=resizes-content`, so the keyboard
77788
+ * shrinks only the *visual* viewport, never the layout viewport — `dvh` units
77789
+ * don't react to it. Pinning `visualViewport.height` is what actually makes a
77790
+ * fullscreen container shrink for the keyboard there.
77791
+ *
77792
+ * The trap (#270 follow-up): if you pin `vv.height` unconditionally and never
77793
+ * reset it, the container stays shrunk after the keyboard closes, and iOS's
77794
+ * documented post-dismiss bug (a late resize event reporting wrong/offset
77795
+ * dimensions) gets applied blindly — leaving the layout too narrow / dialogs
77796
+ * too tall until an orientation toggle resets the OS viewport.
77797
+ *
77798
+ * So we pin a height ONLY while the keyboard is genuinely open (an editable
77799
+ * element is focused AND the visual viewport is meaningfully shorter than the
77800
+ * layout viewport) and return null otherwise. Returning null hands sizing back
77801
+ * to CSS, which recovers automatically — the same reset the orientation toggle
77802
+ * triggers, minus the manual flip. This also no-ops for iPad hardware keyboards
77803
+ * (no visual-viewport shrink → no spurious pin).
77804
+ *
77805
+ * @param active when false the hook stays inert and returns null (e.g. a closed dialog)
77806
+ */
77807
+ function useKeyboardViewportHeight(active = true) {
77808
+ const [height, setHeight] = (0, import_react.useState)(null);
77809
+ (0, import_react.useEffect)(() => {
77810
+ if (!active) {
77811
+ setHeight(null);
77812
+ return;
77813
+ }
77814
+ const vv = window.visualViewport;
77815
+ if (!vv) return;
77816
+ function onResize() {
77817
+ setHeight(isEditableTarget(document.activeElement) && window.innerHeight - vv.height > KEYBOARD_MIN_DELTA ? vv.height : null);
77818
+ }
77819
+ onResize();
77820
+ vv.addEventListener("resize", onResize);
77821
+ window.addEventListener("focusout", onResize);
77822
+ return () => {
77823
+ vv.removeEventListener("resize", onResize);
77824
+ window.removeEventListener("focusout", onResize);
77825
+ };
77826
+ }, [active]);
77827
+ return height;
77828
+ }
77829
+ //#endregion
77738
77830
  //#region node_modules/comma-separated-tokens/index.js
77739
77831
  /**
77740
77832
  * Serialize an array of strings or numbers to comma-separated tokens.
@@ -126441,11 +126533,13 @@ function TerminalViewer({ orchestratorId, session, interactive: initialInteracti
126441
126533
  });
126442
126534
  }
126443
126535
  function TerminalDialog({ open, onOpenChange, orchestratorId, session, interactive }) {
126536
+ const vpHeight = useKeyboardViewportHeight(open);
126444
126537
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Dialog, {
126445
126538
  open,
126446
126539
  onOpenChange,
126447
126540
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DialogContent, {
126448
- className: "inset-0 translate-x-0 translate-y-0 w-auto h-auto max-w-none rounded-none p-0 bg-background border-border gap-0 overflow-hidden",
126541
+ className: "inset-x-0 top-0 translate-x-0 translate-y-0 w-auto max-w-none rounded-none p-0 pt-[var(--sat)] bg-background border-border gap-0 overflow-hidden",
126542
+ style: { height: vpHeight ? `${vpHeight}px` : "100dvh" },
126449
126543
  showCloseButton: false,
126450
126544
  blurOverlay: false,
126451
126545
  onEscapeKeyDown: (e) => e.preventDefault(),
@@ -128894,7 +128988,7 @@ var MessageBubble = (0, import_react.memo)(function MessageBubble({ msg, peer, o
128894
128988
  type: "button",
128895
128989
  title: isTtsPlaying ? "Stop playback" : "Play aloud",
128896
128990
  onClick: togglePlay,
128897
- className: cn$2("absolute -top-2 -right-2 z-20 inline-flex h-6 w-6 items-center justify-center rounded-full border bg-popover shadow-sm transition", isTtsPlaying ? "border-primary/50 text-primary opacity-100" : "border-border text-muted-foreground opacity-70 hover:bg-muted hover:text-foreground hover:opacity-100 md:opacity-0 md:group-hover/msg:opacity-100"),
128991
+ className: cn$2("absolute -top-2 -right-2 z-20 inline-flex h-6 w-6 items-center justify-center rounded-full border bg-popover shadow-sm transition", isTtsPlaying ? "border-primary/50 text-primary opacity-100" : "border-border text-muted-foreground opacity-70 hover:bg-muted hover:text-foreground hover:opacity-100 [@media(hover:hover)]:md:opacity-0 [@media(hover:hover)]:md:group-hover/msg:opacity-100"),
128898
128992
  children: isTtsPlaying ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CircleStop, { className: "h-3.5 w-3.5 animate-pulse" }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Volume2, { className: "h-3.5 w-3.5" })
128899
128993
  }),
128900
128994
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
@@ -129692,7 +129786,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129692
129786
  onClick: interruptAgent,
129693
129787
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CircleStop, { className: "w-3.5 h-3.5" })
129694
129788
  }),
129695
- agent.branchState === "changes" && agent.branchWorkspaceId && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129789
+ (agent.branchState === "changes" || agent.branchState === "idle") && agent.branchWorkspaceId && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
129696
129790
  variant: "ghost",
129697
129791
  size: "icon-sm",
129698
129792
  className: "hidden @4xl/chat:inline-flex text-amber-400 hover:text-amber-300",
@@ -129787,7 +129881,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129787
129881
  onClick: interruptAgent,
129788
129882
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(CircleStop, { className: "w-3.5 h-3.5" }), "Interrupt"]
129789
129883
  }),
129790
- agent.branchState === "changes" && agent.branchWorkspaceId && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DropdownMenuItem, {
129884
+ (agent.branchState === "changes" || agent.branchState === "idle") && agent.branchWorkspaceId && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DropdownMenuItem, {
129791
129885
  onClick: () => openConfirm("Mark Workspace Ready", `Mark ${displayName(agent)}'s branch ready to land? The relay auto-merge will rebase and land it.`, () => workspaceAction(agent.branchWorkspaceId, "ready")),
129792
129886
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Flag, { className: "w-3.5 h-3.5" }), "Mark Ready"]
129793
129887
  }),
@@ -130113,7 +130207,7 @@ function ChatView() {
130113
130207
  }
130114
130208
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
130115
130209
  className: "flex -m-4 md:-m-6",
130116
- style: { height: "calc(100dvh - var(--header-h))" },
130210
+ style: { height: "calc(100dvh - var(--header-h) - var(--sat))" },
130117
130211
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
130118
130212
  className: "hidden md:flex w-full",
130119
130213
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
@@ -130137,19 +130231,9 @@ function ChatView() {
130137
130231
  });
130138
130232
  }
130139
130233
  function MobileChatContainer({ children }) {
130140
- const [height, setHeight] = (0, import_react.useState)(null);
130141
- (0, import_react.useEffect)(() => {
130142
- const vv = window.visualViewport;
130143
- if (!vv) return;
130144
- function onResize() {
130145
- setHeight(vv.height);
130146
- }
130147
- onResize();
130148
- vv.addEventListener("resize", onResize);
130149
- return () => vv.removeEventListener("resize", onResize);
130150
- }, []);
130234
+ const height = useKeyboardViewportHeight();
130151
130235
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
130152
- className: "fixed inset-x-0 top-0 z-40 flex flex-col bg-background",
130236
+ className: "fixed inset-x-0 top-0 z-40 flex flex-col bg-background pt-[var(--sat)]",
130153
130237
  style: { height: height ? `${height}px` : "100dvh" },
130154
130238
  children
130155
130239
  });
@@ -130313,7 +130397,7 @@ function AgentCard({ agent }) {
130313
130397
  onClick: () => void handleOpenTerminal(),
130314
130398
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Terminal, { className: "w-3 h-3" })
130315
130399
  }),
130316
- agent.branchState === "changes" && agent.branchWorkspaceId && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
130400
+ (agent.branchState === "changes" || agent.branchState === "idle") && agent.branchWorkspaceId && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
130317
130401
  size: "icon",
130318
130402
  variant: "ghost",
130319
130403
  className: "h-7 w-7 text-amber-400 hover:text-amber-300",
@@ -155596,7 +155680,7 @@ function InsightsView() {
155596
155680
  }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
155597
155681
  className: "overflow-x-auto",
155598
155682
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("table", {
155599
- className: "w-full text-left text-xs",
155683
+ className: "w-full min-w-[680px] text-left text-xs",
155600
155684
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("thead", {
155601
155685
  className: "text-muted-foreground",
155602
155686
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", {
@@ -159356,15 +159440,23 @@ function OrchestratorSpawnModal() {
159356
159440
  const workspaceMode = useRelayStore((s) => s.spawnWorkspaceMode);
159357
159441
  const prompt = useRelayStore((s) => s.spawnPrompt);
159358
159442
  const systemPromptAppend = useRelayStore((s) => s.spawnSystemPromptAppend);
159443
+ const count = useRelayStore((s) => s.spawnCount);
159444
+ const cwdHistory = useRelayStore((s) => s.spawnCwdHistory);
159445
+ const isSpawning = useRelayStore((s) => s.isSpawning);
159446
+ const isFork = useRelayStore((s) => Boolean(s.pendingForkImport));
159359
159447
  const set = useRelayStore((s) => s.set);
159360
159448
  const submitOrchestratorSpawn = useRelayStore((s) => s.submitOrchestratorSpawn);
159449
+ const selectSpawnHistory = useRelayStore((s) => s.selectSpawnHistory);
159361
159450
  const agentProfiles = useRelayStore((s) => s.agentProfiles);
159362
159451
  const orch = orchestrators.find((o) => o.id === orchId);
159363
159452
  const providers = orch?.providers || [];
159364
159453
  const models = PROVIDER_CATALOG[provider]?.models || [];
159365
159454
  const selectedModel = models.find((m) => m.alias === model);
159366
159455
  const efforts = selectedModel?.efforts || [];
159367
- const canSpawn = Boolean(orch && providers.length > 0 && cwd.trim());
159456
+ const canSpawn = Boolean(orch && providers.length > 0 && cwd.trim()) && !isSpawning;
159457
+ const onlineOrchs = orchestrators.filter((o) => o.status === "online");
159458
+ const multiOrch = onlineOrchs.length > 1;
159459
+ const recentDirs = cwdHistory.filter((h) => onlineOrchs.some((o) => o.id === h.orchId)).slice(0, 5);
159368
159460
  function selectOrchestrator(nextId) {
159369
159461
  const nextOrch = orchestrators.find((o) => o.id === nextId);
159370
159462
  const nextProvider = nextOrch?.providers[0] || "";
@@ -159394,6 +159486,7 @@ function OrchestratorSpawnModal() {
159394
159486
  onOpenChange: (o) => !o && set({ orchestratorSpawnOpen: false }),
159395
159487
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DialogContent, {
159396
159488
  className: "max-w-lg max-h-[90dvh] flex flex-col",
159489
+ onOpenAutoFocus: (e) => e.preventDefault(),
159397
159490
  children: [
159398
159491
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DialogHeader, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DialogTitle, { children: "Spawn Agent" }) }),
159399
159492
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
@@ -159502,15 +159595,39 @@ function OrchestratorSpawnModal() {
159502
159595
  })
159503
159596
  ]
159504
159597
  })] }),
159505
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label, { children: "Working Directory" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
159506
- className: "mt-1",
159507
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DirectoryPicker, {
159508
- value: cwd,
159509
- onChange: (path) => set({ spawnCwd: path }),
159510
- orchestratorId: orchId,
159511
- placeholder: "Select a project directory"
159598
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
159599
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label, { children: "Working Directory" }),
159600
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
159601
+ className: "mt-1",
159602
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DirectoryPicker, {
159603
+ value: cwd,
159604
+ onChange: (path) => set({ spawnCwd: path }),
159605
+ orchestratorId: orchId,
159606
+ placeholder: "Select a project directory"
159607
+ })
159608
+ }),
159609
+ recentDirs.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
159610
+ className: "mt-2",
159611
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
159612
+ className: "text-xs text-muted-foreground mb-1",
159613
+ children: "Recent"
159614
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
159615
+ className: "flex gap-1.5 overflow-x-auto pb-1 sm:flex-wrap sm:overflow-x-visible sm:pb-0",
159616
+ children: recentDirs.map((h) => {
159617
+ const base = h.cwd.replace(/\/+$/, "").split("/").pop() || h.cwd;
159618
+ const host = onlineOrchs.find((o) => o.id === h.orchId)?.hostname;
159619
+ const active = h.orchId === orchId && h.cwd === cwd;
159620
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
159621
+ type: "button",
159622
+ title: multiOrch && host ? `${host}: ${h.cwd}` : h.cwd,
159623
+ onClick: () => selectSpawnHistory(h.orchId, h.cwd),
159624
+ className: `shrink-0 rounded-full border px-2.5 py-1 text-xs font-mono transition-colors ${active ? "border-primary bg-primary/10 text-foreground" : "border-input bg-background text-muted-foreground hover:bg-accent hover:text-foreground"}`,
159625
+ children: multiOrch && host ? `${host}: ${base}` : base
159626
+ }, `${h.orchId}:${h.cwd}`);
159627
+ })
159628
+ })]
159512
159629
  })
159513
- })] }),
159630
+ ] }),
159514
159631
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label, { children: "Label" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
159515
159632
  value: label,
159516
159633
  onChange: (e) => set({ spawnLabel: e.target.value }),
@@ -159541,17 +159658,47 @@ function OrchestratorSpawnModal() {
159541
159658
  className: "text-xs text-muted-foreground mt-1",
159542
159659
  children: "Use for fork history or launch context. It informs the session but does not start work by itself."
159543
159660
  })
159661
+ ] }),
159662
+ !isFork && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [
159663
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
159664
+ className: "flex items-center justify-between",
159665
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label, { children: "Number of Agents" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
159666
+ className: "text-sm font-mono tabular-nums text-muted-foreground",
159667
+ children: count
159668
+ })]
159669
+ }),
159670
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
159671
+ type: "range",
159672
+ min: 1,
159673
+ max: 10,
159674
+ step: 1,
159675
+ value: count,
159676
+ onChange: (e) => set({ spawnCount: Number(e.target.value) }),
159677
+ className: "w-full mt-2 accent-primary",
159678
+ "aria-label": "Number of agents to spawn"
159679
+ }),
159680
+ count > 1 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", {
159681
+ className: "text-xs text-muted-foreground mt-1",
159682
+ children: [
159683
+ "Spawns ",
159684
+ count,
159685
+ " identical agents",
159686
+ label.trim() ? `, labelled "${label.trim()} 1"…"${label.trim()} ${count}"` : "",
159687
+ "."
159688
+ ]
159689
+ })
159544
159690
  ] })
159545
159691
  ]
159546
159692
  }),
159547
159693
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DialogFooter, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
159548
159694
  variant: "outline",
159549
159695
  onClick: () => set({ orchestratorSpawnOpen: false }),
159696
+ disabled: isSpawning,
159550
159697
  children: "Cancel"
159551
159698
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
159552
159699
  onClick: submitOrchestratorSpawn,
159553
159700
  disabled: !canSpawn,
159554
- children: providers.length ? "Spawn" : "No providers"
159701
+ children: isSpawning ? "Spawning…" : providers.length ? count > 1 ? `Spawn ${count}` : "Spawn" : "No providers"
159555
159702
  })] })
159556
159703
  ]
159557
159704
  })
@@ -160677,12 +160824,12 @@ function App() {
160677
160824
  }, [commandPaletteOpen]);
160678
160825
  const ViewComponent = views[view] || OverviewView;
160679
160826
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ErrorBoundary, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TooltipProvider, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
160680
- className: "flex min-h-dvh",
160827
+ className: "flex min-h-[calc(100dvh-var(--sat))] mt-[var(--sat)]",
160681
160828
  children: [
160682
160829
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Sidebar, {}),
160683
160830
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MobileDrawer, {}),
160684
160831
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("main", {
160685
- className: "flex-1 h-dvh overflow-y-auto",
160832
+ className: "flex-1 min-w-0 h-[calc(100dvh-var(--sat))] overflow-y-auto",
160686
160833
  children: [
160687
160834
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MobileNav, {}),
160688
160835
  !authNeeded && connectionError && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
@@ -161305,6 +161452,10 @@ if ("serviceWorker" in navigator) {
161305
161452
  top: calc(var(--header-h, 3rem) + .5rem);
161306
161453
  }
161307
161454
 
161455
+ .top-\[var\(--sat\)\] {
161456
+ top: var(--sat);
161457
+ }
161458
+
161308
161459
  .-right-0\.5 {
161309
161460
  right: calc(var(--spacing) * -.5);
161310
161461
  }
@@ -161507,6 +161658,10 @@ if ("serviceWorker" in navigator) {
161507
161658
  margin-top: calc(var(--spacing) * 4);
161508
161659
  }
161509
161660
 
161661
+ .mt-\[var\(--sat\)\] {
161662
+ margin-top: var(--sat);
161663
+ }
161664
+
161510
161665
  .mt-auto {
161511
161666
  margin-top: auto;
161512
161667
  }
@@ -161761,16 +161916,12 @@ if ("serviceWorker" in navigator) {
161761
161916
  height: calc(100dvh - 24rem);
161762
161917
  }
161763
161918
 
161764
- .h-\[var\(--header-h\)\] {
161765
- height: var(--header-h);
161919
+ .h-\[calc\(100dvh-var\(--sat\)\)\] {
161920
+ height: calc(100dvh - var(--sat));
161766
161921
  }
161767
161922
 
161768
- .h-auto {
161769
- height: auto;
161770
- }
161771
-
161772
- .h-dvh {
161773
- height: 100dvh;
161923
+ .h-\[var\(--header-h\)\] {
161924
+ height: var(--header-h);
161774
161925
  }
161775
161926
 
161776
161927
  .h-fit {
@@ -161893,8 +162044,8 @@ if ("serviceWorker" in navigator) {
161893
162044
  min-height: 360px;
161894
162045
  }
161895
162046
 
161896
- .min-h-dvh {
161897
- min-height: 100dvh;
162047
+ .min-h-\[calc\(100dvh-var\(--sat\)\)\] {
162048
+ min-height: calc(100dvh - var(--sat));
161898
162049
  }
161899
162050
 
161900
162051
  .w-\(--radix-dropdown-menu-trigger-width\) {
@@ -162133,6 +162284,10 @@ if ("serviceWorker" in navigator) {
162133
162284
  min-width: 360px;
162134
162285
  }
162135
162286
 
162287
+ .min-w-\[680px\] {
162288
+ min-width: 680px;
162289
+ }
162290
+
162136
162291
  .min-w-\[980px\] {
162137
162292
  min-width: 980px;
162138
162293
  }
@@ -162141,6 +162296,10 @@ if ("serviceWorker" in navigator) {
162141
162296
  flex: 1;
162142
162297
  }
162143
162298
 
162299
+ .shrink {
162300
+ flex-shrink: 1;
162301
+ }
162302
+
162144
162303
  .shrink-0 {
162145
162304
  flex-shrink: 0;
162146
162305
  }
@@ -163913,6 +164072,10 @@ if ("serviceWorker" in navigator) {
163913
164072
  padding-top: 15vh;
163914
164073
  }
163915
164074
 
164075
+ .pt-\[var\(--sat\)\] {
164076
+ padding-top: var(--sat);
164077
+ }
164078
+
163916
164079
  .pr-2 {
163917
164080
  padding-right: calc(var(--spacing) * 2);
163918
164081
  }
@@ -164515,6 +164678,10 @@ if ("serviceWorker" in navigator) {
164515
164678
  -moz-osx-font-smoothing: grayscale;
164516
164679
  }
164517
164680
 
164681
+ .accent-primary {
164682
+ accent-color: var(--primary);
164683
+ }
164684
+
164518
164685
  .opacity-0 {
164519
164686
  opacity: 0;
164520
164687
  }
@@ -165730,10 +165897,18 @@ if ("serviceWorker" in navigator) {
165730
165897
  flex-direction: row;
165731
165898
  }
165732
165899
 
165900
+ .sm\:flex-wrap {
165901
+ flex-wrap: wrap;
165902
+ }
165903
+
165733
165904
  .sm\:justify-end {
165734
165905
  justify-content: flex-end;
165735
165906
  }
165736
165907
 
165908
+ .sm\:overflow-x-visible {
165909
+ overflow-x: visible;
165910
+ }
165911
+
165737
165912
  .sm\:px-2 {
165738
165913
  padding-inline: calc(var(--spacing) * 2);
165739
165914
  }
@@ -165742,6 +165917,10 @@ if ("serviceWorker" in navigator) {
165742
165917
  padding-right: calc(var(--spacing) * 3);
165743
165918
  }
165744
165919
 
165920
+ .sm\:pb-0 {
165921
+ padding-bottom: calc(var(--spacing) * 0);
165922
+ }
165923
+
165745
165924
  .sm\:opacity-0 {
165746
165925
  opacity: 0;
165747
165926
  }
@@ -165886,10 +166065,6 @@ if ("serviceWorker" in navigator) {
165886
166065
  text-wrap: pretty;
165887
166066
  }
165888
166067
 
165889
- .md\:opacity-0 {
165890
- opacity: 0;
165891
- }
165892
-
165893
166068
  @media (hover: hover) {
165894
166069
  .md\:group-hover\/msg\:pointer-events-auto:is(:where(.group\/msg):hover *) {
165895
166070
  pointer-events: auto;
@@ -166500,6 +166675,20 @@ if ("serviceWorker" in navigator) {
166500
166675
  height: calc(var(--spacing) * 3) !important;
166501
166676
  }
166502
166677
 
166678
+ @media (hover: hover) {
166679
+ @media (min-width: 48rem) {
166680
+ .\[\@media\(hover\:hover\)\]\:md\:opacity-0 {
166681
+ opacity: 0;
166682
+ }
166683
+
166684
+ @media (hover: hover) {
166685
+ .\[\@media\(hover\:hover\)\]\:md\:group-hover\/msg\:opacity-100:is(:where(.group\/msg):hover *) {
166686
+ opacity: 1;
166687
+ }
166688
+ }
166689
+ }
166690
+ }
166691
+
166503
166692
  .safe-area-bottom {
166504
166693
  padding-bottom: max(.625rem, env(safe-area-inset-bottom));
166505
166694
  }
@@ -167103,6 +167292,7 @@ if ("serviceWorker" in navigator) {
167103
167292
 
167104
167293
  :root {
167105
167294
  --header-h: 3rem;
167295
+ --sat: env(safe-area-inset-top, 0px);
167106
167296
  }
167107
167297
 
167108
167298
  .code-preview .shiki {
@@ -1,7 +1,9 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
3
  import { homedir, hostname } from "node:os";
3
4
  import { join, resolve } from "node:path";
4
5
  import { DEFAULT_RELAY_URL, stringValue } from "agent-relay-sdk";
6
+ import type { WorkspaceMetadata } from "agent-relay-sdk";
5
7
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
6
8
  import type { ProviderConfig } from "./adapter";
7
9
 
@@ -101,6 +103,37 @@ export function runnerId(provider: string, cwd: string, label?: string): string
101
103
  return `${hostname()}-${provider}-${cleanLabel}-${crypto.randomUUID().slice(0, 8)}`;
102
104
  }
103
105
 
106
+ /**
107
+ * Stable project identifier for insights aggregation: the repo NAME, never a
108
+ * per-branch/per-session worktree dir or full path. Isolated workspace agents run
109
+ * from a session-specific worktree (…/workspaces/<repo>/<id>) whose basename is
110
+ * unique per session — using it would scatter one repo's data across many "projects"
111
+ * and break per-project rollups. So we resolve up to the main repo root, then take
112
+ * its basename. Falls back to the git toplevel of cwd (handles a direct agent
113
+ * launched in a subdir), then to the cwd basename for a non-git directory.
114
+ */
115
+ export function resolveProjectName(cwd: string, workspace?: WorkspaceMetadata): string {
116
+ const root =
117
+ workspace?.repoRoot ||
118
+ workspace?.probe?.repoRoot ||
119
+ gitToplevel(cwd) ||
120
+ workspace?.sourceCwd ||
121
+ cwd;
122
+ return root.split("/").filter(Boolean).at(-1) || "unknown";
123
+ }
124
+
125
+ function gitToplevel(cwd: string): string | undefined {
126
+ try {
127
+ const out = execFileSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
128
+ encoding: "utf8",
129
+ stdio: ["ignore", "pipe", "ignore"],
130
+ }).trim();
131
+ return out || undefined;
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
104
137
  export function resolveCwd(value: string | undefined, fallback: string): string {
105
138
  return resolve(value || fallback);
106
139
  }
package/src/cli.ts CHANGED
@@ -143,7 +143,8 @@ Upgrade options:
143
143
  --providers LIST Provider integrations to upgrade: auto, all, codex, claude, orchestrator
144
144
  --host ID Upgrade a remote orchestrator host over the relay (repeatable). Skips the local upgrade
145
145
  --all-hosts Upgrade this host, then fan out to every connected remote host that is behind
146
- --no-restart Do not restart agent-relay.service
146
+ --no-restart Do not restart agent-relay.service (warns you to restart it manually)
147
+ --restart-deferred Like --no-restart, but the caller restarts the services itself; suppresses the manual-restart warning (used by the release script)
147
148
  --dry-run Print detected state and planned commands
148
149
  --yes Skip confirmation prompts
149
150
 
@@ -402,6 +403,7 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
402
403
  let targetVersion: string | undefined;
403
404
  let dryRun = false;
404
405
  let noRestart = false;
406
+ let restartDeferred = false;
405
407
  let yes = false;
406
408
  let json = false;
407
409
  let runtimePrefix: string | undefined;
@@ -425,6 +427,7 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
425
427
  else if (arg === "--all") providers.push("all");
426
428
  else if (arg === "--dry-run") dryRun = true;
427
429
  else if (arg === "--no-restart") noRestart = true;
430
+ else if (arg === "--restart-deferred") restartDeferred = true;
428
431
  else if (arg === "--yes" || arg === "-y") yes = true;
429
432
  else if (arg === "--json") json = true;
430
433
  else throw new Error(`Unknown upgrade option "${arg}"`);
@@ -449,6 +452,7 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
449
452
  ...(runtimePrefix ? { runtimePrefix } : {}),
450
453
  providers,
451
454
  noRestart,
455
+ restartDeferred,
452
456
  });
453
457
 
454
458
  if (json) {
@@ -2034,7 +2038,7 @@ async function handleIntrospectCommand(args: string[]): Promise<void> {
2034
2038
 
2035
2039
  const observation = await apiRequest("POST", "/api/insights/observations", {
2036
2040
  sessionId: sessionId || process.env.AGENT_RELAY_PROVIDER_SESSION_ID || `manual-${from}`,
2037
- project: project || process.cwd(),
2041
+ project: project || process.env.AGENT_RELAY_PROJECT || process.cwd().split("/").filter(Boolean).at(-1) || process.cwd(),
2038
2042
  agentId: from,
2039
2043
  signal: "introspection",
2040
2044
  source: "agent",
package/src/upgrade.ts CHANGED
@@ -13,6 +13,13 @@ type UpgradeOptions = {
13
13
  targetVersion?: string;
14
14
  providers?: UpgradeProvider[];
15
15
  noRestart?: boolean;
16
+ /**
17
+ * Like `noRestart` (no restart action → no premature post-restart verify),
18
+ * but the caller restarts the services itself right after install (the
19
+ * release script does). Suppresses the "restart manually" warning, which is
20
+ * a false alarm in that flow — only the caller knows the restart is coming.
21
+ */
22
+ restartDeferred?: boolean;
16
23
  runtimePrefix?: string;
17
24
  };
18
25
 
@@ -206,10 +213,14 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
206
213
  warnings.push("Agent Relay orchestrator not detected; skipping orchestrator package upgrade.");
207
214
  }
208
215
 
216
+ // `restartDeferred` means a caller restarts for us — skip the restart action
217
+ // (and thus the post-restart verify) exactly like `noRestart`, but without the
218
+ // "restart manually" warning, which would be a false alarm in that flow.
219
+ const deferRestart = Boolean(options.noRestart || options.restartDeferred);
209
220
  const serverRestartNeeded = serverPackageUpdated || Boolean(snapshot.runningServerVersion && snapshot.runningServerVersion !== targetVersion);
210
221
  if (snapshot.hasSystemdUserService && serverRestartNeeded) {
211
- if (options.noRestart) {
212
- warnings.push("agent-relay.service detected but --no-restart was set; restart manually to run the upgraded server.");
222
+ if (deferRestart) {
223
+ if (!options.restartDeferred) warnings.push("agent-relay.service detected but --no-restart was set; restart manually to run the upgraded server.");
213
224
  } else {
214
225
  actions.push({
215
226
  label: "Restart Agent Relay service",
@@ -226,8 +237,8 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
226
237
  Boolean((orch.version && orch.version !== targetVersion) || orch.health?.restartRequired)
227
238
  );
228
239
  if (snapshot.hasSystemdUserOrchestratorService && orchestratorRestartNeeded) {
229
- if (options.noRestart) {
230
- warnings.push("agent-relay-orchestrator.service detected but --no-restart was set; restart manually to run the upgraded orchestrator.");
240
+ if (deferRestart) {
241
+ if (!options.restartDeferred) warnings.push("agent-relay-orchestrator.service detected but --no-restart was set; restart manually to run the upgraded orchestrator.");
231
242
  } else {
232
243
  actions.push({
233
244
  label: "Restart Agent Relay orchestrator service",
@@ -232,9 +232,12 @@ function metaNumber(meta: Record<string, unknown> | undefined, key: string): num
232
232
  //
233
233
  // idle/changes need the worktree's ahead/dirty counts, which the relay isn't in the
234
234
  // git path to know live — the ~2 min conflict scan stashes them in metadata
235
- // (`gitAhead`/`gitDirtyCount`). Until the first scan they're absent, so an active
236
- // worktree shows the optimistic `changes` (the high-value "mark ready" affordance);
237
- // the scan then settles it to `idle` when genuinely empty (#236 v1 option a).
235
+ // (`gitAhead`/`gitDirtyCount`). Until the first scan they're absent: presume `idle`,
236
+ // because a freshly-spawned (or freshly-rebased post-land `--N`) worktree genuinely
237
+ // has nothing ahead/dirty, and the old optimistic `changes` default mislabeled every
238
+ // brand-new branch as having unlanded work (#236 v2). The scan flips it to `changes`
239
+ // within one sweep once real commits/dirt appear. The "mark ready" affordance no
240
+ // longer depends on this guess — the dashboard offers it on idle too.
238
241
  //
239
242
  // Returns undefined for non-branch / torn-down workspaces (no badge).
240
243
  export function deriveBranchState(
@@ -248,7 +251,8 @@ export function deriveBranchState(
248
251
  const meta = workspace.metadata as Record<string, unknown> | undefined;
249
252
  const ahead = metaNumber(meta, "gitAhead");
250
253
  const dirty = metaNumber(meta, "gitDirtyCount");
251
- if (ahead === undefined && dirty === undefined) return "changes";
254
+ // Unprobed presume idle (clean fresh/rebased branch); the scan reveals real work.
255
+ if (ahead === undefined && dirty === undefined) return "idle";
252
256
  return (ahead ?? 0) > 0 || (dirty ?? 0) > 0 ? "changes" : "idle";
253
257
  }
254
258
  case "ready":