@saadnvd1/agent-os 0.3.728 → 0.3.731

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/app/api/bus/ports/route.test.ts +43 -0
  2. package/app/api/bus/ports/route.ts +35 -0
  3. package/app/api/sessions/[id]/export/turn/route.test.ts +65 -0
  4. package/app/api/sessions/[id]/export/turn/route.ts +33 -0
  5. package/components/Chat/Messages.tsx +2 -0
  6. package/components/Chat/TurnCopy.tsx +84 -0
  7. package/components/SessionList/GroupShelves.tsx +57 -22
  8. package/components/SessionList/MoveToGroupMenu.test.ts +77 -0
  9. package/components/SessionList/SessionRow.tsx +12 -1
  10. package/components/SessionList/SidebarContextMenu.test.ts +21 -1
  11. package/components/SessionList/SidebarContextMenu.tsx +33 -9
  12. package/components/SessionList/index.tsx +11 -2
  13. package/components/SessionList/useGroupDrag.ts +83 -0
  14. package/data/exports/queries.ts +11 -0
  15. package/lib/agents/brief.ts +1 -1
  16. package/lib/export/markdown.ts +7 -1
  17. package/lib/export/turn.ts +48 -0
  18. package/lib/health/config.ts +2 -0
  19. package/lib/health/env/classes.ts +14 -1
  20. package/lib/health/port-wait-tick.ts +222 -0
  21. package/lib/health/port-wait.ts +160 -0
  22. package/lib/health/tick.ts +9 -2
  23. package/lib/health/types.ts +2 -0
  24. package/lib/{ports.ts → ports/allocatePorts.ts} +30 -7
  25. package/lib/ports/ensurePorts.ts +138 -0
  26. package/lib/ports/findHardcodedPorts.ts +116 -0
  27. package/lib/ports/index.ts +15 -0
  28. package/lib/ports/listListeners.ts +134 -0
  29. package/lib/ports/reassignPort.ts +45 -0
  30. package/lib/ports/types.ts +62 -0
  31. package/lib/ports/whoHolds.ts +155 -0
  32. package/lib/project-config/brief.ts +4 -2
  33. package/lib/project-config/session.ts +19 -3
  34. package/lib/sidebar/groupDrop.ts +36 -0
  35. package/lib/windows-ports.ts +36 -18
  36. package/package.json +1 -1
