agent-relay-server 0.30.1 → 0.31.1

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.1",
3
+ "version": "0.31.1",
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;
@@ -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,18 +126533,7 @@ function TerminalViewer({ orchestratorId, session, interactive: initialInteracti
126441
126533
  });
126442
126534
  }
126443
126535
  function TerminalDialog({ open, onOpenChange, orchestratorId, session, interactive }) {
126444
- const [vpHeight, setVpHeight] = (0, import_react.useState)(null);
126445
- (0, import_react.useEffect)(() => {
126446
- if (!open) return;
126447
- const vv = window.visualViewport;
126448
- if (!vv) return;
126449
- function onResize() {
126450
- setVpHeight(vv.height);
126451
- }
126452
- onResize();
126453
- vv.addEventListener("resize", onResize);
126454
- return () => vv.removeEventListener("resize", onResize);
126455
- }, [open]);
126536
+ const vpHeight = useKeyboardViewportHeight(open);
126456
126537
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Dialog, {
126457
126538
  open,
126458
126539
  onOpenChange,
@@ -128907,7 +128988,7 @@ var MessageBubble = (0, import_react.memo)(function MessageBubble({ msg, peer, o
128907
128988
  type: "button",
128908
128989
  title: isTtsPlaying ? "Stop playback" : "Play aloud",
128909
128990
  onClick: togglePlay,
128910
- 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"),
128911
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" })
128912
128993
  }),
128913
128994
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
@@ -129705,7 +129786,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129705
129786
  onClick: interruptAgent,
129706
129787
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CircleStop, { className: "w-3.5 h-3.5" })
129707
129788
  }),
129708
- 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, {
129709
129790
  variant: "ghost",
129710
129791
  size: "icon-sm",
129711
129792
  className: "hidden @4xl/chat:inline-flex text-amber-400 hover:text-amber-300",
@@ -129800,7 +129881,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
129800
129881
  onClick: interruptAgent,
129801
129882
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(CircleStop, { className: "w-3.5 h-3.5" }), "Interrupt"]
129802
129883
  }),
129803
- 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, {
129804
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")),
129805
129886
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Flag, { className: "w-3.5 h-3.5" }), "Mark Ready"]
129806
129887
  }),
@@ -130150,17 +130231,7 @@ function ChatView() {
130150
130231
  });
130151
130232
  }
130152
130233
  function MobileChatContainer({ children }) {
130153
- const [height, setHeight] = (0, import_react.useState)(null);
130154
- (0, import_react.useEffect)(() => {
130155
- const vv = window.visualViewport;
130156
- if (!vv) return;
130157
- function onResize() {
130158
- setHeight(vv.height);
130159
- }
130160
- onResize();
130161
- vv.addEventListener("resize", onResize);
130162
- return () => vv.removeEventListener("resize", onResize);
130163
- }, []);
130234
+ const height = useKeyboardViewportHeight();
130164
130235
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
130165
130236
  className: "fixed inset-x-0 top-0 z-40 flex flex-col bg-background pt-[var(--sat)]",
130166
130237
  style: { height: height ? `${height}px` : "100dvh" },
@@ -130326,7 +130397,7 @@ function AgentCard({ agent }) {
130326
130397
  onClick: () => void handleOpenTerminal(),
130327
130398
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Terminal, { className: "w-3 h-3" })
130328
130399
  }),
130329
- 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, {
130330
130401
  size: "icon",
130331
130402
  variant: "ghost",
130332
130403
  className: "h-7 w-7 text-amber-400 hover:text-amber-300",
@@ -155609,7 +155680,7 @@ function InsightsView() {
155609
155680
  }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
155610
155681
  className: "overflow-x-auto",
155611
155682
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("table", {
155612
- className: "w-full text-left text-xs",
155683
+ className: "w-full min-w-[680px] text-left text-xs",
155613
155684
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("thead", {
155614
155685
  className: "text-muted-foreground",
155615
155686
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("tr", {
@@ -159369,15 +159440,23 @@ function OrchestratorSpawnModal() {
159369
159440
  const workspaceMode = useRelayStore((s) => s.spawnWorkspaceMode);
159370
159441
  const prompt = useRelayStore((s) => s.spawnPrompt);
159371
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));
159372
159447
  const set = useRelayStore((s) => s.set);
159373
159448
  const submitOrchestratorSpawn = useRelayStore((s) => s.submitOrchestratorSpawn);
159449
+ const selectSpawnHistory = useRelayStore((s) => s.selectSpawnHistory);
159374
159450
  const agentProfiles = useRelayStore((s) => s.agentProfiles);
159375
159451
  const orch = orchestrators.find((o) => o.id === orchId);
159376
159452
  const providers = orch?.providers || [];
159377
159453
  const models = PROVIDER_CATALOG[provider]?.models || [];
159378
159454
  const selectedModel = models.find((m) => m.alias === model);
159379
159455
  const efforts = selectedModel?.efforts || [];
159380
- 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);
159381
159460
  function selectOrchestrator(nextId) {
159382
159461
  const nextOrch = orchestrators.find((o) => o.id === nextId);
159383
159462
  const nextProvider = nextOrch?.providers[0] || "";
@@ -159407,6 +159486,7 @@ function OrchestratorSpawnModal() {
159407
159486
  onOpenChange: (o) => !o && set({ orchestratorSpawnOpen: false }),
159408
159487
  children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DialogContent, {
159409
159488
  className: "max-w-lg max-h-[90dvh] flex flex-col",
159489
+ onOpenAutoFocus: (e) => e.preventDefault(),
159410
159490
  children: [
159411
159491
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DialogHeader, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DialogTitle, { children: "Spawn Agent" }) }),
159412
159492
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
@@ -159515,15 +159595,39 @@ function OrchestratorSpawnModal() {
159515
159595
  })
159516
159596
  ]
159517
159597
  })] }),
