@saadnvd1/agent-os 0.3.777 → 0.3.779
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/live-shares/[id]/route.ts +3 -0
- package/app/api/live-shares/route.test.ts +15 -0
- package/app/api/live-shares/route.ts +14 -3
- package/components/Live/InviteDialog.tsx +44 -13
- package/components/Live/LiveChip.tsx +34 -13
- package/components/Live/LivePanel.tsx +8 -8
- package/components/Live/LobbyCard.tsx +18 -21
- package/components/ui/segmented.tsx +2 -1
- package/data/live/queries.ts +22 -4
- package/lib/connect/control-api.ts +6 -2
- package/lib/db/migrations/20261030T1200_live_share_level.ts +19 -0
- package/lib/live/inbox.ts +8 -0
- package/lib/live/levels.ts +29 -0
- package/lib/live/publisher.ts +10 -0
- package/lib/live/service.ts +52 -4
- package/lib/live/store.ts +21 -3
- package/lib/live/types.ts +43 -0
- package/lib/orchestrator/describe.ts +26 -2
- package/lib/orchestrator/facts.ts +4 -0
- package/lib/orchestrator/tools.ts +1 -1
- package/lib/program-status/store.ts +2 -1
- package/lib/session-activity.ts +5 -3
- package/lib/share/connect.ts +1 -0
- package/lib/sidebar/shelves.ts +4 -1
- package/lib/status/collect.ts +2 -1
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
4
4
|
import {
|
|
5
5
|
admit,
|
|
6
|
+
changeLevel,
|
|
6
7
|
deny,
|
|
7
8
|
dismissSuggestion,
|
|
8
9
|
endLive,
|
|
@@ -15,6 +16,7 @@ const Action = z.discriminatedUnion("action", [
|
|
|
15
16
|
z.object({ action: z.literal("admit"), guestId: z.string().min(1).max(64) }),
|
|
16
17
|
z.object({ action: z.literal("deny"), guestId: z.string().min(1).max(64) }),
|
|
17
18
|
z.object({ action: z.literal("kick"), guestId: z.string().min(1).max(64) }),
|
|
19
|
+
z.object({ action: z.literal("level"), level: z.enum(["watch", "suggest"]) }),
|
|
18
20
|
z.object({
|
|
19
21
|
action: z.literal("send"),
|
|
20
22
|
suggestionId: z.string().min(1).max(64),
|
|
@@ -45,6 +47,7 @@ export async function POST(
|
|
|
45
47
|
if (a.action === "admit") admit(id, a.guestId);
|
|
46
48
|
else if (a.action === "deny") deny(id, a.guestId);
|
|
47
49
|
else if (a.action === "kick") kick(id, a.guestId);
|
|
50
|
+
else if (a.action === "level") changeLevel(id, a.level);
|
|
48
51
|
else if (a.action === "send")
|
|
49
52
|
await sendSuggestion(id, a.suggestionId, a.text);
|
|
50
53
|
else dismissSuggestion(id, a.suggestionId);
|
|
@@ -42,4 +42,19 @@ describe("live-shares routes", () => {
|
|
|
42
42
|
400
|
|
43
43
|
);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("accept watch and suggest only, and history as a boolean", async () => {
|
|
47
|
+
expect((await POST(req({ sessionId: "s", level: "drive" }))).status).toBe(
|
|
48
|
+
400
|
|
49
|
+
);
|
|
50
|
+
expect((await POST(req({ sessionId: "s", level: "root" }))).status).toBe(
|
|
51
|
+
400
|
|
52
|
+
);
|
|
53
|
+
expect((await POST(req({ sessionId: "s", history: "yes" }))).status).toBe(
|
|
54
|
+
400
|
|
55
|
+
);
|
|
56
|
+
expect(
|
|
57
|
+
(await act(req({ action: "level", level: "drive" }), ctx)).status
|
|
58
|
+
).toBe(400);
|
|
59
|
+
});
|
|
45
60
|
});
|
|
@@ -2,11 +2,13 @@ import { NextResponse } from "next/server";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { linked, NOT_LINKED } from "@/lib/share/connect";
|
|
4
4
|
import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
|
|
5
|
-
import { createLive, listLive, startLive } from "@/lib/live/service";
|
|
5
|
+
import { createLive, inviteFor, listLive, startLive } from "@/lib/live/service";
|
|
6
6
|
|
|
7
7
|
const Create = z.object({
|
|
8
8
|
sessionId: z.string().min(1).max(100),
|
|
9
9
|
expiresIn: z.enum(["1h", "session"]).default("1h"),
|
|
10
|
+
level: z.enum(["watch", "suggest"]).default("suggest"),
|
|
11
|
+
history: z.boolean().default(true),
|
|
10
12
|
});
|
|
11
13
|
|
|
12
14
|
// POST /api/live-shares - mint a live link for one chat and start publishing.
|
|
@@ -19,7 +21,12 @@ export async function POST(request: Request) {
|
|
|
19
21
|
if (!body.success) return fail("sessionId is required.", 400);
|
|
20
22
|
if (!linked()) return fail(NOT_LINKED, 409);
|
|
21
23
|
try {
|
|
22
|
-
const view = await createLive(
|
|
24
|
+
const view = await createLive(
|
|
25
|
+
body.data.sessionId,
|
|
26
|
+
body.data.expiresIn,
|
|
27
|
+
body.data.level,
|
|
28
|
+
body.data.history
|
|
29
|
+
);
|
|
23
30
|
return NextResponse.json(view, { status: 201 });
|
|
24
31
|
} catch (err) {
|
|
25
32
|
if ((err as Error).message === "Session not found")
|
|
@@ -36,5 +43,9 @@ export async function GET(request: Request) {
|
|
|
36
43
|
startLive();
|
|
37
44
|
const sessionId =
|
|
38
45
|
new URL(request.url).searchParams.get("sessionId") ?? undefined;
|
|
39
|
-
return NextResponse.json({
|
|
46
|
+
return NextResponse.json({
|
|
47
|
+
linked: linked(),
|
|
48
|
+
shares: listLive(sessionId),
|
|
49
|
+
...(sessionId ? { invite: inviteFor(sessionId) } : {}),
|
|
50
|
+
});
|
|
40
51
|
}
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
useEndLive,
|
|
20
20
|
useLiveShare,
|
|
21
21
|
type LiveExpiry,
|
|
22
|
+
type LiveInvite,
|
|
23
|
+
type LiveLevel,
|
|
22
24
|
type LiveView,
|
|
23
25
|
} from "@/data/live";
|
|
24
26
|
import { EXPIRY_LABEL } from "@/lib/live/types";
|
|
@@ -47,8 +49,8 @@ function InviteBody({ sessionId }: { sessionId: string }) {
|
|
|
47
49
|
<DialogTitle>Invite to watch live</DialogTitle>
|
|
48
50
|
<DialogDescription>
|
|
49
51
|
A guest opens the link, types a name and waits for you to let them in.
|
|
50
|
-
They see this chat as it happens, with secrets masked
|
|
51
|
-
|
|
52
|
+
They see this chat as it happens, with secrets masked. You choose what
|
|
53
|
+
else they can do.
|
|
52
54
|
</DialogDescription>
|
|
53
55
|
</div>
|
|
54
56
|
{query.isPending ? (
|
|
@@ -60,15 +62,26 @@ function InviteBody({ sessionId }: { sessionId: string }) {
|
|
|
60
62
|
) : share ? (
|
|
61
63
|
<Link share={share} />
|
|
62
64
|
) : (
|
|
63
|
-
<Create sessionId={sessionId} />
|
|
65
|
+
<Create sessionId={sessionId} invite={query.data.invite} />
|
|
64
66
|
)}
|
|
65
67
|
</DialogContent>
|
|
66
68
|
);
|
|
67
69
|
}
|
|
68
70
|
|
|
69
|
-
function Create({
|
|
71
|
+
function Create({
|
|
72
|
+
sessionId,
|
|
73
|
+
invite,
|
|
74
|
+
}: {
|
|
75
|
+
sessionId: string;
|
|
76
|
+
invite?: LiveInvite;
|
|
77
|
+
}) {
|
|
70
78
|
const [expiresIn, setExpiresIn] = useState<LiveExpiry>("1h");
|
|
79
|
+
const [level, setLevel] = useState<LiveLevel>("suggest");
|
|
80
|
+
const [history, setHistory] = useState<boolean | null>(null);
|
|
71
81
|
const create = useCreateLive();
|
|
82
|
+
const options = invite?.levels ?? [];
|
|
83
|
+
const chosen = options.find((o) => o.value === level);
|
|
84
|
+
const withHistory = history ?? invite?.history.default ?? true;
|
|
72
85
|
return (
|
|
73
86
|
<>
|
|
74
87
|
<div className="flex flex-col gap-1.5">
|
|
@@ -77,18 +90,34 @@ function Create({ sessionId }: { sessionId: string }) {
|
|
|
77
90
|
</span>
|
|
78
91
|
<Segmented
|
|
79
92
|
label="Access"
|
|
80
|
-
value=
|
|
81
|
-
onChange={
|
|
82
|
-
options={
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
93
|
+
value={level}
|
|
94
|
+
onChange={setLevel}
|
|
95
|
+
options={options.map((o) => ({
|
|
96
|
+
value: o.value,
|
|
97
|
+
label: o.label,
|
|
98
|
+
hint: o.reason ?? o.hint,
|
|
99
|
+
disabled: !o.enabled,
|
|
100
|
+
}))}
|
|
87
101
|
/>
|
|
88
102
|
<p className="text-muted-foreground text-xs">
|
|
89
|
-
|
|
103
|
+
{chosen?.hint}
|
|
104
|
+
{options
|
|
105
|
+
.filter((o) => !o.enabled && o.reason)
|
|
106
|
+
.map((o) => ` ${o.label}: ${o.reason}.`)
|
|
107
|
+
.join("")}
|
|
90
108
|
</p>
|
|
91
109
|
</div>
|
|
110
|
+
{invite && (
|
|
111
|
+
<label className="flex min-h-11 items-center gap-3 text-sm">
|
|
112
|
+
<input
|
|
113
|
+
type="checkbox"
|
|
114
|
+
className="size-5"
|
|
115
|
+
checked={withHistory}
|
|
116
|
+
onChange={(e) => setHistory(e.target.checked)}
|
|
117
|
+
/>
|
|
118
|
+
{invite.history.label}
|
|
119
|
+
</label>
|
|
120
|
+
)}
|
|
92
121
|
<div className="flex flex-col gap-1.5">
|
|
93
122
|
<span className="text-muted-foreground text-xs font-medium">
|
|
94
123
|
The link works for
|
|
@@ -109,7 +138,9 @@ function Create({ sessionId }: { sessionId: string }) {
|
|
|
109
138
|
<Button
|
|
110
139
|
className="min-h-11"
|
|
111
140
|
disabled={create.isPending}
|
|
112
|
-
onClick={() =>
|
|
141
|
+
onClick={() =>
|
|
142
|
+
create.mutate({ sessionId, expiresIn, level, history: withHistory })
|
|
143
|
+
}
|
|
113
144
|
>
|
|
114
145
|
<Users className="h-4 w-4" />
|
|
115
146
|
{create.isPending ? "Creating…" : "Create invite link"}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { ChevronDown, Radio, UserMinus } from "lucide-react";
|
|
5
5
|
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Segmented } from "@/components/ui/segmented";
|
|
6
7
|
import { StatusPill } from "@/components/ui/status-pill";
|
|
7
|
-
import type { LiveView } from "@/data/live";
|
|
8
|
+
import type { LiveLevel, LiveView } from "@/data/live";
|
|
8
9
|
import { cn } from "@/lib/utils";
|
|
9
10
|
|
|
10
11
|
export function presenceText(guests: LiveView["guests"]): string {
|
|
@@ -18,13 +19,15 @@ export function presenceText(guests: LiveView["guests"]): string {
|
|
|
18
19
|
export function LiveChip({
|
|
19
20
|
share,
|
|
20
21
|
busy,
|
|
21
|
-
|
|
22
|
+
onAction,
|
|
23
|
+
onLevel,
|
|
22
24
|
onEnd,
|
|
23
25
|
onInvite,
|
|
24
26
|
}: {
|
|
25
27
|
share: LiveView;
|
|
26
28
|
busy: boolean;
|
|
27
|
-
|
|
29
|
+
onAction: (guestId: string, kind: string) => void;
|
|
30
|
+
onLevel: (level: LiveLevel) => void;
|
|
28
31
|
onEnd: () => void;
|
|
29
32
|
onInvite: () => void;
|
|
30
33
|
}) {
|
|
@@ -58,6 +61,21 @@ export function LiveChip({
|
|
|
58
61
|
{share.notice && (
|
|
59
62
|
<p className="text-muted-foreground px-2 text-xs">{share.notice}</p>
|
|
60
63
|
)}
|
|
64
|
+
<Segmented
|
|
65
|
+
label="Access"
|
|
66
|
+
value={share.level}
|
|
67
|
+
onChange={onLevel}
|
|
68
|
+
disabled={busy}
|
|
69
|
+
options={share.levels.map((o) => ({
|
|
70
|
+
value: o.value,
|
|
71
|
+
label: o.label,
|
|
72
|
+
hint: o.reason ?? o.hint,
|
|
73
|
+
disabled: !o.enabled,
|
|
74
|
+
}))}
|
|
75
|
+
/>
|
|
76
|
+
<p className="text-muted-foreground px-2 text-xs">
|
|
77
|
+
{share.levels.find((o) => o.value === share.level)?.hint}
|
|
78
|
+
</p>
|
|
61
79
|
{admitted.length === 0 && (
|
|
62
80
|
<p className="text-muted-foreground px-2 py-1 text-xs">
|
|
63
81
|
No one has joined yet.
|
|
@@ -73,16 +91,19 @@ export function LiveChip({
|
|
|
73
91
|
aria-hidden
|
|
74
92
|
/>
|
|
75
93
|
<span className="min-w-0 flex-1 truncate text-sm">{g.name}</span>
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
94
|
+
{g.actions.map((a) => (
|
|
95
|
+
<Button
|
|
96
|
+
key={a.kind}
|
|
97
|
+
variant="ghost"
|
|
98
|
+
className="min-h-11"
|
|
99
|
+
disabled={busy}
|
|
100
|
+
onClick={() => onAction(g.id, a.kind)}
|
|
101
|
+
aria-label={`${a.label} ${g.name}`}
|
|
102
|
+
>
|
|
103
|
+
{a.kind === "kick" && <UserMinus className="h-4 w-4" />}
|
|
104
|
+
{a.label}
|
|
105
|
+
</Button>
|
|
106
|
+
))}
|
|
86
107
|
</div>
|
|
87
108
|
))}
|
|
88
109
|
<div className="flex gap-2 pt-1">
|
|
@@ -25,14 +25,19 @@ export function LivePanel({
|
|
|
25
25
|
if (!share) return null;
|
|
26
26
|
const busy = act.isPending || end.isPending;
|
|
27
27
|
const fail = (e: Error) => toast.error(e.message);
|
|
28
|
+
const doAction = (guestId: string, kind: string) => {
|
|
29
|
+
if (kind === "admit" || kind === "deny" || kind === "kick")
|
|
30
|
+
act.mutate({ action: kind, guestId }, { onError: fail });
|
|
31
|
+
};
|
|
28
32
|
const lobby = share.guests.filter((g) => g.status === "pending");
|
|
29
33
|
return (
|
|
30
34
|
<div className="mx-auto flex w-full max-w-3xl flex-col gap-2 px-3 pb-2">
|
|
31
35
|
<LiveChip
|
|
32
36
|
share={share}
|
|
33
37
|
busy={busy}
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
onAction={doAction}
|
|
39
|
+
onLevel={(level) =>
|
|
40
|
+
act.mutate({ action: "level", level }, { onError: fail })
|
|
36
41
|
}
|
|
37
42
|
onEnd={() => end.mutate(undefined, { onError: fail })}
|
|
38
43
|
onInvite={() => liveUiActions.open(sessionId, title)}
|
|
@@ -42,12 +47,7 @@ export function LivePanel({
|
|
|
42
47
|
key={g.id}
|
|
43
48
|
guest={g}
|
|
44
49
|
busy={busy}
|
|
45
|
-
|
|
46
|
-
act.mutate({ action: "admit", guestId: g.id }, { onError: fail })
|
|
47
|
-
}
|
|
48
|
-
onDeny={() =>
|
|
49
|
-
act.mutate({ action: "deny", guestId: g.id }, { onError: fail })
|
|
50
|
-
}
|
|
50
|
+
onAction={(kind) => doAction(g.id, kind)}
|
|
51
51
|
/>
|
|
52
52
|
))}
|
|
53
53
|
{share.suggestions.map((s) => (
|
|
@@ -10,13 +10,11 @@ type Guest = LiveView["guests"][number];
|
|
|
10
10
|
export function LobbyCard({
|
|
11
11
|
guest,
|
|
12
12
|
busy,
|
|
13
|
-
|
|
14
|
-
onDeny,
|
|
13
|
+
onAction,
|
|
15
14
|
}: {
|
|
16
15
|
guest: Guest;
|
|
17
16
|
busy: boolean;
|
|
18
|
-
|
|
19
|
-
onDeny: () => void;
|
|
17
|
+
onAction: (kind: string) => void;
|
|
20
18
|
}) {
|
|
21
19
|
return (
|
|
22
20
|
<div
|
|
@@ -41,23 +39,22 @@ export function LobbyCard({
|
|
|
41
39
|
</div>
|
|
42
40
|
</div>
|
|
43
41
|
<div className="flex gap-2">
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
</Button>
|
|
42
|
+
{guest.actions.map((a, i) => (
|
|
43
|
+
<Button
|
|
44
|
+
key={a.kind}
|
|
45
|
+
variant={i === 0 ? "default" : "outline"}
|
|
46
|
+
className="min-h-11 flex-1 sm:flex-none"
|
|
47
|
+
disabled={busy}
|
|
48
|
+
onClick={() => onAction(a.kind)}
|
|
49
|
+
>
|
|
50
|
+
{a.kind === "admit" ? (
|
|
51
|
+
<Check className="h-4 w-4" />
|
|
52
|
+
) : a.kind === "deny" ? (
|
|
53
|
+
<X className="h-4 w-4" />
|
|
54
|
+
) : null}
|
|
55
|
+
{a.label}
|
|
56
|
+
</Button>
|
|
57
|
+
))}
|
|
61
58
|
</div>
|
|
62
59
|
</div>
|
|
63
60
|
);
|
|
@@ -6,6 +6,7 @@ export interface SegmentedOption<T extends string> {
|
|
|
6
6
|
value: T;
|
|
7
7
|
label: string;
|
|
8
8
|
hint?: string;
|
|
9
|
+
disabled?: boolean;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
/** One choice out of a few, side by side. Radio semantics, 44px targets. */
|
|
@@ -37,7 +38,7 @@ export function Segmented<T extends string>({
|
|
|
37
38
|
role="radio"
|
|
38
39
|
aria-checked={on}
|
|
39
40
|
title={o.hint}
|
|
40
|
-
disabled={disabled}
|
|
41
|
+
disabled={disabled || o.disabled}
|
|
41
42
|
onClick={() => !on && onChange(o.value)}
|
|
42
43
|
className={cn(
|
|
43
44
|
"min-h-11 rounded-md px-2 text-sm font-medium transition-colors disabled:opacity-50",
|
package/data/live/queries.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
LiveExpiry,
|
|
4
|
+
LiveInvite,
|
|
5
|
+
LiveLevel,
|
|
6
|
+
LiveResponse,
|
|
7
|
+
LiveView,
|
|
8
|
+
} from "@/lib/live/types";
|
|
3
9
|
import { liveKeys } from "./keys";
|
|
4
10
|
|
|
5
|
-
export type {
|
|
11
|
+
export type {
|
|
12
|
+
LiveExpiry,
|
|
13
|
+
LiveInvite,
|
|
14
|
+
LiveLevel,
|
|
15
|
+
LiveResponse,
|
|
16
|
+
LiveView,
|
|
17
|
+
} from "@/lib/live/types";
|
|
6
18
|
|
|
7
19
|
async function json<T>(res: Response): Promise<T> {
|
|
8
20
|
const data = await res.json().catch(() => ({}));
|
|
@@ -31,6 +43,7 @@ export function useLiveShare(sessionId: string | undefined, enabled = true) {
|
|
|
31
43
|
select: (r) => ({
|
|
32
44
|
linked: r.linked,
|
|
33
45
|
share: r.shares[0] as LiveView | undefined,
|
|
46
|
+
invite: r.invite as LiveInvite | undefined,
|
|
34
47
|
}),
|
|
35
48
|
refetchInterval: (q) => (q.state.data?.shares.length ? 2000 : 60_000),
|
|
36
49
|
});
|
|
@@ -39,14 +52,19 @@ export function useLiveShare(sessionId: string | undefined, enabled = true) {
|
|
|
39
52
|
export function useCreateLive() {
|
|
40
53
|
const qc = useQueryClient();
|
|
41
54
|
return useMutation({
|
|
42
|
-
mutationFn: async (req: {
|
|
43
|
-
|
|
55
|
+
mutationFn: async (req: {
|
|
56
|
+
sessionId: string;
|
|
57
|
+
expiresIn: LiveExpiry;
|
|
58
|
+
level: LiveLevel;
|
|
59
|
+
history: boolean;
|
|
60
|
+
}) => json<LiveView>(await send("/api/live-shares", "POST", req)),
|
|
44
61
|
onSuccess: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
|
|
45
62
|
});
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
export type LiveAction =
|
|
49
66
|
| { action: "admit" | "deny" | "kick"; guestId: string }
|
|
67
|
+
| { action: "level"; level: LiveLevel }
|
|
50
68
|
| { action: "send"; suggestionId: string; text?: string }
|
|
51
69
|
| { action: "dismiss"; suggestionId: string };
|
|
52
70
|
|
|
@@ -488,12 +488,16 @@ export interface LiveShare {
|
|
|
488
488
|
export async function createLiveShare(
|
|
489
489
|
config: ConnectConfig,
|
|
490
490
|
privateKey: string,
|
|
491
|
-
share: { title: string; expiresIn: LiveExpiry }
|
|
491
|
+
share: { title: string; expiresIn: LiveExpiry; access?: string }
|
|
492
492
|
): Promise<LiveShare> {
|
|
493
493
|
const res = await signed(config, privateKey)(
|
|
494
494
|
"POST",
|
|
495
495
|
`/api/v1/machines/${config.machineId}/live-shares`,
|
|
496
|
-
{
|
|
496
|
+
{
|
|
497
|
+
title: share.title,
|
|
498
|
+
expires_in: share.expiresIn,
|
|
499
|
+
...(share.access ? { access: share.access } : {}),
|
|
500
|
+
}
|
|
497
501
|
);
|
|
498
502
|
const r = (await res.json().catch(() => null)) as Record<
|
|
499
503
|
string,
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261030T1200_live_share_level",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
const cols = (
|
|
7
|
+
db.prepare(`PRAGMA table_info(live_shares)`).all() as { name: string }[]
|
|
8
|
+
).map((c) => c.name);
|
|
9
|
+
// Existing links keep what they always did: suggest, with the whole chat.
|
|
10
|
+
if (!cols.includes("level"))
|
|
11
|
+
db.exec(
|
|
12
|
+
`ALTER TABLE live_shares ADD COLUMN level TEXT NOT NULL DEFAULT 'suggest'`
|
|
13
|
+
);
|
|
14
|
+
if (!cols.includes("history"))
|
|
15
|
+
db.exec(
|
|
16
|
+
`ALTER TABLE live_shares ADD COLUMN history INTEGER NOT NULL DEFAULT 1`
|
|
17
|
+
);
|
|
18
|
+
},
|
|
19
|
+
} satisfies Migration;
|
package/lib/live/inbox.ts
CHANGED
|
@@ -27,6 +27,7 @@ export const DOWNWARD = [
|
|
|
27
27
|
"slow_down",
|
|
28
28
|
"invalid",
|
|
29
29
|
"admit_failed",
|
|
30
|
+
"access_failed",
|
|
30
31
|
] as const;
|
|
31
32
|
|
|
32
33
|
const id = z.string().min(1).max(64);
|
|
@@ -44,6 +45,7 @@ const SCHEMAS = {
|
|
|
44
45
|
slow_down: z.object({}),
|
|
45
46
|
invalid: z.object({}),
|
|
46
47
|
admit_failed: z.object({ guest_id: id.optional(), reason: z.string() }),
|
|
48
|
+
access_failed: z.object({ reason: z.string().max(200).optional() }),
|
|
47
49
|
} satisfies Record<(typeof DOWNWARD)[number], z.ZodType>;
|
|
48
50
|
|
|
49
51
|
export interface InboxEffects {
|
|
@@ -143,6 +145,9 @@ export function handleFrame(
|
|
|
143
145
|
}
|
|
144
146
|
case "suggestion": {
|
|
145
147
|
const s = parsed.data as z.infer<typeof SCHEMAS.suggestion>;
|
|
148
|
+
// Watch guests can't suggest. Connect refuses first; this is the machine's own check.
|
|
149
|
+
if (rt.share.level === "watch")
|
|
150
|
+
return (drop("suggestion on a watch-only share"), false);
|
|
146
151
|
const known = rt.guests.get(s.guest.id);
|
|
147
152
|
// Only a guest this machine let in can leave a card.
|
|
148
153
|
if (!known || known.status !== "admitted")
|
|
@@ -196,6 +201,9 @@ export function handleFrame(
|
|
|
196
201
|
rt.notice = "Your plan's guest limit is reached.";
|
|
197
202
|
return true;
|
|
198
203
|
}
|
|
204
|
+
case "access_failed":
|
|
205
|
+
rt.notice = "Connect refused that access level.";
|
|
206
|
+
return true;
|
|
199
207
|
case "invalid":
|
|
200
208
|
rt.drops++;
|
|
201
209
|
return true;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LEVELS,
|
|
3
|
+
type GuestAction,
|
|
4
|
+
type LevelOption,
|
|
5
|
+
type LiveLevel,
|
|
6
|
+
} from "./types";
|
|
7
|
+
import type { Guest, Runtime } from "./runtime";
|
|
8
|
+
|
|
9
|
+
const ORDER: LiveLevel[] = ["watch", "suggest", "drive"];
|
|
10
|
+
|
|
11
|
+
/** What the invite dialog and the Live popover offer, with why one is off. */
|
|
12
|
+
export function levelOptions(): LevelOption[] {
|
|
13
|
+
return ORDER.map((value) => ({
|
|
14
|
+
value,
|
|
15
|
+
...LEVELS[value],
|
|
16
|
+
...(value === "drive"
|
|
17
|
+
? { enabled: false, reason: "Coming soon" }
|
|
18
|
+
: { enabled: true }),
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function guestActions(_rt: Runtime, g: Guest): GuestAction[] {
|
|
23
|
+
return g.status === "pending"
|
|
24
|
+
? [
|
|
25
|
+
{ kind: "admit", label: "Admit" },
|
|
26
|
+
{ kind: "deny", label: "Deny" },
|
|
27
|
+
]
|
|
28
|
+
: [{ kind: "kick", label: "Kick" }];
|
|
29
|
+
}
|
package/lib/live/publisher.ts
CHANGED
|
@@ -13,8 +13,17 @@ import { live, type Runtime } from "./runtime";
|
|
|
13
13
|
const FLUSH_MS = 1000;
|
|
14
14
|
const SLOW_FLUSH_MS = 4000;
|
|
15
15
|
|
|
16
|
+
/** With "earlier messages" off, nothing from before the link was made goes out. */
|
|
17
|
+
function beforeLink(rt: Runtime, item: ChatItem): boolean {
|
|
18
|
+
return (
|
|
19
|
+
rt.share.history === 0 &&
|
|
20
|
+
(item.createdAt ?? Infinity) < Date.parse(rt.share.created_at)
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
export function seed(rt: Runtime, sessionId: string): void {
|
|
17
25
|
for (const item of visible(listItems(sessionId))) {
|
|
26
|
+
if (beforeLink(rt, item)) continue;
|
|
18
27
|
const frame = rt.map(item);
|
|
19
28
|
if (frame) rt.ring.upsert(frame);
|
|
20
29
|
}
|
|
@@ -55,6 +64,7 @@ export function onMessage(sessionId: string, m: ChatServerMessage): void {
|
|
|
55
64
|
live.socket?.send(rt.share.id, "state", { state: stateOf(m.state) });
|
|
56
65
|
} else if (m.type === "item") {
|
|
57
66
|
const item = m.item;
|
|
67
|
+
if (beforeLink(rt, item)) continue;
|
|
58
68
|
if (item.kind === "turn_end") {
|
|
59
69
|
flush(rt);
|
|
60
70
|
live.socket?.send(rt.share.id, "state", { state: "idle" });
|
package/lib/live/service.ts
CHANGED
|
@@ -11,12 +11,20 @@ import { shareContext } from "../share/context";
|
|
|
11
11
|
import { redactForShare } from "../share/redact";
|
|
12
12
|
import { ShareError, createLiveRemote, revokeRemote } from "../share/connect";
|
|
13
13
|
import type { LiveExpiry } from "../connect/control-api";
|
|
14
|
-
import
|
|
14
|
+
import {
|
|
15
|
+
LEVELS,
|
|
16
|
+
type LiveInvite,
|
|
17
|
+
type LiveLevel,
|
|
18
|
+
type LiveView,
|
|
19
|
+
} from "./types";
|
|
20
|
+
import { guestActions, levelOptions } from "./levels";
|
|
21
|
+
import { listItems } from "../chat/store";
|
|
22
|
+
import { visible } from "../share/snapshot";
|
|
15
23
|
import { cleanBody } from "./clean";
|
|
16
24
|
import { itemMapper } from "./frames";
|
|
17
25
|
import { handleFrame } from "./inbox";
|
|
18
26
|
import { onMessage, seed, flush } from "./publisher";
|
|
19
|
-
import { Ring } from "./ring";
|
|
27
|
+
import { RING_ITEMS, Ring } from "./ring";
|
|
20
28
|
import { LOBBY_MS, live, type Guest, type Runtime } from "./runtime";
|
|
21
29
|
import { LiveSocket, type WsFactory } from "./socket";
|
|
22
30
|
import {
|
|
@@ -27,6 +35,7 @@ import {
|
|
|
27
35
|
logLive,
|
|
28
36
|
markLiveEnded,
|
|
29
37
|
openSuggestions,
|
|
38
|
+
setLevel,
|
|
30
39
|
type LiveShareRow,
|
|
31
40
|
} from "./store";
|
|
32
41
|
|
|
@@ -126,7 +135,9 @@ function runtimeFor(id: string): Runtime {
|
|
|
126
135
|
|
|
127
136
|
export async function createLive(
|
|
128
137
|
sessionId: string,
|
|
129
|
-
expiresIn: LiveExpiry
|
|
138
|
+
expiresIn: LiveExpiry,
|
|
139
|
+
level: LiveLevel = "suggest",
|
|
140
|
+
history = true
|
|
130
141
|
): Promise<LiveView> {
|
|
131
142
|
startLive();
|
|
132
143
|
const session = getSession(sessionId);
|
|
@@ -138,7 +149,7 @@ export async function createLive(
|
|
|
138
149
|
.text.replace(/\s+/g, " ")
|
|
139
150
|
.trim()
|
|
140
151
|
.slice(0, 120) || "Chat";
|
|
141
|
-
const made = await createLiveRemote({ title, expiresIn });
|
|
152
|
+
const made = await createLiveRemote({ title, expiresIn, access: level });
|
|
142
153
|
const row: LiveShareRow = {
|
|
143
154
|
id: made.id,
|
|
144
155
|
session_id: sessionId,
|
|
@@ -149,12 +160,45 @@ export async function createLive(
|
|
|
149
160
|
created_at: new Date().toISOString(),
|
|
150
161
|
expires_at: made.expires_at,
|
|
151
162
|
ended_at: null,
|
|
163
|
+
level,
|
|
164
|
+
history: history ? 1 : 0,
|
|
152
165
|
};
|
|
153
166
|
insertLiveShare(row);
|
|
154
167
|
attach(row);
|
|
155
168
|
return viewOf(row.id)!;
|
|
156
169
|
}
|
|
157
170
|
|
|
171
|
+
export function changeLevel(id: string, level: LiveLevel): void {
|
|
172
|
+
const rt = runtimeFor(id);
|
|
173
|
+
if (!(level in LEVELS)) throw new ShareError("Unknown level.", 400);
|
|
174
|
+
if (!levelOptions().find((o) => o.value === level)?.enabled)
|
|
175
|
+
throw new ShareError(`${LEVELS[level].label} isn't available.`, 400);
|
|
176
|
+
if (rt.share.level === level) return;
|
|
177
|
+
// Synchronous and first: from here the machine treats the share as `level`.
|
|
178
|
+
rt.share = { ...rt.share, level };
|
|
179
|
+
setLevel(id, level);
|
|
180
|
+
rt.notice = null;
|
|
181
|
+
live.socket?.send(id, "access", { level });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** What the invite dialog offers for a session. */
|
|
185
|
+
export function inviteFor(sessionId: string): LiveInvite {
|
|
186
|
+
const count = Math.min(
|
|
187
|
+
visible(listItems(sessionId)).filter(
|
|
188
|
+
(i) => i.kind === "user" || i.kind === "assistant"
|
|
189
|
+
).length,
|
|
190
|
+
RING_ITEMS
|
|
191
|
+
);
|
|
192
|
+
return {
|
|
193
|
+
levels: levelOptions(),
|
|
194
|
+
history: {
|
|
195
|
+
label: `Include the ${count} earlier message${count === 1 ? "" : "s"}`,
|
|
196
|
+
default: true,
|
|
197
|
+
count,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
158
202
|
/** Ends access: Connect disconnects every guest, then this machine lets go. */
|
|
159
203
|
export async function endLive(id: string): Promise<void> {
|
|
160
204
|
runtimeFor(id);
|
|
@@ -293,6 +337,9 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
|
|
|
293
337
|
maxGuests: rt.share.max_guests,
|
|
294
338
|
connected: live.socket?.connected ?? false,
|
|
295
339
|
notice: rt.notice,
|
|
340
|
+
level: rt.share.level,
|
|
341
|
+
levels: levelOptions(),
|
|
342
|
+
history: rt.share.history === 1,
|
|
296
343
|
guests: shown
|
|
297
344
|
.filter((g) => g.status === "pending" || admitted.has(g.id))
|
|
298
345
|
.map((g) => ({
|
|
@@ -302,6 +349,7 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
|
|
|
302
349
|
online: g.online,
|
|
303
350
|
duplicateName:
|
|
304
351
|
(names.get(g.name.normalize("NFKC").toLowerCase()) ?? 0) > 1,
|
|
352
|
+
actions: guestActions(rt, g),
|
|
305
353
|
})),
|
|
306
354
|
suggestions: openSuggestions(id),
|
|
307
355
|
};
|
package/lib/live/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { db } from "../db";
|
|
2
|
+
import type { LiveLevel } from "./types";
|
|
2
3
|
|
|
3
4
|
export interface LiveShareRow {
|
|
4
5
|
id: string;
|
|
@@ -10,6 +11,9 @@ export interface LiveShareRow {
|
|
|
10
11
|
created_at: string;
|
|
11
12
|
expires_at: string | null;
|
|
12
13
|
ended_at: string | null;
|
|
14
|
+
level: LiveLevel;
|
|
15
|
+
/** 1: guests get the chat from before the link was made. */
|
|
16
|
+
history: number;
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export type LogKind =
|
|
@@ -19,7 +23,8 @@ export type LogKind =
|
|
|
19
23
|
| "kick"
|
|
20
24
|
| "suggestion"
|
|
21
25
|
| "sent"
|
|
22
|
-
| "dismissed"
|
|
26
|
+
| "dismissed"
|
|
27
|
+
| "level";
|
|
23
28
|
|
|
24
29
|
export interface LogRow {
|
|
25
30
|
id: number;
|
|
@@ -36,8 +41,8 @@ export interface LogRow {
|
|
|
36
41
|
export function insertLiveShare(row: LiveShareRow): void {
|
|
37
42
|
db.prepare(
|
|
38
43
|
`INSERT INTO live_shares
|
|
39
|
-
(id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at)
|
|
40
|
-
VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at)`
|
|
44
|
+
(id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at, level, history)
|
|
45
|
+
VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at, @level, @history)`
|
|
41
46
|
).run(row);
|
|
42
47
|
}
|
|
43
48
|
|
|
@@ -70,6 +75,19 @@ export function markLiveEnded(id: string): void {
|
|
|
70
75
|
).run(new Date().toISOString(), id);
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
export function setLevel(id: string, level: LiveLevel): void {
|
|
79
|
+
db.prepare(`UPDATE live_shares SET level = ? WHERE id = ?`).run(level, id);
|
|
80
|
+
logLive({
|
|
81
|
+
share_id: id,
|
|
82
|
+
kind: "level",
|
|
83
|
+
guest_id: null,
|
|
84
|
+
guest_name: null,
|
|
85
|
+
ref: level,
|
|
86
|
+
text: null,
|
|
87
|
+
sent_text: null,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
73
91
|
export function logLive(
|
|
74
92
|
row: Omit<LogRow, "id" | "at"> & { at?: number }
|
|
75
93
|
): void {
|
package/lib/live/types.ts
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
+
export type LiveLevel = "watch" | "suggest" | "drive";
|
|
2
|
+
|
|
3
|
+
/** The single source for what each level is called and what it lets a guest do. */
|
|
4
|
+
export const LEVELS: Record<LiveLevel, { label: string; hint: string }> = {
|
|
5
|
+
watch: {
|
|
6
|
+
label: "Watch",
|
|
7
|
+
hint: "Guests only watch. They can't send you anything.",
|
|
8
|
+
},
|
|
9
|
+
suggest: {
|
|
10
|
+
label: "Suggest",
|
|
11
|
+
hint: "Guests can suggest a prompt. Nothing reaches the agent unless you tap Send.",
|
|
12
|
+
},
|
|
13
|
+
drive: {
|
|
14
|
+
label: "Drive",
|
|
15
|
+
hint: "Guests can make the agent read this task's files. Everything else waits for you.",
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface LevelOption {
|
|
20
|
+
value: LiveLevel;
|
|
21
|
+
label: string;
|
|
22
|
+
hint: string;
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
reason?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface GuestAction {
|
|
28
|
+
kind: "kick" | "admit" | "deny";
|
|
29
|
+
label: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
1
32
|
// What the host's screens show of a live share (served by /api/live-shares).
|
|
2
33
|
export interface LiveView {
|
|
3
34
|
id: string;
|
|
@@ -9,6 +40,10 @@ export interface LiveView {
|
|
|
9
40
|
maxGuests: number;
|
|
10
41
|
connected: boolean;
|
|
11
42
|
notice: string | null;
|
|
43
|
+
level: LiveLevel;
|
|
44
|
+
levels: LevelOption[];
|
|
45
|
+
/** Whether the guests got the chat from before the link was made. */
|
|
46
|
+
history: boolean;
|
|
12
47
|
guests: {
|
|
13
48
|
id: string;
|
|
14
49
|
name: string;
|
|
@@ -16,6 +51,7 @@ export interface LiveView {
|
|
|
16
51
|
online: boolean;
|
|
17
52
|
/** Another guest here has the same name. */
|
|
18
53
|
duplicateName: boolean;
|
|
54
|
+
actions: GuestAction[];
|
|
19
55
|
}[];
|
|
20
56
|
suggestions: {
|
|
21
57
|
id: string;
|
|
@@ -33,7 +69,14 @@ export const EXPIRY_LABEL: Record<LiveExpiry, string> = {
|
|
|
33
69
|
session: "Until the session ends",
|
|
34
70
|
};
|
|
35
71
|
|
|
72
|
+
export interface LiveInvite {
|
|
73
|
+
levels: LevelOption[];
|
|
74
|
+
history: { label: string; default: boolean; count: number };
|
|
75
|
+
}
|
|
76
|
+
|
|
36
77
|
export interface LiveResponse {
|
|
37
78
|
linked: boolean;
|
|
38
79
|
shares: LiveView[];
|
|
80
|
+
/** Only when the request named a session. */
|
|
81
|
+
invite?: LiveInvite;
|
|
39
82
|
}
|
|
@@ -64,15 +64,35 @@ function sessionLine(f: SessionFacts): string {
|
|
|
64
64
|
return parts.join(" | ");
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
const compactLine = (f: SessionFacts) =>
|
|
68
|
+
`${f.name} (${f.project ?? "no project"}, id ${shortId(f.id)})`;
|
|
69
|
+
|
|
70
|
+
// Nothing to say about it beyond that it is idle.
|
|
71
|
+
const plainIdle = (f: SessionFacts) =>
|
|
72
|
+
f.status === "idle" &&
|
|
73
|
+
!f.activity &&
|
|
74
|
+
!f.task &&
|
|
75
|
+
!f.plan &&
|
|
76
|
+
!f.group &&
|
|
77
|
+
!f.stack &&
|
|
78
|
+
!f.compacting &&
|
|
79
|
+
!f.waits?.on.length &&
|
|
80
|
+
!f.waits?.by.length;
|
|
81
|
+
|
|
82
|
+
// The sessions tool's answer: one line per live session, busiest news first;
|
|
83
|
+
// then the done and plain idle ones, compactly, so a done can be seen to have
|
|
84
|
+
// worked.
|
|
68
85
|
export function describeSessions(
|
|
69
86
|
workspace: string,
|
|
70
87
|
facts: SessionFacts[],
|
|
71
88
|
groups: readonly string[] = []
|
|
72
89
|
): string {
|
|
73
90
|
if (!facts.length) return `No sessions are running in ${workspace}.`;
|
|
91
|
+
const done = facts.filter((f) => f.done && f.status !== "running");
|
|
92
|
+
const idle = facts.filter((f) => !f.done && plainIdle(f));
|
|
93
|
+
const shown = facts.filter((f) => !done.includes(f) && !idle.includes(f));
|
|
74
94
|
const rank = { waiting: 0, running: 1, idle: 2, dead: 3 };
|
|
75
|
-
const sorted = [...
|
|
95
|
+
const sorted = [...shown].sort((a, b) => rank[a.status] - rank[b.status]);
|
|
76
96
|
const counts = (["running", "waiting"] as const)
|
|
77
97
|
.map((s) => {
|
|
78
98
|
const n = facts.filter((f) => f.status === s).length;
|
|
@@ -84,6 +104,10 @@ export function describeSessions(
|
|
|
84
104
|
return [
|
|
85
105
|
`${facts.length} ${noun} in ${workspace}${summary}:`,
|
|
86
106
|
...sorted.map(sessionLine),
|
|
107
|
+
...(idle.length ? [`Idle: ${idle.map(compactLine).join("; ")}.`] : []),
|
|
108
|
+
...(done.length
|
|
109
|
+
? [`Done (on Saad's Done shelf): ${done.map(compactLine).join("; ")}.`]
|
|
110
|
+
: []),
|
|
87
111
|
...(groups.length
|
|
88
112
|
? [`Saad's groups here: ${groups.map((g) => `"${g}"`).join(", ")}.`]
|
|
89
113
|
: []),
|
|
@@ -27,6 +27,7 @@ import { commitTime, repoOf } from "./repo";
|
|
|
27
27
|
import { CHECKLESS_MS, checklessFor, ciSettleIn } from "./task-state";
|
|
28
28
|
import { repoHasCI } from "./repo-ci";
|
|
29
29
|
import { markStatusDirty } from "../status/dirty";
|
|
30
|
+
import { settledHow } from "../sidebar/shelves";
|
|
30
31
|
|
|
31
32
|
export type FactStatus = "running" | "waiting" | "idle" | "dead";
|
|
32
33
|
|
|
@@ -46,6 +47,8 @@ export interface SessionFacts {
|
|
|
46
47
|
compacting?: boolean;
|
|
47
48
|
// Saad's group it sits in, when it is in one.
|
|
48
49
|
group?: string | null;
|
|
50
|
+
// On Saad's Done shelf: marked done (or quiet for days) and not woken since.
|
|
51
|
+
done?: boolean;
|
|
49
52
|
// Last sign of life, in ms.
|
|
50
53
|
lastActive: number;
|
|
51
54
|
// Open waits (aos send --await, aos wait): who it waits on, and who waits on it.
|
|
@@ -346,6 +349,7 @@ export async function sessionFacts(
|
|
|
346
349
|
compacting:
|
|
347
350
|
s.view === "chat" && !hostLink(s.host_id) && !!chatCompacting(s.id),
|
|
348
351
|
group: groups.get(s.session_group_id ?? "") ?? null,
|
|
352
|
+
done: settledHow(s, Date.now()) !== null,
|
|
349
353
|
lastActive: await lastActiveFor(s),
|
|
350
354
|
branch: s.branch_name,
|
|
351
355
|
quick: !!s.quick_of,
|
|
@@ -116,7 +116,7 @@ const answer = async (call: Promise<string>): Promise<CallToolResult> => {
|
|
|
116
116
|
|
|
117
117
|
const DESCRIPTIONS: Record<ToolName, string> = {
|
|
118
118
|
sessions:
|
|
119
|
-
"Every session in this workspace: project, view, status, what it's doing, task/PR/CI state and stack position; then external sessions (tmux sessions AgentOS didn't start, here or on other machines, working in a workspace repository), read-only, with their branch's PR.",
|
|
119
|
+
"Every session in this workspace: project, view, status, what it's doing, task/PR/CI state and stack position; plain idle and done sessions (on Saad's Done shelf) are listed compactly after them; then external sessions (tmux sessions AgentOS didn't start, here or on other machines, working in a workspace repository), read-only, with their branch's PR.",
|
|
120
120
|
read: "The end of one session's terminal or chat, or an external session's screen, as plain text (capped at about 4k tokens).",
|
|
121
121
|
cards: "Cards on this workspace's linked LumifyHub boards, by list.",
|
|
122
122
|
logs: "Session logs AgentOS wrote when tasks merged: the last N headers (task, PR, cost, duration) of the workspace or one project, or one log in full (brief, plan, timeline, usage, outcome). Read straight from the database; nothing is summarised.",
|
|
@@ -85,7 +85,8 @@ function commit(sessionName: string, next: Records): boolean {
|
|
|
85
85
|
if (same(before, after)) return false;
|
|
86
86
|
if (after && after.state !== "idle" && after.state !== before?.state)
|
|
87
87
|
db.prepare(
|
|
88
|
-
`UPDATE sessions SET updated_at = datetime('now'), active_at = datetime('now') WHERE tmux_name = ? OR id =
|
|
88
|
+
`UPDATE sessions SET updated_at = datetime('now'), active_at = datetime('now') WHERE (tmux_name = ? OR id = ?)
|
|
89
|
+
AND (settled_at IS NULL OR active_at > settled_at)`
|
|
89
90
|
).run(sessionName, getSessionIdFromName(sessionName));
|
|
90
91
|
return true;
|
|
91
92
|
}
|
package/lib/session-activity.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { getDb } from "./db";
|
|
2
2
|
|
|
3
3
|
// A session did something: a message, a run starting, a need. Written at most
|
|
4
|
-
// once a minute, unless it wakes a snooze
|
|
4
|
+
// once a minute, unless it wakes a snooze. Never for a settled session: what
|
|
5
|
+
// comes after Done (the stop's own events, a status refresh) isn't the work
|
|
6
|
+
// starting again. Only a prompt does that (the chat worker writes it).
|
|
5
7
|
export function touchActive(sessionId: string): void {
|
|
6
8
|
try {
|
|
7
9
|
getDb()
|
|
@@ -9,8 +11,8 @@ export function touchActive(sessionId: string): void {
|
|
|
9
11
|
`UPDATE sessions SET active_at = datetime('now')
|
|
10
12
|
WHERE id = ? AND (active_at IS NULL
|
|
11
13
|
OR active_at < datetime('now', '-60 seconds')
|
|
12
|
-
OR (snoozed_at IS NOT NULL AND active_at <= snoozed_at)
|
|
13
|
-
|
|
14
|
+
OR (snoozed_at IS NOT NULL AND active_at <= snoozed_at))
|
|
15
|
+
AND (settled_at IS NULL OR active_at > settled_at)`
|
|
14
16
|
)
|
|
15
17
|
.run(sessionId);
|
|
16
18
|
} catch (err) {
|
package/lib/share/connect.ts
CHANGED
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -220,7 +220,10 @@ export function settledHow(
|
|
|
220
220
|
now: number
|
|
221
221
|
): "manual" | "auto" | null {
|
|
222
222
|
const active = activeMs(session, now);
|
|
223
|
-
|
|
223
|
+
// A settled session's own row is the clock: updated_at moves with
|
|
224
|
+
// bookkeeping, so it never takes a done session off the shelf.
|
|
225
|
+
const own = session.peer_mirror || session.active_at ? active : 0;
|
|
226
|
+
if (session.settled_at && own <= ms(session.settled_at)) return "manual";
|
|
224
227
|
return now - active >= SETTLE_AFTER_MS ? "auto" : null;
|
|
225
228
|
}
|
|
226
229
|
|
package/lib/status/collect.ts
CHANGED
|
@@ -421,7 +421,8 @@ async function collect(full = false): Promise<StatusSnapshot> {
|
|
|
421
421
|
|
|
422
422
|
// Batch update sessions and claude_session_id in a single transaction
|
|
423
423
|
const updateStatusStmt = db.prepare(
|
|
424
|
-
|
|
424
|
+
`UPDATE sessions SET updated_at = datetime('now'), active_at = datetime('now')
|
|
425
|
+
WHERE id = ? AND (settled_at IS NULL OR active_at > settled_at)`
|
|
425
426
|
);
|
|
426
427
|
const updateClaudeIdStmt = db.prepare(
|
|
427
428
|
"UPDATE sessions SET claude_session_id = ? WHERE id = ? AND (claude_session_id IS NULL OR claude_session_id != ?)"
|