@saadnvd1/agent-os 0.3.646 → 0.3.649

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.
@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
12
12
  if (held)
13
13
  return NextResponse.json({ peer: { id: held.id, name: held.name } });
14
14
  try {
15
- const { session, note } = resolveTarget(ref);
15
+ const { session, note } = resolveTarget(ref, from || null);
16
16
  return NextResponse.json({
17
17
  peer: { id: session.id, name: session.name },
18
18
  note,
@@ -15,7 +15,7 @@ import { orchestratorOpenActions } from "@/stores/orchestratorOpen";
15
15
  import { useRowContext } from "./RowContext";
16
16
  import { SessionRowMenu } from "./SessionRowMenu";
17
17
  import { ActiveTick, ROW_MENU_CONTENT } from "./RowShell";
18
- import { RowRename, UnreadDot } from "./RowParts";
18
+ import { keepRenameFocus, RowRename, UnreadDot } from "./RowParts";
19
19
  import { QuickChangeRow } from "./QuickChangeRow";
20
20
  import { NestRail } from "./RowParts";
21
21
 
@@ -159,6 +159,7 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
159
159
  <DM.DropdownMenuContent
160
160
  align="end"
161
161
  className={ROW_MENU_CONTENT}
162
+ onCloseAutoFocus={keepRenameFocus(renaming)}
162
163
  onClick={(e) => e.stopPropagation()}
163
164
  >
164
165
  {menu("dropdown")}
@@ -171,7 +172,10 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
171
172
  <>
172
173
  <CM.ContextMenu>
173
174
  <CM.ContextMenuTrigger asChild>{content}</CM.ContextMenuTrigger>
174
- <CM.ContextMenuContent className={ROW_MENU_CONTENT}>
175
+ <CM.ContextMenuContent
176
+ className={ROW_MENU_CONTENT}
177
+ onCloseAutoFocus={keepRenameFocus(renaming)}
178
+ >
175
179
  {menu("context")}
176
180
  </CM.ContextMenuContent>
177
181
  </CM.ContextMenu>
@@ -61,6 +61,12 @@ export function CardBadge({ count }: { count: number }) {
61
61
  );
62
62
  }
63
63
 
64
+ // For a menu's onCloseAutoFocus: while a rename is open, the input keeps the
65
+ // focus instead of the menu's trigger getting it back.
66
+ export const keepRenameFocus = (renaming: boolean) => (e: Event) => {
67
+ if (renaming) e.preventDefault();
68
+ };
69
+
64
70
  export function RowRename({
65
71
  initial,
66
72
  onDone,
@@ -70,13 +76,26 @@ export function RowRename({
70
76
  }) {
71
77
  const [value, setValue] = useState(initial);
72
78
  const ref = useRef<HTMLInputElement>(null);
73
- // The menu closing hands focus back to its trigger; take it after that.
79
+ const hadFocus = useRef(false);
80
+ // The menu closing hands focus back to its trigger (RowShell stops that
81
+ // while renaming); keep taking it until we hold it, then leave it alone.
74
82
  useEffect(() => {
75
- const t = setTimeout(() => {
76
- ref.current?.focus();
77
- ref.current?.select();
78
- }, 50);
79
- return () => clearTimeout(t);
83
+ const take = () => {
84
+ const el = ref.current;
85
+ if (!el || hadFocus.current) return;
86
+ el.focus();
87
+ if (document.activeElement === el) {
88
+ hadFocus.current = true;
89
+ el.select();
90
+ }
91
+ };
92
+ take();
93
+ const t = setInterval(take, 50);
94
+ const stop = setTimeout(() => clearInterval(t), 1000);
95
+ return () => {
96
+ clearInterval(t);
97
+ clearTimeout(stop);
98
+ };
80
99
  }, []);
81
100
  return (
82
101
  <input
@@ -84,7 +103,13 @@ export function RowRename({
84
103
  value={value}
85
104
  aria-label="Session name"
86
105
  onChange={(e) => setValue(e.target.value)}
87
- onBlur={() => onDone(value.trim() || null)}
106
+ onFocus={() => {
107
+ hadFocus.current = true;
108
+ }}
109
+ onBlur={() => {
110
+ // Only once we've held focus: the menu closing hands it to its trigger.
111
+ if (hadFocus.current) onDone(value.trim() || null);
112
+ }}
88
113
  onKeyDown={(e) => {
89
114
  e.stopPropagation();
90
115
  if (e.key === "Enter") onDone(value.trim() || null);
@@ -0,0 +1,102 @@
1
+ import "@/components/Chat/composer/test-dom";
2
+ import { act, createElement, useState } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+ import { keepRenameFocus, RowRename } from "./RowParts";
6
+
7
+ (
8
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
9
+ ).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ let root: Root;
12
+ let host: HTMLElement;
13
+ const input = () => host.querySelector("input")!;
14
+
15
+ beforeEach(() => {
16
+ vi.useFakeTimers();
17
+ host = document.createElement("div");
18
+ document.body.appendChild(host);
19
+ root = createRoot(host);
20
+ });
21
+ afterEach(() => {
22
+ act(() => root.unmount());
23
+ host.remove();
24
+ vi.useRealTimers();
25
+ });
26
+
27
+ function mount(onDone: (n: string | null) => void, initial = "old") {
28
+ act(() => root.render(createElement(RowRename, { initial, onDone })));
29
+ }
30
+
31
+ describe("RowRename", () => {
32
+ it("retries focus the menu stole, and ignores blur until it has it", () => {
33
+ const real = window.HTMLElement.prototype.focus;
34
+ let calls = 0;
35
+ // The first focus attempts land while the menu still traps focus.
36
+ vi.spyOn(window.HTMLElement.prototype, "focus").mockImplementation(
37
+ function (this: HTMLElement) {
38
+ if (++calls > 2) real.call(this);
39
+ }
40
+ );
41
+ const done = vi.fn();
42
+ mount(done);
43
+ act(() => input().dispatchEvent(new window.FocusEvent("blur")));
44
+ expect(done).not.toHaveBeenCalled();
45
+ act(() => vi.advanceTimersByTime(150));
46
+ expect(document.activeElement).toBe(input());
47
+ vi.restoreAllMocks();
48
+ });
49
+
50
+ it("keeps typed text across a parent re-render (list refresh)", () => {
51
+ function Parent() {
52
+ const [n, setN] = useState(0);
53
+ return createElement(
54
+ "div",
55
+ null,
56
+ createElement("button", { onClick: () => setN(n + 1) }),
57
+ createElement(RowRename, { initial: "old", onDone: () => {} })
58
+ );
59
+ }
60
+ act(() => root.render(createElement(Parent)));
61
+ const el = input();
62
+ act(() => {
63
+ const set = Object.getOwnPropertyDescriptor(
64
+ window.HTMLInputElement.prototype,
65
+ "value"
66
+ )!.set!;
67
+ set.call(el, "typed");
68
+ el.dispatchEvent(new window.Event("input", { bubbles: true }));
69
+ });
70
+ act(() => host.querySelector("button")!.click());
71
+ expect(input()).toBe(el);
72
+ expect(input().value).toBe("typed");
73
+ });
74
+
75
+ it("saves on Enter, cancels on Escape, saves on blur once focused", () => {
76
+ const done = vi.fn();
77
+ mount(done);
78
+ act(() => vi.advanceTimersByTime(60));
79
+ const key = (k: string) =>
80
+ act(() => {
81
+ input().dispatchEvent(
82
+ new window.KeyboardEvent("keydown", { key: k, bubbles: true })
83
+ );
84
+ });
85
+ key("Enter");
86
+ expect(done).toHaveBeenLastCalledWith("old");
87
+ key("Escape");
88
+ expect(done).toHaveBeenLastCalledWith(null);
89
+ act(() => input().blur());
90
+ expect(done).toHaveBeenCalledTimes(3);
91
+ });
92
+ });
93
+
94
+ describe("keepRenameFocus", () => {
95
+ it("stops the menu returning focus only while renaming", () => {
96
+ const e = { preventDefault: vi.fn() } as unknown as Event;
97
+ keepRenameFocus(false)(e);
98
+ expect(e.preventDefault).not.toHaveBeenCalled();
99
+ keepRenameFocus(true)(e);
100
+ expect(e.preventDefault).toHaveBeenCalled();
101
+ });
102
+ });
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
6
6
  import * as DM from "@/components/ui/dropdown-menu";
7
7
  import * as CM from "@/components/ui/context-menu";
8
8
  import { cn } from "@/lib/utils";
9
+ import { keepRenameFocus } from "./RowParts";
9
10
 
10
11
  // The open row's accent: a thin rounded tick at the left edge, inside the
11
12
  // row so the list's overflow can't clip it.
@@ -41,6 +42,7 @@ export function RowShell({
41
42
  menu,
42
43
  menuLabel,
43
44
  menuButton = true,
45
+ renaming = false,
44
46
  }: {
45
47
  title: string;
46
48
  tipKey?: string;
@@ -60,6 +62,8 @@ export function RowShell({
60
62
  menuLabel: string;
61
63
  // False keeps the right-click menu but hides the ⋯ (while selecting).
62
64
  menuButton?: boolean;
65
+ // A rename input is open: closing the menu mustn't move focus off it.
66
+ renaming?: boolean;
63
67
  }) {
64
68
  const content = (
65
69
  <div
@@ -114,6 +118,7 @@ export function RowShell({
114
118
  <DM.DropdownMenuContent
115
119
  align="end"
116
120
  className={ROW_MENU_CONTENT}
121
+ onCloseAutoFocus={keepRenameFocus(renaming)}
117
122
  onClick={(e) => e.stopPropagation()}
118
123
  >
119
124
  {menu("dropdown")}
@@ -126,7 +131,10 @@ export function RowShell({
126
131
  return (
127
132
  <CM.ContextMenu>
128
133
  <CM.ContextMenuTrigger asChild>{content}</CM.ContextMenuTrigger>
129
- <CM.ContextMenuContent className={ROW_MENU_CONTENT}>
134
+ <CM.ContextMenuContent
135
+ className={ROW_MENU_CONTENT}
136
+ onCloseAutoFocus={keepRenameFocus(renaming)}
137
+ >
130
138
  {menu("context")}
131
139
  </CM.ContextMenuContent>
132
140
  </CM.ContextMenu>
@@ -115,6 +115,7 @@ export const SessionRow = memo(function SessionRow({
115
115
  menu={menu}
116
116
  menuLabel="Session actions"
117
117
  menuButton={!selecting}
118
+ renaming={renaming}
118
119
  >
119
120
  {renaming ? (
120
121
  <RowRename
@@ -2,7 +2,7 @@
2
2
  export const BUS_BRIEF = `You are one of several coding agents running in AgentOS. Other sessions may be working on related projects at the same time, and you can talk to them with the \`aos\` command:
3
3
 
4
4
  - \`aos peers\`: list sessions, their project and what each is doing
5
- - \`aos send <session> "<message>"\`: message a session by name (or project/name, an old name, or its id); it prints delivered, queued or FAILED
5
+ - \`aos send <session> "<message>"\`: message a session by name (or project/name, an old name, or its id; \`orchestrator\` is your workspace's orchestrator); it prints delivered, queued or FAILED
6
6
  - \`aos inbox\`: read messages sent to you
7
7
  - \`aos history <session>\`: your conversation with a session
8
8
  - \`aos spawn <project> "<prompt>"\`: start a new agent session in a project
package/lib/bus/index.ts CHANGED
@@ -7,7 +7,9 @@
7
7
  import { db, type Session } from "../db";
8
8
  import { getProject } from "../projects";
9
9
  import { statusDetector } from "../status-detector";
10
- import { sendChatConfirmed } from "../chat/runner";
10
+ import { chatState, sendChatConfirmed } from "../chat/runner";
11
+ import { chatActivityLine } from "../chat/activity";
12
+ import { getWorkspace } from "../workspaces";
11
13
  import type { ChatOrigin, PeerMessage } from "../chat/events";
12
14
  import { sessionRowInfo } from "../session-meta";
13
15
  import { previousNames } from "../session-names";
@@ -38,6 +40,10 @@ export interface Peer {
38
40
  hostId: string;
39
41
  running: boolean;
40
42
  doing: string | null;
43
+ // The workspace it is the orchestrator of, else null.
44
+ orchestratorOf: string | null;
45
+ // The asking session's own workspace orchestrator.
46
+ yours?: boolean;
41
47
  }
42
48
 
43
49
  function liveSessions(): Session[] {
@@ -68,19 +74,41 @@ const sessionById = (id: string | null | undefined) =>
68
74
  | undefined)
69
75
  : undefined;
70
76
 
77
+ // A chat has no pane: its worker is the state, and it can always take a
78
+ // message (queued as its next prompt), so it is never "not running".
79
+ async function peerState(
80
+ s: Session
81
+ ): Promise<{ running: boolean; subtitle: string | null }> {
82
+ if (s.view === "chat") {
83
+ const busy = chatState(s.id) === "running";
84
+ return {
85
+ running: true,
86
+ subtitle: chatActivityLine(s.id) ?? (busy ? "working" : "idle"),
87
+ };
88
+ }
89
+ const status = statusDetector.sessionExists(s.tmux_name)
90
+ ? await statusDetector.getStatus(s.tmux_name)
91
+ : "dead";
92
+ const info = sessionRowInfo(status, statusDetector.titleFor(s.tmux_name));
93
+ return { running: info.running, subtitle: info.subtitle };
94
+ }
95
+
96
+ const orchestratorOf = (s: Session) =>
97
+ s.role === "orchestrator" && s.workspace_id
98
+ ? (getWorkspace(s.workspace_id)?.name ?? "a workspace")
99
+ : null;
100
+
71
101
  // `callerId`: the asking session. A task its orchestrator moved here also
72
102
  // sees that orchestrator, which it can message though it runs elsewhere.
73
103
  export async function listPeers(callerId?: string): Promise<Peer[]> {
74
104
  await statusDetector.refreshCache();
75
105
  const sessions = liveSessions();
76
106
  const named = candidates(sessions);
77
- const orchestrator = heldOrchestrator(sessionById(callerId));
107
+ const caller = sessionById(callerId);
108
+ const orchestrator = heldOrchestrator(caller);
78
109
  const listed = await Promise.all(
79
110
  sessions.map(async (s, i) => {
80
- const status = statusDetector.sessionExists(s.tmux_name)
81
- ? await statusDetector.getStatus(s.tmux_name)
82
- : "dead";
83
- const info = sessionRowInfo(status, statusDetector.titleFor(s.tmux_name));
111
+ const info = await peerState(s);
84
112
  return {
85
113
  id: s.id,
86
114
  name: s.name,
@@ -90,6 +118,10 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
90
118
  hostId: s.host_id,
91
119
  running: info.running,
92
120
  doing: info.subtitle,
121
+ orchestratorOf: orchestratorOf(s),
122
+ ...(caller?.workspace_id &&
123
+ s.role === "orchestrator" &&
124
+ s.workspace_id === caller.workspace_id && { yours: true }),
93
125
  };
94
126
  })
95
127
  );
@@ -105,6 +137,8 @@ export async function listPeers(callerId?: string): Promise<Peer[]> {
105
137
  hostId: "remote",
106
138
  running: true,
107
139
  doing: `your orchestrator, on ${orchestrator.heldFrom}`,
140
+ orchestratorOf: null,
141
+ yours: true,
108
142
  },
109
143
  ];
110
144
  }
@@ -116,28 +150,60 @@ export function resolveHeldOrchestrator(callerId: string, ref: string) {
116
150
 
117
151
  // A live session by name, old name, project/name, id or id prefix. The note
118
152
  // says when the name meant something other than its current owner.
119
- export function resolveTarget(ref: string): {
153
+ export function resolveTarget(
154
+ ref: string,
155
+ callerId?: string | null
156
+ ): {
120
157
  session: Session;
121
158
  note?: string;
122
159
  } {
123
- const found = tryResolveTarget(ref);
160
+ const found = tryResolveTarget(ref, callerId);
124
161
  if (!found.ok) throw new Error(found.error);
125
162
  return found;
126
163
  }
127
164
 
165
+ // "orchestrator" / "my orchestrator": the caller's own workspace orchestrator.
166
+ function ownOrchestrator(
167
+ ref: string,
168
+ sessions: Session[],
169
+ callerId?: string | null
170
+ ): Session | undefined {
171
+ if (!/^(my )?orchestrator$/i.test(ref.trim())) return undefined;
172
+ const workspace = sessionById(callerId)?.workspace_id;
173
+ if (!workspace) return undefined;
174
+ return sessions.find(
175
+ (s) => s.role === "orchestrator" && s.workspace_id === workspace
176
+ );
177
+ }
178
+
128
179
  function tryResolveTarget(
129
- ref: string
180
+ ref: string,
181
+ callerId?: string | null
130
182
  ):
131
183
  | { ok: true; session: Session; note?: string }
132
184
  | { ok: false; none: boolean; error: string } {
133
185
  const sessions = liveSessions();
186
+ const own = ownOrchestrator(ref, sessions, callerId);
187
+ if (own) return { ok: true, session: own };
134
188
  const r = resolveRef(ref, candidates(sessions));
135
- if (!r.ok)
189
+ if (!r.ok) {
190
+ const orchestrators = /^(my )?orchestrator$/i.test(ref.trim())
191
+ ? sessions.filter((s) => s.role === "orchestrator")
192
+ : [];
193
+ if (orchestrators.length)
194
+ return {
195
+ ok: false,
196
+ none: true,
197
+ error: `You are in no workspace, so "${ref}" is ambiguous. Orchestrators: ${orchestrators
198
+ .map((s) => `${s.name} (id ${s.id.slice(0, 8)})`)
199
+ .join(", ")}`,
200
+ };
136
201
  return {
137
202
  ok: false,
138
203
  none: r.reason === "none",
139
204
  error: r.reason === "none" ? `${r.error}. Run: aos peers` : r.error,
140
205
  };
206
+ }
141
207
  return {
142
208
  ok: true,
143
209
  session: sessions.find((s) => s.id === r.id)!,
@@ -287,7 +353,7 @@ export async function sendMessage(opts: {
287
353
  // A held task's message to its orchestrator is kept for the holder to pull.
288
354
  const held = heldTarget(from, opts.to);
289
355
  if (from && held) return storeForHolder(from, held, body);
290
- const target = tryResolveTarget(opts.to);
356
+ const target = tryResolveTarget(opts.to, opts.fromId);
291
357
  if (!target.ok) {
292
358
  // A task still in the start queue has no session yet: hold it.
293
359
  const queued = target.none ? findQueuedTarget(opts.to) : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.646",
3
+ "version": "0.3.649",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -48,7 +48,7 @@
48
48
  "lint": "eslint .",
49
49
  "format": "prettier --write .",
50
50
  "format:check": "prettier --check .",
51
- "test": "vitest run",
51
+ "test": "scripts/test",
52
52
  "test:watch": "vitest",
53
53
  "check": "scripts/check",
54
54
  "screenshots": "tsx scripts/screenshots/run.ts",
package/scripts/check CHANGED
@@ -56,7 +56,7 @@ if has static; then
56
56
  fi
57
57
  fi
58
58
  if has test; then
59
- step "test"; npx vitest run ${vitest_args[@]+"${vitest_args[@]}"}
59
+ step "test"; scripts/test ${vitest_args[@]+"${vitest_args[@]}"}
60
60
  fi
61
61
  if has build; then
62
62
  # The build imports the generated migrations index, so it needs it even
package/scripts/test ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bash
2
+ # The full suite, kindly: through `aos heavy` (so other sessions are told) and
3
+ # at low priority on a Mac. CI and machines without aos run plain vitest.
4
+ set -euo pipefail
5
+ cd "$(dirname "$0")/.."
6
+ cmd=(npx vitest run "$@")
7
+ [ "$(uname)" = Darwin ] && [ -z "${CI:-}" ] && cmd=(nice -n 10 "${cmd[@]}")
8
+ if [ -z "${CI:-}" ] && command -v aos >/dev/null 2>&1; then
9
+ exec aos heavy -- "${cmd[@]}"
10
+ fi
11
+ exec "${cmd[@]}"