@saadnvd1/agent-os 0.3.768 → 0.3.770

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.
@@ -0,0 +1,81 @@
1
+ import { randomUUID } from "crypto";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { NextRequest } from "next/server";
4
+ import { getDb } from "@/lib/db";
5
+ import { GET } from "./route";
6
+
7
+ const get = (query: string) =>
8
+ GET(new NextRequest(`http://localhost/api/sessions/resolve?${query}`));
9
+
10
+ function insert(
11
+ id: string,
12
+ name: string,
13
+ over: { archived?: boolean; created?: string } = {}
14
+ ) {
15
+ getDb()
16
+ .prepare(
17
+ `INSERT INTO sessions (id, name, tmux_name, created_at, archived_at)
18
+ VALUES (?, ?, ?, ?, ?)`
19
+ )
20
+ .run(
21
+ id,
22
+ name,
23
+ `claude-${id}`,
24
+ over.created ?? "2026-10-01 10:00:00",
25
+ over.archived ? "2026-10-02 10:00:00" : null
26
+ );
27
+ }
28
+
29
+ // Prefixes no random uuid in the shared test database starts with.
30
+ const P = "fffe0001";
31
+ const Q = "fffe0002";
32
+
33
+ beforeEach(() => {
34
+ getDb().prepare(`DELETE FROM sessions WHERE id LIKE 'fffe000%'`).run();
35
+ });
36
+
37
+ describe("GET /api/sessions/resolve", () => {
38
+ it("resolves a short id, archived sessions included", async () => {
39
+ const id = `${P}-1111-4111-8111-111111111111`;
40
+ insert(id, "Web perf audit", { archived: true });
41
+ const res = await get(`ref=${P}&slug=whatever`);
42
+ expect(res.status).toBe(200);
43
+ expect(await res.json()).toEqual({
44
+ id,
45
+ name: "Web perf audit",
46
+ archived: true,
47
+ });
48
+ });
49
+
50
+ it("resolves a whole id", async () => {
51
+ const id = randomUUID();
52
+ insert(id, "Plain");
53
+ const res = await get(`ref=${id}`);
54
+ expect((await res.json()).id).toBe(id);
55
+ getDb().prepare(`DELETE FROM sessions WHERE id = ?`).run(id);
56
+ });
57
+
58
+ it("breaks a prefix clash by slug, then by newest", async () => {
59
+ const older = `${Q}-1111-4111-8111-111111111111`;
60
+ const newer = `${Q}-2222-4222-8222-222222222222`;
61
+ insert(older, "Fix login", { created: "2026-10-01 10:00:00" });
62
+ insert(newer, "Ship docs", { created: "2026-10-03 10:00:00" });
63
+ expect((await (await get(`ref=${Q}&slug=fix-login`)).json()).id).toBe(
64
+ older
65
+ );
66
+ expect((await (await get(`ref=${Q}&slug=ship-docs`)).json()).id).toBe(
67
+ newer
68
+ );
69
+ expect((await (await get(`ref=${Q}&slug=gone-name`)).json()).id).toBe(
70
+ newer
71
+ );
72
+ expect((await (await get(`ref=${Q}`)).json()).id).toBe(newer);
73
+ });
74
+
75
+ it("is 404 for an unknown id and 400 for a malformed or short ref", async () => {
76
+ expect((await get(`ref=fffe0009`)).status).toBe(404);
77
+ for (const bad of ["", "ref=", "ref=fffe", "ref=fffe00", "ref=<script>"]) {
78
+ expect((await get(bad)).status).toBe(400);
79
+ }
80
+ });
81
+ });
@@ -0,0 +1,14 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { refFromParams, resolveSessionRef } from "@/lib/sessions/resolve";
3
+
4
+ // GET /api/sessions/resolve?ref=<id8|id>[&slug=] - the session a link names,
5
+ // archived ones included: {id, name, archived}.
6
+ export async function GET(request: NextRequest) {
7
+ const params = request.nextUrl.searchParams;
8
+ const ref = refFromParams(params.get("ref"), params.get("slug"));
9
+ if (!ref) return NextResponse.json({ error: "bad ref" }, { status: 400 });
10
+ const session = resolveSessionRef(ref);
11
+ if (!session)
12
+ return NextResponse.json({ error: "not found" }, { status: 404 });
13
+ return NextResponse.json(session);
14
+ }
@@ -66,7 +66,7 @@ import { useGoToItem } from "./useGoToItem";
66
66
  import { NeedsYouStrip } from "@/components/Orchestrator/NeedsYouStrip";
67
67
  import { useResolveQuestion } from "@/data/orchestrators";
68
68
  import { needsYouFocus, needsYouFocusActions } from "@/stores/needsYouFocus";
69
- import { hrefWithSession } from "@/lib/session-url";
69
+ import { openSessionById } from "./session-link";
70
70
  import { questionSource } from "@/lib/chat/turns";
71
71
  import { useViewport } from "@/hooks/useViewport";
72
72
  import { useChatCommands } from "./useChatCommands";
