@saadnvd1/agent-os 0.3.766 → 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.
- package/app/api/sessions/resolve/route.test.ts +81 -0
- package/app/api/sessions/resolve/route.ts +14 -0
- package/components/Chat/ChatPanel.tsx +3 -9
- package/components/Chat/EventRow.tsx +4 -4
- package/components/Chat/Messages.tsx +6 -6
- package/components/Chat/SessionAnchor.tsx +22 -0
- package/components/Chat/session-link.ts +21 -4
- package/components/Health/EnvFailures/SessionList.tsx +2 -2
- package/components/Pane/PaneMenu.tsx +1 -1
- package/components/SessionList/QueuedRow.tsx +4 -4
- package/components/SessionList/SessionRowMenu.tsx +1 -1
- package/components/Shares/SharedLinksPanel.tsx +3 -5
- package/hooks/useCopyToClipboard.ts +5 -2
- package/hooks/useSessionHref.ts +20 -0
- package/hooks/useSessionUrl.ts +40 -11
- package/lib/away/deploy.ts +39 -2
- package/lib/reports/evidence/build.ts +4 -3
- package/lib/session-audit/render.ts +2 -1
- package/lib/session-logs/gather.ts +2 -2
- package/lib/session-slug.ts +29 -0
- package/lib/session-url.ts +162 -40
- package/lib/sessions/resolve.ts +55 -0
- package/lib/settings-url.ts +1 -1
- package/lib/tasks/contract/plan.ts +8 -1
- package/lib/tasks/placement.ts +22 -0
- package/lib/tasks/queue-placement.ts +15 -3
- package/lib/welcome-url.ts +1 -1
- package/next.config.ts +4 -0
- package/package.json +1 -1
- package/stores/chatJump.ts +5 -1
|
@@ -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 {
|
|
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
|
-
|
|
771
|
-
|
|
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 {
|
|
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
|
-
<
|
|
49
|
-
|
|
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
|
-
</
|
|
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 {
|
|
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
|
|
45
|
+
const ref =
|
|
46
46
|
typeof window === "undefined"
|
|
47
47
|
? null
|
|
48
|
-
:
|
|
48
|
+
: inAppSessionRef(href, window.location.origin);
|
|
49
49
|
return (
|
|
50
50
|
<a
|
|
51
51
|
href={href}
|
|
52
|
-
target={
|
|
53
|
-
rel={
|
|
54
|
-
onClick={
|
|
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 {
|
|
2
|
+
import {
|
|
3
|
+
hrefWithRef,
|
|
4
|
+
hrefWithSession,
|
|
5
|
+
type SessionRef,
|
|
6
|
+
} from "@/lib/session-url";
|
|
3
7
|
|
|
4
|
-
// Opens
|
|
5
|
-
|
|
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
|
-
|
|
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 {
|
|
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={
|
|
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
|
|
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 {
|
|
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
|
-
<
|
|
188
|
-
|
|
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
|
-
</
|
|
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
|
|
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
|
-
<
|
|
48
|
-
href={`/?session=${encodeURIComponent(share.sessionId)}`}
|
|
49
|
-
className="hover:underline"
|
|
50
|
-
>
|
|
48
|
+
<SessionAnchor sessionId={share.sessionId} className="hover:underline">
|
|
51
49
|
{share.title}
|
|
52
|
-
</
|
|
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(
|
|
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,
|
|
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
|
+
}
|
package/hooks/useSessionUrl.ts
CHANGED
|
@@ -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 {
|
|
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 (
|
|
20
|
-
* address opens its session: on load, after pairing, and on
|
|
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
|
-
|
|
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 (
|
|
95
|
+
openUnlisted: async (ref) => {
|
|
89
96
|
const fresh = await latest.current.reloadSessions();
|
|
90
|
-
const session =
|
|
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
|
+
}
|
package/lib/away/deploy.ts
CHANGED
|
@@ -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 (
|
|
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:
|
|
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:
|
|
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:
|
|
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)}](
|
|
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 {
|
|
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:
|
|
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();
|
package/lib/session-url.ts
CHANGED
|
@@ -1,50 +1,152 @@
|
|
|
1
|
-
// Every session has its own address:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
38
|
-
|
|
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,
|
|
42
|
-
return `${origin}
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
98
|
-
|
|
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(
|
|
212
|
+
openUnlisted(ref: SessionRef): Promise<boolean>;
|
|
107
213
|
/** The address named a session that can't be opened. */
|
|
108
|
-
notFound(
|
|
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,
|
|
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:
|
|
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
|
|
138
|
-
if (
|
|
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
|
|
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
|
|
157
|
-
if (
|
|
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(
|
|
163
|
-
|
|
164
|
-
this.
|
|
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 (
|
|
167
|
-
const opened = await this.deps.openUnlisted(
|
|
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 ===
|
|
170
|
-
this.deps.notFound(
|
|
286
|
+
if (this.pending === ref) this.pending = null;
|
|
287
|
+
this.deps.notFound(ref);
|
|
171
288
|
}
|
|
172
289
|
|
|
173
290
|
private sync(): void {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
this.write(current && this.
|
|
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
|
-
|
|
182
|
-
const next = hrefWithSession(
|
|
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
|
+
}
|
package/lib/settings-url.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
|
@@ -25,7 +25,7 @@ import { getProject } from "../../projects";
|
|
|
25
25
|
import { planFor, splitOf } from "../../plans/features";
|
|
26
26
|
import { moveTask } from "../move-flow";
|
|
27
27
|
import * as client from "./client";
|
|
28
|
-
import { meetsNeeds, needsOf } from "../placement";
|
|
28
|
+
import { meetsNeeds, needsOf, taskPinnedToMac } from "../placement";
|
|
29
29
|
import { remotePlanPath } from "./plan-path";
|
|
30
30
|
import { DEFAULT_PRIORITY, isPriority } from "../schedule-types";
|
|
31
31
|
import { localHolds } from "../hold-checks";
|
|
@@ -55,6 +55,9 @@ function checkedSplit(s: Session, text: string | null) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
const pinned = (s: Session, project: { working_directory: string }) =>
|
|
59
|
+
taskPinnedToMac(s.id, expandHome(project.working_directory));
|
|
60
|
+
|
|
58
61
|
/**
|
|
59
62
|
* Where a written plan is built: "there" (approved on the machine that
|
|
60
63
|
* planned it) or "home" (moved here first). Pieces are checked against this
|
|
@@ -70,6 +73,9 @@ export function implementTarget(
|
|
|
70
73
|
// The project lives there: nothing here to build it in.
|
|
71
74
|
if (!project || isRemoteHost(project.host_id)) return "there";
|
|
72
75
|
if (split) return "home";
|
|
76
|
+
// An explicit Mac pin builds on the Mac, wherever the plan ran.
|
|
77
|
+
if (pinned(s, project))
|
|
78
|
+
return meetsNeeds(["mac"], machineCapabilities()) ? "home" : "there";
|
|
73
79
|
const { needs } = needsOf({
|
|
74
80
|
projectId: project.id,
|
|
75
81
|
projectPath: expandHome(project.working_directory),
|
|
@@ -229,6 +235,7 @@ export async function handoffHome(
|
|
|
229
235
|
projectPath: expandHome(project.working_directory),
|
|
230
236
|
prompt: s.task_prompt ?? "",
|
|
231
237
|
});
|
|
238
|
+
if (pinned(s, project) && !needs.includes("mac")) needs.push("mac");
|
|
232
239
|
const holds = localHolds(
|
|
233
240
|
{
|
|
234
241
|
needs,
|
package/lib/tasks/placement.ts
CHANGED
|
@@ -79,6 +79,28 @@ export function inferNeeds(projectPath: string): Need[] {
|
|
|
79
79
|
return [];
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
// Whether the starter asked for a Mac outright: the request's `machine`
|
|
83
|
+
// (stored on the queue row, which shares the task's id), a recipe's, or the
|
|
84
|
+
// project's own machine setting. A need read from the brief or the files
|
|
85
|
+
// doesn't count: that one a plan can ignore.
|
|
86
|
+
export function pinnedToMac(
|
|
87
|
+
stored: string | null,
|
|
88
|
+
projectPath: string
|
|
89
|
+
): boolean {
|
|
90
|
+
const asked = parseNeeds(stored);
|
|
91
|
+
if (asked) return asked.includes("mac");
|
|
92
|
+
return !!loadProjectConfig(projectPath).config.machine?.needs?.includes(
|
|
93
|
+
"mac"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function taskPinnedToMac(taskId: string, projectPath: string): boolean {
|
|
98
|
+
const row = db
|
|
99
|
+
.prepare(`SELECT machine_needs FROM task_queue WHERE id = ?`)
|
|
100
|
+
.get(taskId) as { machine_needs: string | null } | undefined;
|
|
101
|
+
return pinnedToMac(row?.machine_needs ?? null, projectPath);
|
|
102
|
+
}
|
|
103
|
+
|
|
82
104
|
export function needsOf(input: {
|
|
83
105
|
projectId: string;
|
|
84
106
|
projectPath: string;
|
|
@@ -13,6 +13,7 @@ import { usesGitCrypt } from "../worktree-add";
|
|
|
13
13
|
import { list as hostTasks } from "./contract/client";
|
|
14
14
|
import {
|
|
15
15
|
asPlanPhase,
|
|
16
|
+
pinnedToMac,
|
|
16
17
|
place,
|
|
17
18
|
unplaceable,
|
|
18
19
|
wantsPlacing,
|
|
@@ -90,11 +91,22 @@ async function peerOptions(
|
|
|
90
91
|
return out;
|
|
91
92
|
}
|
|
92
93
|
|
|
93
|
-
// A start that plans first goes by its plan phase: it needs no
|
|
94
|
-
// simulator, and a plan-only one that no machine can take starts here
|
|
94
|
+
// A start that plans first goes by its plan phase: it needs no simulator, and a plan-only one that no machine can take starts here
|
|
95
95
|
// as ever (it never waits on load).
|
|
96
96
|
const plansFirst = (r: QueueRow) => !!(r.plan || r.plan_only);
|
|
97
97
|
|
|
98
|
+
// Planning needs no simulator or Xcode, and a need read from the files or the
|
|
99
|
+
// brief doesn't hold it. Only an explicit Mac pin does: the work is Mac-only
|
|
100
|
+
// (often because the other machine can't reach what it touches).
|
|
101
|
+
const planNeeds = (r: QueueRow, needs: Need[]): Need[] => {
|
|
102
|
+
const project = getProject(r.project_id);
|
|
103
|
+
return project &&
|
|
104
|
+
needs.includes("mac") &&
|
|
105
|
+
pinnedToMac(r.machine_needs, expandHome(project.working_directory))
|
|
106
|
+
? ["mac"]
|
|
107
|
+
: [];
|
|
108
|
+
};
|
|
109
|
+
|
|
98
110
|
// Marks each start the home machine's load rules out as remote and returns
|
|
99
111
|
// where it goes. "stale": the line moved while the machines were asked, so
|
|
100
112
|
// this look is over, and the one a change during it asks for sees the line
|
|
@@ -143,7 +155,7 @@ export async function placeCandidates(
|
|
|
143
155
|
asked(c),
|
|
144
156
|
home,
|
|
145
157
|
options,
|
|
146
|
-
plan ? [] : (needsOf.get(c.id) ?? []),
|
|
158
|
+
plan ? planNeeds(r, needsOf.get(c.id) ?? []) : (needsOf.get(c.id) ?? []),
|
|
147
159
|
now
|
|
148
160
|
);
|
|
149
161
|
if (!spot) continue;
|
package/lib/welcome-url.ts
CHANGED
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
package/stores/chatJump.ts
CHANGED
|
@@ -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(
|
|
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
|
}
|