159518
- /* @__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", {
159519
- className: "mt-1",
159520
- children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DirectoryPicker, {
159521
- value: cwd,
159522
- onChange: (path) => set({ spawnCwd: path }),
159523
- orchestratorId: orchId,
159524
- 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
+ })]
159525
159629
  })
159526
- })] }),
159630
+ ] }),
159527
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, {
159528
159632
  value: label,
159529
159633
  onChange: (e) => set({ spawnLabel: e.target.value }),
@@ -159554,17 +159658,47 @@ function OrchestratorSpawnModal() {
159554
159658
  className: "text-xs text-muted-foreground mt-1",
159555
159659
  children: "Use for fork history or launch context. It informs the session but does not start work by itself."
159556
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
+ })
159557
159690
  ] })
159558
159691
  ]
159559
159692
  }),
159560
159693
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DialogFooter, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
159561
159694
  variant: "outline",
159562
159695
  onClick: () => set({ orchestratorSpawnOpen: false }),
159696
+ disabled: isSpawning,
159563
159697
  children: "Cancel"
159564
159698
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
159565
159699
  onClick: submitOrchestratorSpawn,
159566
159700
  disabled: !canSpawn,
159567
- children: providers.length ? "Spawn" : "No providers"
159701
+ children: isSpawning ? "Spawning…" : providers.length ? count > 1 ? `Spawn ${count}` : "Spawn" : "No providers"
159568
159702
  })] })
159569
159703
  ]
159570
159704
  })
@@ -160695,7 +160829,7 @@ function App() {
160695
160829
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Sidebar, {}),
160696
160830
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MobileDrawer, {}),
160697
160831
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("main", {
160698
- className: "flex-1 h-[calc(100dvh-var(--sat))] overflow-y-auto",
160832
+ className: "flex-1 min-w-0 h-[calc(100dvh-var(--sat))] overflow-y-auto",
160699
160833
  children: [
160700
160834
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MobileNav, {}),
160701
160835
  !authNeeded && connectionError && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
@@ -162150,6 +162284,10 @@ if ("serviceWorker" in navigator) {
162150
162284
  min-width: 360px;
162151
162285
  }
162152
162286
 
162287
+ .min-w-\[680px\] {
162288
+ min-width: 680px;
162289
+ }
162290
+
162153
162291
  .min-w-\[980px\] {
162154
162292
  min-width: 980px;
162155
162293
  }
@@ -162158,6 +162296,10 @@ if ("serviceWorker" in navigator) {
162158
162296
  flex: 1;
162159
162297
  }
162160
162298
 
162299
+ .shrink {
162300
+ flex-shrink: 1;
162301
+ }
162302
+
162161
162303
  .shrink-0 {
162162
162304
  flex-shrink: 0;
162163
162305
  }
@@ -164536,6 +164678,10 @@ if ("serviceWorker" in navigator) {
164536
164678
  -moz-osx-font-smoothing: grayscale;
164537
164679
  }
164538
164680
 
164681
+ .accent-primary {
164682
+ accent-color: var(--primary);
164683
+ }
164684
+
164539
164685
  .opacity-0 {
164540
164686
  opacity: 0;
164541
164687
  }
@@ -165751,10 +165897,18 @@ if ("serviceWorker" in navigator) {
165751
165897
  flex-direction: row;
165752
165898
  }
165753
165899
 
165900
+ .sm\:flex-wrap {
165901
+ flex-wrap: wrap;
165902
+ }
165903
+
165754
165904
  .sm\:justify-end {
165755
165905
  justify-content: flex-end;
165756
165906
  }
165757
165907
 
165908
+ .sm\:overflow-x-visible {
165909
+ overflow-x: visible;
165910
+ }
165911
+
165758
165912
  .sm\:px-2 {
165759
165913
  padding-inline: calc(var(--spacing) * 2);
165760
165914
  }
@@ -165763,6 +165917,10 @@ if ("serviceWorker" in navigator) {
165763
165917
  padding-right: calc(var(--spacing) * 3);
165764
165918
  }
165765
165919
 
165920
+ .sm\:pb-0 {
165921
+ padding-bottom: calc(var(--spacing) * 0);
165922
+ }
165923
+
165766
165924
  .sm\:opacity-0 {
165767
165925
  opacity: 0;
165768
165926
  }
@@ -165907,10 +166065,6 @@ if ("serviceWorker" in navigator) {
165907
166065
  text-wrap: pretty;
165908
166066
  }
165909
166067
 
165910
- .md\:opacity-0 {
165911
- opacity: 0;
165912
- }
165913
-
165914
166068
  @media (hover: hover) {
165915
166069
  .md\:group-hover\/msg\:pointer-events-auto:is(:where(.group\/msg):hover *) {
165916
166070
  pointer-events: auto;
@@ -166521,6 +166675,20 @@ if ("serviceWorker" in navigator) {
166521
166675
  height: calc(var(--spacing) * 3) !important;
166522
166676
  }
166523
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
+
166524
166692
  .safe-area-bottom {
166525
166693
  padding-bottom: max(.625rem, env(safe-area-inset-bottom));
166526
166694
  }
@@ -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/agent-ref.ts CHANGED
@@ -105,6 +105,24 @@ export function matchAgents(ref: string, agents: AgentCard[], opts: ResolveOptio
105
105
  return pool.filter((a) => idMatchesSegment(a.id, trimmed));
106
106
  }
107
107
 
108
+ /** A channel/bridge agent (Telegram, Slack, …) — the canonical endpoint for its provider name. */
109
+ export function isChannelAgent(agent: AgentCard): boolean {
110
+ return agent.kind === "channel" || (agent.meta?.kind as unknown) === "channel";
111
+ }
112
+
113
+ /**
114
+ * A channel agent is the canonical OWNER of its provider/account name, so a bare ref that
115
+ * names a channel ("telegram") must resolve to that channel even when provider agents in the
116
+ * channel's pool carry the same string as a label/tag. Without this, the bare channel name
117
+ * collides with its own pool and resolves to an arbitrary live pool member — worst case the
118
+ * sender itself — so the bridge never receives the message and it never reaches the chat
119
+ * (#290). Returns the single channel in `candidates`, or null if there isn't exactly one.
120
+ */
121
+ function soleChannel(candidates: AgentCard[]): AgentCard | null {
122
+ const channels = candidates.filter(isChannelAgent);
123
+ return channels.length === 1 ? channels[0]! : null;
124
+ }
125
+
108
126
  /**
109
127
  * Resolve `ref` to a SINGLE agent. Prefers online matches; reports ambiguity rather
110
128
  * than guessing. On not_found, returns any offline matches so callers can say "exists
@@ -116,8 +134,15 @@ export function resolveAgentRef(ref: string, agents: AgentCard[], opts: ResolveO
116
134
  if (matches.length === 0) return { status: "not_found", offlineMatches: [] };
117
135
 
118
136
  const online = matches.filter((a) => isAgentOnline(a, now));
137
+ // Channel precedence: when a bare ref matches a channel, the channel wins over provider
138
+ // agents that merely share its name — both when live and (so the caller reports "bridge
139
+ // offline" rather than misrouting to a pool member) when momentarily down.
140
+ const onlineChannel = soleChannel(online);
141
+ if (onlineChannel) return { status: "resolved", agent: onlineChannel };
119
142
  if (online.length === 1) return { status: "resolved", agent: online[0]! };
120
143
  if (online.length > 1) return { status: "ambiguous", candidates: online };
144
+ const offlineChannel = soleChannel(matches);
145
+ if (offlineChannel) return { status: "not_found", offlineMatches: [offlineChannel] };
121
146
  return { status: "not_found", offlineMatches: matches };
122
147
  }
123
148
 
@@ -193,7 +218,8 @@ function notFoundMessage(ref: string, agents: AgentCard[]): string {
193
218
  * a live recipient exists; fan-out targets report how many online members they reach;
194
219
  * reserved/policy targets pass through unchanged.
195
220
  */