@@ -767,15 +767,9 @@ export function ChatPanel({
767
767
  if (item.kind === "ask") needsYouFocusActions.ask(item.ask.id);
768
768
  else if (item.kind === "chat") toEnd();
769
769
  else if (item.kind === "task" || item.kind === "card") {
770
- window.history.pushState(
771
- null,
772
- "",
773
- hrefWithSession(
774
- location.href,
775
- item.kind === "task" ? item.sessionId : item.card.sessionId
776
- )
770
+ openSessionById(
771
+ item.kind === "task" ? item.sessionId : item.card.sessionId
777
772
  );
778
- window.dispatchEvent(new PopStateEvent("popstate"));
779
773
  } else goTo((all) => questionSource(all, item.question.text));
780
774
  }}
781
775
  onReply={(item) => {
@@ -12,7 +12,7 @@ import {
12
12
  } from "lucide-react";
13
13
  import type { ChatItem, ChatOrigin, ChatOriginKind } from "@/lib/chat/events";
14
14
  import { eventParts, originOf } from "@/lib/chat/origin";
15
- import { hrefWithSession } from "@/lib/session-url";
15
+ import { SessionAnchor } from "./SessionAnchor";
16
16
  import { cn } from "@/lib/utils";
17
17
  import { UserMessage } from "./Messages";
18
18
  import { openSessionLink as openSession } from "./session-link";
@@ -45,8 +45,8 @@ function Source({ origin }: { origin: ChatOrigin }) {
45
45
  if (!origin.sessionId) return name;
46
46
  const id = origin.sessionId;
47
47
  return (
48
- <a
49
- href={hrefWithSession("/", id)}
48
+ <SessionAnchor
49
+ sessionId={id}
50
50
  onClick={(e) => {
51
51
  e.stopPropagation();
52
52
  openSession(e, id);
@@ -54,7 +54,7 @@ function Source({ origin }: { origin: ChatOrigin }) {
54
54
  className="hover:text-foreground relative min-w-0 underline-offset-2 before:absolute before:-inset-x-1 before:-inset-y-3.5 hover:underline"
55
55
  >
56
56
  {name}
57
- </a>
57
+ </SessionAnchor>
58
58
  );
59
59
  }
60
60
 
@@ -20,7 +20,7 @@ import { cn } from "@/lib/utils";
20
20
  import { leadingCommand } from "@/lib/chat/commands";
21
21
  import { compactedLine } from "@/lib/chat/stale";
22
22
  import { splitProse, splitUserText } from "@/lib/chat/user-text";
23
- import { inAppSessionId } from "@/lib/session-url";
23
+ import { inAppSessionRef } from "@/lib/session-url";
24
24
  import { CodeBlock } from "./Code";
25
25
  import { CopyButton } from "./CopyButton";
26
26
  import { FileChip } from "./FileChip";
@@ -42,16 +42,16 @@ import {
42
42
  type Of<K extends ChatItem["kind"]> = Extract<ChatItem, { kind: K }>;
43
43
 
44
44
  function UserLink({ href }: { href: string }) {
45
- const id =
45
+ const ref =
46
46
  typeof window === "undefined"
47
47
  ? null
48
- : inAppSessionId(href, window.location.origin);
48
+ : inAppSessionRef(href, window.location.origin);
49
49
  return (
50
50
  <a
51
51
  href={href}
52
- target={id ? undefined : "_blank"}
53
- rel={id ? undefined : "noopener noreferrer"}
54
- onClick={id ? (e) => openSessionLink(e, id) : undefined}
52
+ target={ref ? undefined : "_blank"}
53
+ rel={ref ? undefined : "noopener noreferrer"}
54
+ onClick={ref ? (e) => openSessionLink(e, ref) : undefined}
55
55
  className="chat-link font-medium"
56
56
  >
57
57
  {href}
@@ -0,0 +1,22 @@
1
+ "use client";
2
+
3
+ import type { AnchorHTMLAttributes } from "react";
4
+ import { useSessionHref } from "@/hooks/useSessionHref";
5
+ import { openSessionLink } from "./session-link";
6
+
7
+ type Props = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href"> & {
8
+ sessionId: string;
9
+ };
10
+
11
+ // A link to a session at its readable address. A plain click opens it here;
12
+ // a modified one, a new tab. An own onClick replaces the open.
13
+ export function SessionAnchor({ sessionId, onClick, ...rest }: Props) {
14
+ const href = useSessionHref(sessionId);
15
+ return (
16
+ <a
17
+ href={href}
18
+ onClick={onClick ?? ((e) => openSessionLink(e, sessionId))}
19
+ {...rest}
20
+ />
21
+ );
22
+ }
@@ -1,10 +1,27 @@
1
1
  import type { MouseEvent } from "react";
2
- import { hrefWithSession } from "@/lib/session-url";
2
+ import {
3
+ hrefWithRef,
4
+ hrefWithSession,
5
+ type SessionRef,
6
+ } from "@/lib/session-url";
3
7
 
4
- // Opens the session through its address, the way a shared link does.
5
- export function openSessionLink(e: MouseEvent<HTMLAnchorElement>, id: string) {
8
+ // Opens a session through its address, the way a shared link does. The
9
+ // address it lands on is rewritten to the session's readable one.
10
+ export function openSessionById(id: string): void {
11
+ go(hrefWithSession(location.href, { id }));
12
+ }
13
+
14
+ export function openSessionLink(
15
+ e: MouseEvent<HTMLAnchorElement>,
16
+ target: string | SessionRef
17
+ ) {
6
18
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
7
19
  e.preventDefault();
8
- window.history.pushState(null, "", hrefWithSession(location.href, id));
20
+ if (typeof target === "string") openSessionById(target);
21
+ else go(hrefWithRef(location.href, target));
22
+ }
23
+
24
+ function go(href: string): void {
25
+ window.history.pushState(null, "", href);
9
26
  window.dispatchEvent(new PopStateEvent("popstate"));
10
27
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { openSessionLink } from "@/components/Chat/session-link";
4
4
  import { compactTimeAgo } from "@/lib/session-meta";
5
- import { hrefWithSession } from "@/lib/session-url";
5
+ import { sessionPath } from "@/lib/session-url";
6
6
  import type { EnvSessionView } from "@/lib/health/env/types";
7
7
 
8
8
  export function SessionList({
@@ -27,7 +27,7 @@ export function SessionList({
27
27
  {sessions.map((s) => (
28
28
  <li key={s.sessionId} className="min-h-11 text-sm">
29
29
  <a
30
- href={hrefWithSession("/", s.sessionId)}
30
+ href={sessionPath({ id: s.sessionId, name: s.name })}
31
31
  onClick={(e) => openSessionLink(e, s.sessionId)}
32
32
  className="font-medium underline-offset-2 hover:underline"
33
33
  >
@@ -103,7 +103,7 @@ export function PaneMenu({
103
103
  </div>
104
104
  )}
105
105
  </DropdownMenuLabel>
106
- <DropdownMenuItem onSelect={() => copySessionLink(session.id)}>
106
+ <DropdownMenuItem onSelect={() => copySessionLink(session)}>
107
107
  <Link className="h-4 w-4" />
108
108
  Copy link
109
109
  </DropdownMenuItem>
@@ -12,7 +12,7 @@ import {
12
12
  queuedSubtitle,
13
13
  type QueuedRow as Row,
14
14
  } from "@/lib/sidebar/queued";
15
- import { sessionLink } from "@/lib/session-url";
15
+ import { SessionAnchor } from "@/components/Chat/SessionAnchor";
16
16
  import { cn } from "@/lib/utils";
17
17
  import { useRowContext } from "./RowContext";
18
18
  import { RowShell, type RowMenuKind } from "./RowShell";
@@ -184,8 +184,8 @@ function QueuedSubtitle({ row }: { row: Row }) {
184
184
  (target ? (
185
185
  <>
186
186
  {" "}
187
- <a
188
- href={sessionLink("", target)}
187
+ <SessionAnchor
188
+ sessionId={target}
189
189
  onClick={(e) => {
190
190
  // The row opens it too; the link alone handles its click.
191
191
  e.stopPropagation();
@@ -198,7 +198,7 @@ function QueuedSubtitle({ row }: { row: Row }) {
198
198
  className="hover:text-foreground underline decoration-dotted underline-offset-2"
199
199
  >
200
200
  {after}
201
- </a>
201
+ </SessionAnchor>
202
202
  </>
203
203
  ) : (
204
204
  ` ${after}`
@@ -191,7 +191,7 @@ export function SessionRowMenu({
191
191
  recipesUiActions.openRecipe(recipeRun!.id)
192
192
  );
193
193
  case "copy-link":
194
- return item("Copy link", Link, () => copySessionLink(session.id));
194
+ return item("Copy link", Link, () => copySessionLink(session));
195
195
  case "share":
196
196
  return item("Share…", Share2, () =>
197
197
  shareUiActions.open({
@@ -10,6 +10,7 @@ import {
10
10
  type SharedLinkRow,
11
11
  } from "@/data/shares";
12
12
  import { cn } from "@/lib/utils";
13
+ import { SessionAnchor } from "@/components/Chat/SessionAnchor";
13
14
 
14
15
  const when = (iso: string) =>
15
16
  new Date(iso).toLocaleDateString(undefined, {
@@ -44,12 +45,9 @@ function Row({ share }: { share: SharedLinkRow }) {
44
45
 
45
46
  const title =
46
47
  share.kind === "chat" && share.sessionId ? (
47
- <a
48
- href={`/?session=${encodeURIComponent(share.sessionId)}`}
49
- className="hover:underline"
50
- >
48
+ <SessionAnchor sessionId={share.sessionId} className="hover:underline">
51
49
  {share.title}
52
- </a>
50
+ </SessionAnchor>
53
51
  ) : (
54
52
  share.title
55
53
  );
@@ -65,9 +65,12 @@ export async function writeClipboard(text: string): Promise<void> {
65
65
  }
66
66
 
67
67
  // A session's own address (lib/session-url), for a bookmark or another device.
68
- export async function copySessionLink(sessionId: string): Promise<void> {
68
+ export async function copySessionLink(session: {
69
+ id: string;
70
+ name: string;
71
+ }): Promise<void> {
69
72
  try {
70
- await writeClipboard(sessionLink(window.location.origin, sessionId));
73
+ await writeClipboard(sessionLink(window.location.origin, session));
71
74
  toast.success("Link copied");
72
75
  } catch {
73
76
  toast.error("Could not copy to clipboard");
@@ -0,0 +1,20 @@
1
+ "use client";
2
+
3
+ import { useContext } from "react";
4
+ import { QueryClientContext } from "@tanstack/react-query";
5
+ import { sessionKeys } from "@/data/sessions/keys";
6
+ import type { SessionsResponse } from "@/data/sessions/queries";
7
+ import { sessionPath } from "@/lib/session-url";
8
+
9
+ /**
10
+ * A session's address from just its id: readable when the list has its name,
11
+ * `/s/<id8>` otherwise (opening that fills the slug in). Works without a
12
+ * query client too: a chat rendered to static markup (an export, a share).
13
+ */
14
+ export function useSessionHref(id: string): string {
15
+ const list = useContext(QueryClientContext)?.getQueryData<SessionsResponse>(
16
+ sessionKeys.list()
17
+ );
18
+ const name = list?.sessions.find((s) => s.id === id)?.name;
19
+ return sessionPath({ id, name });
20
+ }
@@ -3,7 +3,12 @@
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
4
4
  import { toast } from "sonner";
5
5
  import type { Session } from "@/lib/db";
6
- import { SessionUrlSync, locateSession } from "@/lib/session-url";
6
+ import {
7
+ SessionUrlSync,
8
+ locateSession,
9
+ pickSession,
10
+ type SessionRef,
11
+ } from "@/lib/session-url";
7
12
  import { usePanes } from "@/contexts/PaneContext";
8
13
 
9
14
  interface Options {
@@ -16,9 +21,9 @@ interface Options {
16
21
  }
17
22
 
18
23
  /**
19
- * The address bar names the focused session (`/?session=<id>`), and an
20
- * address opens its session: on load, after pairing, and on back/forward.
21
- * With split panes it follows the focused pane.
24
+ * The address bar names the focused session (`/s/<name>-<id8>`, following
25
+ * renames), and an address opens its session: on load, after pairing, and on
26
+ * back/forward. With split panes it follows the focused pane.
22
27
  */
23
28
  export function useSessionUrl({
24
29
  sessions,
@@ -78,23 +83,22 @@ export function useSessionUrl({
78
83
  replace: (href) => window.history.replaceState(null, "", href),
79
84
  },
80
85
  {
81
- isKnown: (id) => latest.current.sessions.some((s) => s.id === id),
86
+ find: (ref) => pickSession(ref, latest.current.sessions)?.id ?? null,
87
+ nameOf: (id) =>
88
+ latest.current.sessions.find((s) => s.id === id)?.name ?? null,
82
89
  open: (id) => {
83
90
  const session = latest.current.sessions.find((s) => s.id === id);
84
91
  if (session) reveal(session);
85
92
  },
86
93
  // Made a moment ago and not in the list yet: ask once more. Archived,
87
94
  // merged and dropped sessions aren't listed and stay not found.
88
- openUnlisted: async (id) => {
95
+ openUnlisted: async (ref) => {
89
96
  const fresh = await latest.current.reloadSessions();
90
- const session = fresh.find((s) => s.id === id);
97
+ const session = pickSession(ref, fresh);
91
98
  if (session) reveal(session);
92
99
  return !!session;
93
100
  },
94
- notFound: () =>
95
- toast.error("Session not found", {
96
- description: "It may have been archived or deleted.",
97
- }),
101
+ notFound: (ref) => void explainNotFound(ref),
98
102
  }
99
103
  );
100
104
  return syncRef.current;
@@ -112,6 +116,12 @@ export function useSessionUrl({
112
116
  getSync().focusChanged(focusedId);
113
117
  }, [getSync, focusedId]);
114
118
 
119
+ // A rename (by hand, or the title a first turn gives it) moves the slug.
120
+ const focusedName = sessions.find((s) => s.id === focusedId)?.name ?? null;
121
+ useEffect(() => {
122
+ if (focusedName !== null) getSync().renamed();
123
+ }, [getSync, focusedName]);
124
+
115
125
  const ready = hydrated && loaded;
116
126
  useEffect(() => {
117
127
  if (ready) void getSync().start();
@@ -123,3 +133,22 @@ export function useSessionUrl({
123
133
  return () => window.removeEventListener("popstate", onPop);
124
134
  }, [getSync]);
125
135
  }
136
+
137
+ // An archived session says so, by name; anything else isn't found.
138
+ async function explainNotFound(ref: SessionRef): Promise<void> {
139
+ const params = new URLSearchParams(
140
+ "id" in ref ? { ref: ref.id } : { ref: ref.short, slug: ref.slug ?? "" }
141
+ );
142
+ const found = await fetch(`/api/sessions/resolve?${params}`)
143
+ .then((r) => (r.ok ? r.json() : null))
144
+ .catch(() => null);
145
+ if (found?.archived) {
146
+ toast.error(`${found.name} is on the Done shelf`, {
147
+ description: "Bring it back from there to open it.",
148
+ });
149
+ return;
150
+ }
151
+ toast.error("Session not found", {
152
+ description: "It may have been archived or deleted.",
153
+ });
154
+ }
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import fs from "fs";
11
+ import path from "path";
11
12
  import type { ProjectConfig } from "../project-config";
12
13
  import { gitFetch, isAncestor } from "../git-exec";
13
14
  import { expandHome } from "../tasks/session";
@@ -19,14 +20,22 @@ export type DeployConfig = NonNullable<ProjectConfig["deploy"]>;
19
20
  export type DeployVerdict =
20
21
  | { state: "ok"; detail: string }
21
22
  | { state: "failed"; detail: string }
22
- | { state: "pending" }
23
+ | { state: "pending"; detail?: string }
23
24
  | { state: "unchecked"; detail: string };
24
25
 
25
26
  export const DEPLOY_WITHIN_MIN = 30;
26
27
  // After the deploy lands, the server gets this long to answer healthy.
27
28
  export const HEALTH_GRACE_MS = 3 * 60 * 1000;
28
29
 
30
+ // scripts/autodeploy holds a new head AGENTOS_MIN_GAP seconds after the last
31
+ // restart (`restarted`, next to `deployed`), so a merge isn't late until the
32
+ // gap is over plus this margin.
33
+ export const GAP_MARGIN_MS = 10 * 60 * 1000;
34
+ const DEFAULT_GAP_S = 3600;
35
+
29
36
  export interface DeployDeps {
37
+ // When the deploy last restarted the server, in ms; null when unknown.
38
+ restartedAt: (file: string) => number | null;
30
39
  // The commit a state file names, or null when there's no such file.
31
40
  readSha: (file: string) => { sha: string; at: number } | null;
32
41
  // Whether `sha` is in the history of `tip` (fetching it if need be);
@@ -89,7 +98,22 @@ async function health(
89
98
  }
90
99
  }
91
100
 
101
+ function restartedAt(file: string): number | null {
102
+ try {
103
+ const s = Number(fs.readFileSync(expandHome(file), "utf-8").trim());
104
+ return Number.isFinite(s) && s > 0 ? s * 1000 : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ const gapMs = () => {
111
+ const s = Number(process.env.AGENTOS_MIN_GAP ?? DEFAULT_GAP_S);
112
+ return (Number.isFinite(s) && s >= 0 ? s : DEFAULT_GAP_S) * 1000;
113
+ };
114
+
92
115
  export const DEFAULT_DEPS: DeployDeps = {
116
+ restartedAt,
93
117
  readSha,
94
118
  contains,
95
119
  health,
@@ -159,8 +183,21 @@ async function judge(
159
183
  state: "failed",
160
184
  detail: `the deploy of ${failed.sha.slice(0, 7)} (which has ${short}) failed; see the deploy's log`,
161
185
  };
186
+ // The state files sit together; `restarted` is beside `deployed`.
187
+ const restarted = deps.restartedAt(
188
+ path.join(path.dirname(config.deployed), "restarted")
189
+ );
190
+ const gapEnd = restarted === null ? 0 : restarted + gapMs();
191
+ if (gapEnd > input.now)
192
+ return {
193
+ state: "pending",
194
+ detail: `deploy waiting: restart gap, ~${Math.ceil((gapEnd - input.now) / 60000)} min left`,
195
+ };
162
196
  const within = (config.within_minutes ?? DEPLOY_WITHIN_MIN) * 60 * 1000;
163
- if (input.now - input.mergedAt >= within)
197
+ if (
198
+ input.now - input.mergedAt >= within &&
199
+ input.now >= gapEnd + GAP_MARGIN_MS
200
+ )
164
201
  return {
165
202
  state: "failed",
166
203
  detail: `${short} not deployed ${Math.round(within / 60000)} minutes after the merge`,
@@ -6,6 +6,7 @@ import type { TaskState } from "../../tasks/state";
6
6
  import type { DigestSignals } from "../../analyst/types";
7
7
  import { latestChecks } from "../../analyst/signals";
8
8
  import type { EvidenceRef } from "../types";
9
+ import { sessionPath } from "../../session-url";
9
10
 
10
11
  const EXCERPT = 1500;
11
12
  const cap = (s: string, n = EXCERPT) =>
@@ -82,7 +83,7 @@ export function testEvidence(
82
83
  status: c.passed ? "pass" : "fail",
83
84
  at,
84
85
  sha: head ?? undefined,
85
- href: `/?session=${sessionId}`,
86
+ href: sessionPath({ id: sessionId }),
86
87
  }));
87
88
  }
88
89
 
@@ -101,7 +102,7 @@ export function diffEvidence(
101
102
  label: `${m[3]} (+${m[1] === "-" ? 0 : m[1]} −${m[2] === "-" ? 0 : m[2]})`,
102
103
  status: "info",
103
104
  at,
104
- href: `/?session=${sessionId}`,
105
+ href: sessionPath({ id: sessionId }),
105
106
  });
106
107
  if (out.length >= 40) break;
107
108
  }
@@ -174,7 +175,7 @@ export function logEvidence(log: {
174
175
  status: "pass",
175
176
  at: log.createdAt,
176
177
  excerpt: log.mergeCommit ? `Merge commit ${log.mergeCommit}` : undefined,
177
- href: `/?session=${log.sessionId}`,
178
+ href: sessionPath({ id: log.sessionId }),
178
179
  };
179
180
  }
180
181
 
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { PatternStat, Summary } from "./aggregate";
8
8
  import type { AuditFindings } from "./store";
9
+ import { sessionPath } from "../session-url";
9
10
 
10
11
  export const fmt = (n: number) =>
11
12
  n >= 1e9
@@ -245,7 +246,7 @@ export function renderSummary(
245
246
  "biggest output (est.)",
246
247
  ],
247
248
  s.sessions.slice(0, 30).map((x) => [
248
- `[${x.id.slice(0, 8)}](/?session=${x.id})`,
249
+ `[${x.id.slice(0, 8)}](${sessionPath(x)})`,
249
250
  x.kind,
250
251
  x.name.slice(0, 40),
251
252
  x.turns,
@@ -18,7 +18,7 @@ import { getWorkspace } from "../workspaces";
18
18
  import { amendmentsOf } from "../orchestrator/amendments";
19
19
  import { PLAN_FILE } from "../models/plan-step";
20
20
  import { estimateCost, rowModel } from "../usage/cost";
21
- import { sessionLink } from "../session-url";
21
+ import { sessionPath } from "../session-url";
22
22
  import type { LogData, LogEvent, LogUsageRow } from "./build";
23
23
 
24
24
  const expandHome = (p: string) => p.replace(/^~/, os.homedir());
@@ -296,6 +296,6 @@ export function gatherLogData(s: Session, opts: GatherOpts = {}): LogData {
296
296
  timeline,
297
297
  usage: usageRows(s.id),
298
298
  outcome: finalMessage(s.id),
299
- sessionPath: sessionLink("", s.id),
299
+ sessionPath: sessionPath(s),
300
300
  };
301
301
  }
@@ -0,0 +1,29 @@
1
+ // The readable part of a session's address (`/s/<slug>-<id8>`). Cosmetic:
2
+ // only the short id resolves, so the slug is rebuilt from the current name
3
+ // and a rename never breaks a link. No imports: the browser uses it too.
4
+
5
+ export const SLUG_MAX = 48;
6
+ export const SHORT_ID_LEN = 8;
7
+
8
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9
+
10
+ export const isUuid = (id: string): boolean => UUID.test(id);
11
+
12
+ /** Lowercase ASCII words joined by `-`; `session` when nothing is left. */
13
+ export function sessionSlug(name: string): string {
14
+ const words = (name ?? "")
15
+ .normalize("NFKD")
16
+ .replace(/\p{M}/gu, "")
17
+ .toLowerCase()
18
+ .replace(/[^a-z0-9]+/g, "-")
19
+ .replace(/^-+|-+$/g, "");
20
+ if (!words) return "session";
21
+ if (words.length <= SLUG_MAX) return words;
22
+ const cut = words.slice(0, SLUG_MAX);
23
+ const atWord = cut.lastIndexOf("-");
24
+ return (atWord > 0 ? cut.slice(0, atWord) : cut).replace(/-+$/, "");
25
+ }
26
+
27
+ /** The first group of a uuid, lowercase. */
28
+ export const shortId = (id: string): string =>
29
+ id.slice(0, SHORT_ID_LEN).toLowerCase();
@@ -1,50 +1,152 @@
1
- // Every session has its own address: `/?session=<id>`. The id, not the name:
2
- // names change, ids don't. Nothing here grants access; the page behind the
3
- // address is behind the device gate like any other.
1
+ // Every session has its own address: `/s/<slug>-<id8>`, the slug made from
2
+ // its name and id8 the first group of its uuid. Only the id resolves: the
3
+ // slug is rebuilt from the current name, so a rename never breaks a link,
4
+ // and a stale one is rewritten in place. `/?session=<id>`, the address before,
5
+ // still opens and is rewritten the same way. Nothing here grants access; the
6
+ // page behind the address is behind the device gate like any other.
4
7
 
5
8
  import { getAllPaneIds, type PaneState } from "./panes";
9
+ import { isUuid, sessionSlug, shortId, SHORT_ID_LEN } from "./session-slug";
6
10
 
7
11
  export const SESSION_PARAM = "session";
12
+ export const SESSION_PATH = "/s/";
8
13
 
9
14
  // Ids are uuids today; anything id-shaped is accepted so other machines'
10
15
  // sessions fit too. Everything else in the address is ignored.
11
16
  const ID_SHAPE = /^[A-Za-z0-9_-]{1,128}$/;
17
+ const SHORT = new RegExp(`^[0-9a-f]{${SHORT_ID_LEN}}$`);
18
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
19
+ // The one path segment of `/s/<ref>`.
20
+ const REF_PATH = /^\/s\/([A-Za-z0-9-]{1,200})$/;
12
21
 
13
22
  // The site-relative origin used to parse paths with URL.
14
23
  const BASE = "http://agentos.invalid";
15
24
 
16
- export function sessionIdFromHref(href: string): string | null {
17
- const id = new URL(href, BASE).searchParams.get(SESSION_PARAM);
18
- return id && ID_SHAPE.test(id) ? id : null;
25
+ /** What an address names: a whole id, or a short id and its (cosmetic) slug. */
26
+ export type SessionRef =
27
+ { id: string } | { short: string; slug: string | null };
28
+
29
+ /** A session as links need it: the name only makes the slug. */
30
+ export interface SessionLinkable {
31
+ id: string;
32
+ name?: string | null;
33
+ }
34
+
35
+ function refFromSegment(segment: string): SessionRef | null {
36
+ if (isUuid(segment)) return { id: segment.toLowerCase() };
37
+ const lower = segment.toLowerCase();
38
+ const dash = lower.lastIndexOf("-");
39
+ const short = lower.slice(dash + 1);
40
+ if (!SHORT.test(short)) return null;
41
+ const slug = dash > 0 ? lower.slice(0, dash) : null;
42
+ if (slug !== null && !SLUG.test(slug)) return null;
43
+ return { short, slug };
44
+ }
45
+
46
+ export function sessionRefFromHref(href: string): SessionRef | null {
47
+ const url = new URL(href, BASE);
48
+ const m = REF_PATH.exec(url.pathname);
49
+ if (m) return refFromSegment(m[1]);
50
+ const id = url.searchParams.get(SESSION_PARAM);
51
+ return id && ID_SHAPE.test(id) ? { id } : null;
52
+ }
53
+
54
+ /** Whether a ref could name this id (the slug isn't consulted). */
55
+ export function refMatches(ref: SessionRef, id: string): boolean {
56
+ if ("id" in ref) return ref.id === id || ref.id === id.toLowerCase();
57
+ return isUuid(id) && id.toLowerCase().startsWith(ref.short);
58
+ }
59
+
60
+ /**
61
+ * The session a ref names among these: the one id, else the one with the
62
+ * prefix, else (two sharing a prefix) the one whose slug matches, then the
63
+ * newest.
64
+ */
65
+ export function pickSession<
66
+ T extends { id: string; name: string; created_at?: string | null },
67
+ >(ref: SessionRef, sessions: readonly T[]): T | null {
68
+ const found = sessions.filter((s) => refMatches(ref, s.id));
69
+ if (found.length <= 1) return found[0] ?? null;
70
+ if ("short" in ref && ref.slug) {
71
+ const bySlug = found.filter((s) => sessionSlug(s.name) === ref.slug);
72
+ if (bySlug.length === 1) return bySlug[0];
73
+ if (bySlug.length > 1) return newest(bySlug);
74
+ }
75
+ return newest(found);
76
+ }
77
+
78
+ const newest = <T extends { created_at?: string | null }>(xs: T[]): T =>
79
+ xs.reduce((a, b) => ((b.created_at ?? "") > (a.created_at ?? "") ? b : a));
80
+
81
+ /**
82
+ * A session's address: `/s/<slug>-<id8>`, or `/s/<id8>` when the name isn't
83
+ * at hand (opening it fills the slug in). An id that isn't a uuid keeps the
84
+ * old `/?session=<id>`.
85
+ */
86
+ export function sessionPath(s: SessionLinkable): string {
87
+ if (!isUuid(s.id)) return `/?${SESSION_PARAM}=${encodeURIComponent(s.id)}`;
88
+ const short = shortId(s.id);
89
+ return s.name == null
90
+ ? `${SESSION_PATH}${short}`
91
+ : `${SESSION_PATH}${sessionSlug(s.name)}-${short}`;
92
+ }
93
+
94
+ /** The address a ref names as it stands, slug and all. */
95
+ export function refPath(ref: SessionRef): string {
96
+ if ("id" in ref) return sessionPath({ id: ref.id });
97
+ const slug = ref.slug ? `${ref.slug}-` : "";
98
+ return `${SESSION_PATH}${slug}${ref.short}`;
19
99
  }
20
100
 
21
- /** The same address with the session set (or removed), as path+query+hash. */
22
- export function hrefWithSession(href: string, id: string | null): string {
101
+ function hrefWithPath(href: string, path: string | null): string {
23
102
  const url = new URL(href, BASE);
24
- if (id) url.searchParams.set(SESSION_PARAM, id);
25
- else url.searchParams.delete(SESSION_PARAM);
103
+ url.searchParams.delete(SESSION_PARAM);
104
+ const target = new URL(path ?? "/", BASE);
105
+ url.pathname = target.pathname;
106
+ target.searchParams.forEach((v, k) => url.searchParams.set(k, v));
26
107
  return url.pathname + url.search + url.hash;
27
108
  }
28
109
 
110
+ /**
111
+ * The same address showing this session (or none), as path+query+hash. Every
112
+ * other query param (`?settings=`, `?welcome=`) stays.
113
+ */
114
+ export function hrefWithSession(
115
+ href: string,
116
+ s: SessionLinkable | null
117
+ ): string {
118
+ return hrefWithPath(href, s ? sessionPath(s) : null);
119
+ }
120
+
121
+ /** The same address showing the session a ref names. */
122
+ export function hrefWithRef(href: string, ref: SessionRef): string {
123
+ return hrefWithPath(href, refPath(ref));
124
+ }
125
+
29
126
  /** The session a link to this same instance opens, or null for any other link. */
30
- export function inAppSessionId(href: string, origin: string): string | null {
127
+ export function inAppSessionRef(
128
+ href: string,
129
+ origin: string
130
+ ): SessionRef | null {
31
131
  let url: URL;
32
132
  try {
33
133
  url = new URL(href);
34
134
  } catch {
35
135
  return null;
36
136
  }
37
- if (url.origin !== origin || url.pathname !== "/") return null;
38
- return sessionIdFromHref(href);
137
+ if (url.origin !== origin) return null;
138
+ if (url.pathname !== "/" && !url.pathname.startsWith(SESSION_PATH))
139
+ return null;
140
+ return sessionRefFromHref(href);
39
141
  }
40
142
 
41
- export function sessionLink(origin: string, id: string): string {
42
- return `${origin}/?${SESSION_PARAM}=${encodeURIComponent(id)}`;
143
+ export function sessionLink(origin: string, s: SessionLinkable): string {
144
+ return `${origin}${sessionPath(s)}`;
43
145
  }
44
146
 
45
147
  /**
46
148
  * The address to come back to after pairing, or null. Only the app's own
47
- * page with a query survives: what a link to a session is. Checked after
149
+ * page survives: `/` with a query, or a session's `/s/<ref>`. Checked after
48
150
  * parsing, as `/.//host` and the like resolve to another host.
49
151
  */
50
152
  export function safeNextPath(raw: string | null | undefined): string | null {
@@ -55,7 +157,9 @@ export function safeNextPath(raw: string | null | undefined): string | null {
55
157
  } catch {
56
158
  return null;
57
159
  }
58
- if (url.origin !== BASE || url.pathname !== "/" || !url.search) return null;
160
+ if (url.origin !== BASE) return null;
161
+ if (REF_PATH.test(url.pathname)) return url.pathname + url.search;
162
+ if (url.pathname !== "/" || !url.search) return null;
59
163
  return "/" + url.search;
60
164
  }
61
165
 
@@ -94,8 +198,10 @@ export interface SessionUrlEnv {
94
198
  }
95
199
 
96
200
  export interface SessionUrlDeps {
97
- /** A session the sidebar lists. */
98
- isKnown(id: string): boolean;
201
+ /** The listed session a ref names, or null. */
202
+ find(ref: SessionRef): string | null;
203
+ /** A listed session's current name, for its address. */
204
+ nameOf(id: string): string | null;
99
205
  /** Show a listed session. */
100
206
  open(id: string): void;
101
207
  /**
@@ -103,9 +209,9 @@ export interface SessionUrlDeps {
103
209
  * found and opened. Archived, merged and dropped sessions aren't listed and
104
210
  * stay not found.
105
211
  */
106
- openUnlisted(id: string): Promise<boolean>;
212
+ openUnlisted(ref: SessionRef): Promise<boolean>;
107
213
  /** The address named a session that can't be opened. */
108
- notFound(id: string): void;
214
+ notFound(ref: SessionRef): void;
109
215
  }
110
216
 
111
217
  /**
@@ -115,13 +221,15 @@ export interface SessionUrlDeps {
115
221
  *
116
222
  * A switch away from a live session pushes an entry, so back returns to it.
117
223
  * Anything else replaces: the first sync, leaving a session that was archived
118
- * or deleted, and leaving a sessionless address.
224
+ * or deleted, leaving a sessionless address, and rewriting an address that
225
+ * names the shown session another way (an old `?session=`, a stale slug, a
226
+ * rename).
119
227
  */
120
228
  export class SessionUrlSync {
121
229
  private started = false;
122
230
  // A session the address asked for, until it's the one focused (or the
123
231
  // open plainly didn't land).
124
- private pending: string | null = null;
232
+ private pending: SessionRef | null = null;
125
233
  private pendingUntil = 0;
126
234
  private focused: string | null = null;
127
235
 
@@ -134,8 +242,8 @@ export class SessionUrlSync {
134
242
  /** Once panes and sessions are loaded: follow the address it opened with. */
135
243
  async start(): Promise<void> {
136
244
  if (this.started) return;
137
- const id = sessionIdFromHref(this.env.href());
138
- if (id) await this.go(id);
245
+ const ref = sessionRefFromHref(this.env.href());
246
+ if (ref) await this.go(ref);
139
247
  this.started = true;
140
248
  if (!this.pending) this.sync();
141
249
  }
@@ -144,42 +252,56 @@ export class SessionUrlSync {
144
252
  focusChanged(id: string | null): void {
145
253
  this.focused = id;
146
254
  if (this.pending && this.now() < this.pendingUntil) {
147
- if (id !== this.pending) return;
255
+ if (!id || !refMatches(this.pending, id)) return;
148
256
  }
149
257
  this.pending = null;
150
258
  if (this.started) this.sync();
151
259
  }
152
260
 
261
+ /** The focused session's name changed: the address takes the new slug. */
262
+ renamed(): void {
263
+ if (this.started && !this.pending) this.write("replace");
264
+ }
265
+
153
266
  /** Back or forward landed on another entry. */
154
267
  async popstate(): Promise<void> {
155
268
  if (!this.started) return;
156
- const id = sessionIdFromHref(this.env.href());
157
- if (id && id !== this.focused) await this.go(id);
269
+ const ref = sessionRefFromHref(this.env.href());
270
+ if (ref) {
271
+ const id = this.deps.find(ref);
272
+ if (!id || id !== this.focused) await this.go(ref);
273
+ }
158
274
  // An entry with no session, or one that couldn't open: say what's shown.
159
275
  if (!this.pending) this.write("replace");
160
276
  }
161
277
 
162
- private async go(id: string): Promise<void> {
163
- if (id === this.focused) return;
164
- this.pending = id;
278
+ private async go(ref: SessionRef): Promise<void> {
279
+ const id = this.deps.find(ref);
280
+ if (id && id === this.focused) return;
281
+ this.pending = ref;
165
282
  this.pendingUntil = this.now() + PENDING_MS;
166
- if (this.deps.isKnown(id)) return this.deps.open(id);
167
- const opened = await this.deps.openUnlisted(id).catch(() => false);
283
+ if (id) return this.deps.open(id);
284
+ const opened = await this.deps.openUnlisted(ref).catch(() => false);
168
285
  if (opened) return;
169
- if (this.pending === id) this.pending = null;
170
- this.deps.notFound(id);
286
+ if (this.pending === ref) this.pending = null;
287
+ this.deps.notFound(ref);
171
288
  }
172
289
 
173
290
  private sync(): void {
174
- const current = sessionIdFromHref(this.env.href());
175
- if (current === this.focused) return;
176
- this.write(current && this.deps.isKnown(current) ? "push" : "replace");
291
+ const ref = sessionRefFromHref(this.env.href());
292
+ const current = ref && this.deps.find(ref);
293
+ this.write(current && current !== this.focused ? "push" : "replace");
177
294
  }
178
295
 
179
296
  private write(mode: "push" | "replace"): void {
180
297
  const href = this.env.href();
181
- if (sessionIdFromHref(href) === this.focused) return;
182
- const next = hrefWithSession(href, this.focused);
298
+ const id = this.focused;
299
+ const next = hrefWithSession(
300
+ href,
301
+ id ? { id, name: this.deps.nameOf(id) ?? undefined } : null
302
+ );
303
+ const url = new URL(href, BASE);
304
+ if (next === url.pathname + url.search + url.hash) return;
183
305
  if (mode === "push") this.env.push(next);
184
306
  else this.env.replace(next);
185
307
  }
@@ -0,0 +1,55 @@
1
+ // The session a link's ref names, archived ones included: what the page asks
2
+ // when its list doesn't have it (lib/session-url).
3
+
4
+ import { db } from "../db";
5
+ import { pickSession, type SessionRef } from "../session-url";
6
+ import { isUuid, SHORT_ID_LEN } from "../session-slug";
7
+
8
+ export interface ResolvedSession {
9
+ id: string;
10
+ name: string;
11
+ archived: boolean;
12
+ }
13
+
14
+ interface Row {
15
+ id: string;
16
+ name: string;
17
+ archived_at: string | null;
18
+ created_at: string | null;
19
+ }
20
+
21
+ const ID_SHAPE = /^[A-Za-z0-9_-]{1,128}$/;
22
+ const SHORT = new RegExp(`^[0-9a-f]{${SHORT_ID_LEN}}$`);
23
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
24
+
25
+ /** The ref a request's `ref` and `slug` params name, or null when malformed. */
26
+ export function refFromParams(
27
+ ref: string | null,
28
+ slug: string | null
29
+ ): SessionRef | null {
30
+ if (!ref) return null;
31
+ if (SHORT.test(ref)) {
32
+ return { short: ref, slug: slug && SLUG.test(slug) ? slug : null };
33
+ }
34
+ // A whole id; never a shorter prefix, which would let a caller walk the table.
35
+ if (isUuid(ref) || (ID_SHAPE.test(ref) && ref.length > SHORT_ID_LEN))
36
+ return { id: ref };
37
+ return null;
38
+ }
39
+
40
+ export function resolveSessionRef(ref: SessionRef): ResolvedSession | null {
41
+ const cols = `id, name, archived_at, created_at`;
42
+ const rows =
43
+ "id" in ref
44
+ ? (db
45
+ .prepare(`SELECT ${cols} FROM sessions WHERE id = ?`)
46
+ .all(ref.id) as Row[])
47
+ : // A range on the key, so the index finds the prefix.
48
+ (db
49
+ .prepare(`SELECT ${cols} FROM sessions WHERE id >= ? AND id < ?`)
50
+ .all(ref.short, `${ref.short}~`) as Row[]);
51
+ const hit = pickSession(ref, rows);
52
+ return hit
53
+ ? { id: hit.id, name: hit.name, archived: hit.archived_at !== null }
54
+ : null;
55
+ }
@@ -1,6 +1,6 @@
1
1
  // Settings has its own address: `/?settings=<section>`, or a bare
2
2
  // `?settings` for the list (a phone) or the first section. It sits beside
3
- // `?session=`, so a link can name both.
3
+ // a session's address (`/s/<name>-<id8>`), so a link can name both.
4
4
 
5
5
  export const SETTINGS_PARAM = "settings";
6
6
 
@@ -1,5 +1,5 @@
1
1
  // The welcome has its own address: `/?welcome=<step>`, beside `?settings=`
2
- // and `?session=`. `/welcome` redirects here.
2
+ // and a session's `/s/<name>-<id8>`. `/welcome` redirects here.
3
3
 
4
4
  export const WELCOME_PARAM = "welcome";
5
5
 
package/next.config.ts CHANGED
@@ -13,6 +13,10 @@ const nextConfig: NextConfig = {
13
13
  experimental: {
14
14
  agentUpgrade: false,
15
15
  },
16
+ // A session's readable address (lib/session-url) is the same one page.
17
+ async rewrites() {
18
+ return { beforeFiles: [{ source: "/s/:ref", destination: "/" }] };
19
+ },
16
20
  turbopack: {
17
21
  root: resolve(import.meta.dirname),
18
22
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.768",
3
+ "version": "0.3.770",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -4,7 +4,11 @@ import { bookmarksUiActions } from "@/stores/bookmarksUi";
4
4
  // Opens a chat by its address, then takes it to one message (bookmarks and
5
5
  // search both end here). The chat takes the jump once it is on screen.
6
6
  export function openChatAt(sessionId: string, itemId: string): void {
7
- window.history.pushState(null, "", hrefWithSession(location.href, sessionId));
7
+ window.history.pushState(
8
+ null,
9
+ "",
10
+ hrefWithSession(location.href, { id: sessionId })
11
+ );
8
12
  window.dispatchEvent(new PopStateEvent("popstate"));
9
13
  bookmarksUiActions.jump(sessionId, itemId);
10
14
  }