@@ -0,0 +1,155 @@
1
+ import fs from "fs";
2
+ import { db } from "@/lib/db";
3
+ import { parsePorts } from "./allocatePorts";
4
+ import type {
5
+ DevServerPortRow,
6
+ Holder,
7
+ Listener,
8
+ PortCheck,
9
+ SessionPortRow,
10
+ } from "./types";
11
+
12
+ const inside = (cwd: string, dir: string) =>
13
+ cwd === dir || cwd.startsWith(dir.endsWith("/") ? dir : `${dir}/`);
14
+
15
+ const holdsPort = (s: SessionPortRow, port: number) =>
16
+ s.devServerPort === port || Object.values(s.ports ?? {}).includes(port);
17
+
18
+ const asSession = (
19
+ s: SessionPortRow,
20
+ pid: number | null,
21
+ listening: boolean
22
+ ): Holder => ({
23
+ kind: "session",
24
+ sessionId: s.id,
25
+ name: s.name,
26
+ project: s.project,
27
+ pid,
28
+ listening,
29
+ });
30
+
31
+ /**
32
+ * Who holds a port: the session whose worktree the listener runs in, the
33
+ * Dev Servers panel's server, a Windows process, or a process outside
34
+ * AgentOS. A listener whose cwd can't be read falls to the session that
35
+ * holds the port, if any. With nothing listening, a session or panel server
36
+ * that holds the port still has it. Null when it's free.
37
+ */
38
+ export function whoHolds(
39
+ port: number,
40
+ listeners: Listener[],
41
+ sessions: SessionPortRow[],
42
+ devServers: DevServerPortRow[]
43
+ ): Holder | null {
44
+ const l = listeners.find((x) => x.port === port);
45
+ const reserving = sessions.find((s) => holdsPort(s, port));
46
+ const panel = devServers.find((d) => d.ports.includes(port));
47
+ if (l) {
48
+ if (l.cwd) {
49
+ const s = sessions.find((x) => x.worktree && inside(l.cwd!, x.worktree));
50
+ if (s) return asSession(s, l.pid, true);
51
+ }
52
+ const byPid = devServers.find((d) => d.pid !== null && d.pid === l.pid);
53
+ if (byPid)
54
+ return { kind: "dev-server", id: byPid.id, name: byPid.name, pid: l.pid };
55
+ if (l.windows) return { kind: "windows", pid: l.pid };
56
+ if (!l.cwd && reserving) return asSession(reserving, l.pid, true);
57
+ return { kind: "outside", pid: l.pid, command: l.command, cwd: l.cwd };
58
+ }
59
+ if (reserving) return asSession(reserving, null, false);
60
+ if (panel)
61
+ return {
62
+ kind: "dev-server",
63
+ id: panel.id,
64
+ name: panel.name,
65
+ pid: panel.pid,
66
+ };
67
+ return null;
68
+ }
69
+
70
+ export function judgePort(
71
+ askerId: string,
72
+ name: string | null,
73
+ port: number,
74
+ listeners: Listener[] | null,
75
+ sessions: SessionPortRow[],
76
+ devServers: DevServerPortRow[]
77
+ ): PortCheck {
78
+ const holder = whoHolds(port, listeners ?? [], sessions, devServers);
79
+ const mine = holder?.kind === "session" && holder.sessionId === askerId;
80
+ const free =
81
+ holder === null
82
+ ? listeners === null
83
+ ? null
84
+ : true
85
+ : mine && !holder.listening;
86
+ return { name, port, free, holder, mine };
87
+ }
88
+
89
+ const real = (p: string) => {
90
+ try {
91
+ return fs.realpathSync(p);
92
+ } catch {
93
+ return p;
94
+ }
95
+ };
96
+
97
+ // This machine's sessions that run in a worktree or hold ports.
98
+ export function localPortRows(): {
99
+ sessions: SessionPortRow[];
100
+ devServers: DevServerPortRow[];
101
+ } {
102
+ const rows = db
103
+ .prepare(
104
+ `SELECT s.id, s.name, p.name AS project, s.worktree_path, s.ports, s.dev_server_port
105
+ FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
106
+ WHERE (s.host_id IS NULL OR s.host_id = 'local')
107
+ AND ((s.worktree_path IS NOT NULL AND s.archived_at IS NULL)
108
+ OR s.ports IS NOT NULL OR s.dev_server_port IS NOT NULL)`
109
+ )
110
+ .all() as {
111
+ id: string;
112
+ name: string;
113
+ project: string | null;
114
+ worktree_path: string | null;
115
+ ports: string | null;
116
+ dev_server_port: number | null;
117
+ }[];
118
+ const servers = db
119
+ .prepare(
120
+ `SELECT id, name, pid, ports FROM dev_servers WHERE status IN ('running', 'starting')`
121
+ )
122
+ .all() as { id: string; name: string; pid: number | null; ports: string }[];
123
+ return {
124
+ sessions: rows.map((r) => ({
125
+ id: r.id,
126
+ name: r.name,
127
+ project: r.project,
128
+ worktree: r.worktree_path ? real(r.worktree_path) : null,
129
+ ports: parsePorts(r.ports),
130
+ devServerPort: r.dev_server_port,
131
+ })),
132
+ devServers: servers.map((d) => {
133
+ let ports: number[] = [];
134
+ try {
135
+ const parsed = JSON.parse(d.ports) as unknown;
136
+ if (Array.isArray(parsed))
137
+ ports = parsed.filter((p): p is number => Number.isInteger(p));
138
+ } catch {}
139
+ return { id: d.id, name: d.name, pid: d.pid, ports };
140
+ }),
141
+ };
142
+ }
143
+
144
+ export function describeHolder(h: Holder): string {
145
+ switch (h.kind) {
146
+ case "session":
147
+ return `session "${h.name}"${h.project ? ` (project ${h.project})` : ""}${h.pid ? `, pid ${h.pid}` : ""}${h.listening ? "" : ", which holds it but isn't listening yet"}`;
148
+ case "dev-server":
149
+ return `the Dev Servers panel's "${h.name}"${h.pid ? `, pid ${h.pid}` : ""}`;
150
+ case "windows":
151
+ return `a Windows process${h.pid ? ` (pid ${h.pid})` : ""}`;
152
+ case "outside":
153
+ return `${h.pid ? `pid ${h.pid}` : "a process"}${h.command ? ` (${h.command})` : ""}${h.cwd ? ` in ${h.cwd}` : ""}, outside AgentOS`;
154
+ }
155
+ }
@@ -19,15 +19,17 @@ export function runningBrief(
19
19
  config: ProjectConfig,
20
20
  ports: Record<string, number>,
21
21
  database: SessionDatabase | null = null,
22
- warning: string | null = null
22
+ warning: string | null = null,
23
+ hardcoded: string[] = []
23
24
  ): string {
24
25
  const lines: string[] = [];
25
26
  if (warning) lines.push(`- ${warning}`);
26
27
  const names = Object.entries(ports);
27
28
  if (names.length)
28
29
  lines.push(
29
- `- Your ports, exported in your shell and no other session's: ${names.map(([n, p]) => `${n}=${p}`).join(", ")}. Start servers on these; never stop a process you didn't start (kill by your port: \`lsof -ti tcp:<port> -sTCP:LISTEN | xargs kill\`).`
30
+ `- Your ports, exported in your shell and no other session's: ${names.map(([n, p]) => `${n}=${p}`).join(", ")}. Start servers on these; never stop a process you didn't start (kill by your port: \`lsof -ti tcp:<port> -sTCP:LISTEN | xargs kill\`). If one is taken, never wait for it: \`aos port\` names the holder and gives you a free one.`
30
31
  );
32
+ for (const note of hardcoded) lines.push(`- ${note}`);
31
33
  if (config.dev)
32
34
  lines.push(`- Dev server: \`${withPorts(config.dev, ports)}\``);
33
35
  const ready = readyCommand(config, ports);
@@ -9,7 +9,8 @@ import os from "os";
9
9
  import { sessionTmpDir } from "../sessions/leftovers";
10
10
  import { db } from "../db";
11
11
  import { getProject } from "../projects";
12
- import { sessionPorts } from "../ports";
12
+ import { sessionPorts } from "../ports/allocatePorts";
13
+ import { findHardcodedPorts, hardcodedNote } from "../ports/findHardcodedPorts";
13
14
  import {
14
15
  loadProjectConfig,
15
16
  NONE,
@@ -71,12 +72,27 @@ export function sessionProjectEnv(sessionId: string): Record<string, string> {
71
72
  }
72
73
  }
73
74
 
75
+ // With no config file the section is only the session's ports, so an agent
76
+ // in a project without agentos.json still knows it has them.
74
77
  export function sessionRunningBrief(sessionId: string): string {
75
78
  try {
76
79
  const { config, source, warning, ports } = sessionConfig(sessionId);
80
+ if (!source && !ports) return "";
81
+ const dir = projectPathOf(sessionId);
82
+ const primary = ports ? Object.values(ports)[0] : undefined;
83
+ const hardcoded =
84
+ dir && primary !== undefined
85
+ ? findHardcodedPorts(dir, config.dev, primary).map(hardcodedNote)
86
+ : [];
77
87
  return source
78
- ? runningBrief(config, ports ?? {}, sessionDatabase(sessionId), warning)
79
- : "";
88
+ ? runningBrief(
89
+ config,
90
+ ports ?? {},
91
+ sessionDatabase(sessionId),
92
+ warning,
93
+ hardcoded
94
+ )
95
+ : runningBrief({}, ports ?? {}, null, null, hardcoded);
80
96
  } catch (error) {
81
97
  console.error(`[project-config] brief for ${sessionId}:`, error);
82
98
  return "";
@@ -0,0 +1,36 @@
1
+ // Drag a session onto a group to file it there, or onto the list outside any
2
+ // group to take it out. Pure, so the rules are testable without a DOM.
3
+
4
+ export type DraggedSession = {
5
+ id: string;
6
+ groupId: string | null;
7
+ // The session's workspace, by the same rule the server files by.
8
+ workspaceId: string | null;
9
+ };
10
+
11
+ export type DropTarget = { groupId: string; workspaceId: string | null };
12
+
13
+ // What a drop should do: the group to file into (null = out of any group), or
14
+ // null when nothing should happen. A group of another workspace never takes
15
+ // the session (the server refuses it too); dropping on its own group, or an
16
+ // ungrouped session outside any group, changes nothing.
17
+ export function dropOutcome(
18
+ dragged: DraggedSession,
19
+ target: DropTarget | null
20
+ ): { groupId: string | null } | null {
21
+ if (!target) return dragged.groupId ? { groupId: null } : null;
22
+ if ((target.workspaceId ?? null) !== (dragged.workspaceId ?? null))
23
+ return null;
24
+ return target.groupId === dragged.groupId
25
+ ? null
26
+ : { groupId: target.groupId };
27
+ }
28
+
29
+ export const DRAG_TYPE = "application/x-agentos-session";
30
+
31
+ // dragover can't read the payload, so the one drag in flight is kept here.
32
+ let current: DraggedSession | null = null;
33
+ export const dragging = () => current;
34
+ export const setDragging = (d: DraggedSession | null) => {
35
+ current = d;
36
+ };
@@ -16,45 +16,55 @@ const execFileAsync = promisify(execFile);
16
16
  const TTL_MS = 3000;
17
17
 
18
18
  /**
19
- * Listening ports in `netstat.exe -ano -p TCP` output. A listener has a
20
- * wildcard peer (0.0.0.0:0 or [::]:0), which holds in every Windows locale;
21
- * the state column ("LISTENING") is translated.
19
+ * Listening ports in `netstat.exe -ano -p TCP` output, each with the pid
20
+ * that holds it. A listener has a wildcard peer (0.0.0.0:0 or [::]:0), which
21
+ * holds in every Windows locale; the state column ("LISTENING") is
22
+ * translated.
22
23
  */
23
- export function parseNetstatListeners(out: string): Set<number> {
24
- const ports = new Set<number>();
24
+ export function parseNetstatOwners(out: string): Map<number, number | null> {
25
+ const owners = new Map<number, number | null>();
25
26
  for (const line of out.split(/\r?\n/)) {
26
27
  const cols = line.trim().split(/\s+/);
27
28
  if (cols.length < 4 || cols[0].toUpperCase() !== "TCP") continue;
28
29
  const [, local, peer] = cols;
29
30
  if (!/^(0\.0\.0\.0|\[::\]):0$/.test(peer)) continue;
30
31
  const port = Number(local.slice(local.lastIndexOf(":") + 1));
31
- if (Number.isInteger(port) && port > 0) ports.add(port);
32
+ const pid = Number(cols[cols.length - 1]);
33
+ if (Number.isInteger(port) && port > 0 && !owners.has(port))
34
+ owners.set(port, Number.isInteger(pid) && pid > 0 ? pid : null);
32
35
  }
33
- return ports;
36
+ return owners;
34
37
  }
35
38
 
39
+ export const parseNetstatListeners = (out: string): Set<number> =>
40
+ new Set(parseNetstatOwners(out).keys());
41
+
36
42
  type Run = (file: string, args: string[]) => Promise<string>;
37
43
 
38
44
  const defaultRun: Run = async (file, args) =>
39
45
  (await execFileAsync(file, args, { timeout: 4000, maxBuffer: 8 << 20 }))
40
46
  .stdout;
41
47
 
42
- let cache: { at: number; ports: Set<number> | null } | undefined;
43
- let inflight: Promise<Set<number> | null> | undefined;
48
+ type Owners = Map<number, number | null>;
44
49
 
45
- /** The ports Windows listens on, or null when unknown (not WSL 2, or it failed). */
46
- export async function windowsListeningPorts(
47
- opts: { wsl?: number; run?: Run; now?: number } = {}
48
- ): Promise<Set<number> | null> {
50
+ let cache: { at: number; owners: Owners | null } | undefined;
51
+ let inflight: Promise<Owners | null> | undefined;
52
+
53
+ type Opts = { wsl?: number; run?: Run; now?: number };
54
+
55
+ /** Windows' listening ports and their pids, or null when unknown (not WSL 2, or it failed). */
56
+ export async function windowsListeners(
57
+ opts: Opts = {}
58
+ ): Promise<Owners | null> {
49
59
  if ((opts.wsl ?? wslVersion()) !== 2) return null;
50
60
  const now = opts.now ?? Date.now();
51
- if (cache && now - cache.at < TTL_MS) return cache.ports;
61
+ if (cache && now - cache.at < TTL_MS) return cache.owners;
52
62
  inflight ??= (opts.run ?? defaultRun)("netstat.exe", ["-ano", "-p", "TCP"])
53
- .then((out) => parseNetstatListeners(out))
63
+ .then((out) => parseNetstatOwners(out))
54
64
  .catch(() => null)
55
- .then((ports) => {
56
- cache = { at: now, ports };
57
- return ports;
65
+ .then((owners) => {
66
+ cache = { at: now, owners };
67
+ return owners;
58
68
  })
59
69
  .finally(() => {
60
70
  inflight = undefined;
@@ -62,6 +72,14 @@ export async function windowsListeningPorts(
62
72
  return inflight;
63
73
  }
64
74
 
75
+ /** The ports Windows listens on, or null when unknown (not WSL 2, or it failed). */
76
+ export async function windowsListeningPorts(
77
+ opts: Opts = {}
78
+ ): Promise<Set<number> | null> {
79
+ const owners = await windowsListeners(opts);
80
+ return owners ? new Set(owners.keys()) : null;
81
+ }
82
+
65
83
  export function resetWindowsPortsCache() {
66
84
  cache = undefined;
67
85
  inflight = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.728",
3
+ "version": "0.3.731",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"