196
- export function planSend(to: string, agents: AgentCard[], now: number = Date.now()): SendPlan {
221
+ export function planSend(to: string, agents: AgentCard[], opts: ResolveOptions = {}): SendPlan {
222
+ const now = opts.now ?? Date.now();
197
223
  const target = to.trim();
198
224
 
199
225
  if (target === "broadcast") {
@@ -209,8 +235,9 @@ export function planSend(to: string, agents: AgentCard[], now: number = Date.now
209
235
  return { kind: "passthrough", to: target, receipt: { delivered: true, expectReply: true, recipients: [target] } };
210
236
  }
211
237
 
212
- // Direct single-agent reference.
213
- const resolved = resolveAgentRef(target, agents, { now });
238
+ // Direct single-agent reference. `excludeId` (the sender) keeps a bare ref from ever
239
+ // resolving back to its own author — a self-loop silently swallows the message (#290).
240
+ const resolved = resolveAgentRef(target, agents, opts);
214
241
  if (resolved.status === "resolved") {
215
242
  return { kind: "direct", to: resolved.agent.id, receipt: { delivered: true, expectReply: true, recipients: [resolved.agent.id] } };
216
243
  }
package/src/cli.ts CHANGED
@@ -2038,7 +2038,7 @@ async function handleIntrospectCommand(args: string[]): Promise<void> {
2038
2038
 
2039
2039
  const observation = await apiRequest("POST", "/api/insights/observations", {
2040
2040
  sessionId: sessionId || process.env.AGENT_RELAY_PROVIDER_SESSION_ID || `manual-${from}`,
2041
- project: project || process.cwd(),
2041
+ project: project || process.env.AGENT_RELAY_PROJECT || process.cwd().split("/").filter(Boolean).at(-1) || process.cwd(),
2042
2042
  agentId: from,
2043
2043
  signal: "introspection",
2044
2044
  source: "agent",
package/src/mcp.ts CHANGED
@@ -564,13 +564,15 @@ function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>):
564
564
  const attachments = optionalAttachments(args.attachments);
565
565
  const payload = payloadWithAttachments(optionalRecord(args.payload, "payload"), attachments);
566
566
  const requestedTo = stringField(args.to, "to", { required: true, max: 200 });
567
+ const sender = resolveSender(auth, args.from);
567
568
  // Resolve the target to a canonical agent id (so poll-time matching works) and refuse
568
569
  // up front when it's unknown or ambiguous — never store a message no one will receive.
569
- const plan = planSend(requestedTo, listAgents());
570
+ // Exclude the sender so a bare ref can't loop back to its own author (#290).
571
+ const plan = planSend(requestedTo, listAgents(), { excludeId: sender });
570
572
  if (plan.kind === "not_found") throw new McpNotFoundError(plan.message);
571
573
  if (plan.kind === "ambiguous") throw new ValidationError(plan.message);
572
574
  const input: SendMessageInput = {
573
- from: resolveSender(auth, args.from),
575
+ from: sender,
574
576
  to: plan.to,
575
577
  body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
576
578
  subject: optionalString(args.subject, "subject", 200),
@@ -617,7 +619,7 @@ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Messag
617
619
  emitMessage(result.message, result.created);
618
620
  // Reply routing is fixed to the parent's sender — never reject, but report whether
619
621
  // that original sender is still reachable so the agent doesn't wait forever.
620
- const plan = planSend(input.to, listAgents());
622
+ const plan = planSend(input.to, listAgents(), { excludeId: input.from });
621
623
  const delivery: DeliveryReceipt = plan.kind === "not_found" || plan.kind === "ambiguous"
622
624
  ? { delivered: false, expectReply: false, recipients: [], reason: "original sender no longer reachable" }
623
625
  : plan.receipt;
package/src/routes.ts CHANGED
@@ -5222,7 +5222,9 @@ const postMessage: Handler = async (req) => {
5222
5222
  // from the provider transcript and stored for the dashboard chat; it must persist
5223
5223
  // regardless of target liveness and never be re-delivered into a session.
5224
5224
  if (!isMechanicalMessageKind(input.kind)) {
5225
- const plan = planSend(input.to, listAgents());
5225
+ // excludeId: a bare ref must never resolve back to its own author — that self-loop
5226
+ // silently swallows the message (the reddit-briefing → telegram bridge break, #290).
5227
+ const plan = planSend(input.to, listAgents(), { excludeId: input.from });
5226
5228
  if (plan.kind === "ambiguous") return error(plan.message, 409);
5227
5229
  if (plan.kind !== "not_found") input.to = plan.to;
5228
5230
  // Long-standing guard: refuse a direct send to a known-offline agent (now also
@@ -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":