@saadnvd1/agent-os 0.3.547 → 0.3.549
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/bus/archive/route.ts +27 -0
- package/app/api/pair/claim/route.ts +8 -1
- package/app/api/sessions/[id]/archive/route.ts +17 -0
- package/components/SessionList/SessionRowMenu.tsx +34 -10
- package/components/SessionList/useDoneAction.ts +28 -4
- package/data/done/index.ts +6 -0
- package/lib/db/migrations.ts +16 -0
- package/lib/done/archive.ts +27 -1
- package/lib/done/auto-archive.ts +67 -0
- package/lib/done/bulk.ts +8 -4
- package/lib/done/index.ts +5 -3
- package/lib/done/plan.ts +3 -3
- package/lib/orchestrator/act.ts +13 -1
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/serve.ts +3 -0
- package/lib/orchestrator/shell-guard.ts +2 -1
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +1 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/security/approver.ts +2 -1
- package/lib/security/claim-response.ts +8 -2
- package/lib/security/devices.ts +8 -4
- package/lib/security/pairing.ts +7 -1
- package/lib/sidebar/row-actions.ts +15 -0
- package/lib/sidebar/shelves.ts +2 -0
- package/package.json +1 -1
- package/server.ts +3 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
2
|
+
import { resolveSession } from "@/lib/bus";
|
|
3
|
+
import { getDoneTarget } from "@/lib/done";
|
|
4
|
+
import { archiveChecked } from "@/lib/done/archive";
|
|
5
|
+
import { inScope, scopeOf } from "@/lib/done/bulk";
|
|
6
|
+
|
|
7
|
+
// `aos archive <session>`, from inside a session: reaches only the caller's
|
|
8
|
+
// workspace (or its project when it has none).
|
|
9
|
+
export async function POST(request: NextRequest) {
|
|
10
|
+
try {
|
|
11
|
+
const { from = null, session } = await request.json();
|
|
12
|
+
if (typeof from !== "string" || !from)
|
|
13
|
+
throw new Error("aos archive runs from inside an AgentOS session");
|
|
14
|
+
const caller = getDoneTarget(from);
|
|
15
|
+
const scope = scopeOf(caller);
|
|
16
|
+
const target = resolveSession(String(session ?? ""));
|
|
17
|
+
if (!inScope(scope, target))
|
|
18
|
+
throw new Error(
|
|
19
|
+
`${target.name} isn't in ${caller.name}'s ${"workspaceId" in scope ? "workspace" : "project"}; aos archive reaches only those`
|
|
20
|
+
);
|
|
21
|
+
await archiveChecked(target.id);
|
|
22
|
+
return NextResponse.json({ summary: `Archived: ${target.name}.` });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
25
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -7,10 +7,12 @@ import { MAX_CLAIM_BYTES, readCapped } from "@/lib/security/read-capped";
|
|
|
7
7
|
import {
|
|
8
8
|
claimResponseBody,
|
|
9
9
|
claimSetsCookie,
|
|
10
|
+
isNativeClaim,
|
|
10
11
|
} from "@/lib/security/claim-response";
|
|
11
12
|
|
|
12
13
|
// POST /api/pair/claim - reachable without a token; trades a code for one.
|
|
13
|
-
// A client that isn't a browser sends `token: true` to get it in the body too
|
|
14
|
+
// A client that isn't a browser sends `token: true` to get it in the body too,
|
|
15
|
+
// and starts with Can approve: it held a one-time code, as a browser does.
|
|
14
16
|
export async function POST(request: NextRequest) {
|
|
15
17
|
const raw = await readCapped(request, MAX_CLAIM_BYTES);
|
|
16
18
|
if (raw === null)
|
|
@@ -23,6 +25,10 @@ export async function POST(request: NextRequest) {
|
|
|
23
25
|
}
|
|
24
26
|
if (!body.code)
|
|
25
27
|
return NextResponse.json({ error: "code required" }, { status: 400 });
|
|
28
|
+
const native = isNativeClaim({
|
|
29
|
+
wantsToken: body.token,
|
|
30
|
+
origin: request.headers.get("origin"),
|
|
31
|
+
});
|
|
26
32
|
const result = claimPairing({
|
|
27
33
|
code: body.code,
|
|
28
34
|
name: body.name ?? "",
|
|
@@ -31,6 +37,7 @@ export async function POST(request: NextRequest) {
|
|
|
31
37
|
request.headers.get(REMOTE_HEADER),
|
|
32
38
|
request.headers.get("x-forwarded-for")
|
|
33
39
|
),
|
|
40
|
+
native,
|
|
34
41
|
});
|
|
35
42
|
if (!result.ok) {
|
|
36
43
|
const status = result.error === "rate_limited" ? 429 : 400;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { archiveChecked } from "@/lib/done/archive";
|
|
3
|
+
|
|
4
|
+
// POST /api/sessions/:id/archive - put a session away, out of the sidebar.
|
|
5
|
+
// 409 with the reason when refused.
|
|
6
|
+
export async function POST(
|
|
7
|
+
_request: Request,
|
|
8
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
9
|
+
) {
|
|
10
|
+
try {
|
|
11
|
+
const session = await archiveChecked((await params).id);
|
|
12
|
+
return NextResponse.json({ session });
|
|
13
|
+
} catch (error) {
|
|
14
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
15
|
+
return NextResponse.json({ error: message }, { status: 409 });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -31,7 +31,8 @@ import { selectionActions } from "@/stores/sessionSelection";
|
|
|
31
31
|
import { schedulesUiActions } from "@/stores/schedulesUi";
|
|
32
32
|
import { recipesUiActions } from "@/stores/recipesUi";
|
|
33
33
|
import { rowGroup } from "@/lib/recipes/chip";
|
|
34
|
-
import {
|
|
34
|
+
import { finishActions } from "@/lib/sidebar/row-actions";
|
|
35
|
+
import { useArchiveAction, useDoneAction } from "./useDoneAction";
|
|
35
36
|
import { useShelfActions } from "./useShelfActions";
|
|
36
37
|
import { SNOOZE_PRESETS, snoozePreset, wakeLabel } from "@/lib/sidebar/snooze";
|
|
37
38
|
import type { SidebarRow } from "@/lib/sidebar/shelves";
|
|
@@ -84,6 +85,37 @@ function BlockedItem({ kind, icon: Icon, label, why }: BlockedProps) {
|
|
|
84
85
|
);
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
// Done and Archive, as the session's kind allows.
|
|
89
|
+
function FinishItems({
|
|
90
|
+
session,
|
|
91
|
+
plain,
|
|
92
|
+
Item,
|
|
93
|
+
}: {
|
|
94
|
+
session: Session;
|
|
95
|
+
plain: boolean;
|
|
96
|
+
Item: (typeof PARTS)[keyof typeof PARTS]["Item"];
|
|
97
|
+
}) {
|
|
98
|
+
const markDone = useDoneAction();
|
|
99
|
+
const archive = useArchiveAction();
|
|
100
|
+
const actions = finishActions(session, plain);
|
|
101
|
+
return (
|
|
102
|
+
<>
|
|
103
|
+
{actions.includes("done") && (
|
|
104
|
+
<Item onClick={() => markDone(session)}>
|
|
105
|
+
<CircleCheck className={icon} />
|
|
106
|
+
Done
|
|
107
|
+
</Item>
|
|
108
|
+
)}
|
|
109
|
+
{actions.includes("archive") && (
|
|
110
|
+
<Item onClick={() => archive(session)}>
|
|
111
|
+
<Archive className={icon} />
|
|
112
|
+
Archive
|
|
113
|
+
</Item>
|
|
114
|
+
)}
|
|
115
|
+
</>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
87
119
|
// The row's ⋯ menu (and right-click menu): everything a session can do.
|
|
88
120
|
export function SessionRowMenu({
|
|
89
121
|
session,
|
|
@@ -98,7 +130,6 @@ export function SessionRowMenu({
|
|
|
98
130
|
onRename: () => void;
|
|
99
131
|
}) {
|
|
100
132
|
const ctx = useRowContext();
|
|
101
|
-
const markDone = useDoneAction();
|
|
102
133
|
const shelf = useShelfActions();
|
|
103
134
|
const plain = !!row && !row.task && !session.task_status;
|
|
104
135
|
const { Item, Separator, Sub, SubTrigger, SubContent } = PARTS[kind];
|
|
@@ -342,14 +373,7 @@ export function SessionRowMenu({
|
|
|
342
373
|
Mark done
|
|
343
374
|
</Item>
|
|
344
375
|
))}
|
|
345
|
-
<
|
|
346
|
-
{plain ? (
|
|
347
|
-
<Archive className={icon} />
|
|
348
|
-
) : (
|
|
349
|
-
<CircleCheck className={icon} />
|
|
350
|
-
)}
|
|
351
|
-
{plain ? "Archive" : "Done"}
|
|
352
|
-
</Item>
|
|
376
|
+
<FinishItems session={session} plain={plain} Item={Item} />
|
|
353
377
|
<Item
|
|
354
378
|
onClick={() => ctx.onDelete(session.id)}
|
|
355
379
|
className="text-red-500 focus:text-red-500"
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
import { toast } from "sonner";
|
|
2
2
|
import type { Session } from "@/lib/db";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
useArchiveSession,
|
|
5
|
+
useDoneSession,
|
|
6
|
+
useUnarchiveSession,
|
|
7
|
+
} from "@/data/done";
|
|
8
|
+
import { useSettleSession } from "@/data/sessions/shelf";
|
|
4
9
|
|
|
5
10
|
// Done from a session's menu: an open PR merges through the gates, so that
|
|
6
11
|
// one asks first; the rest can be undone from the toast.
|
|
7
12
|
export function useDoneAction() {
|
|
8
13
|
const done = useDoneSession();
|
|
9
|
-
const
|
|
14
|
+
const settle = useSettleSession();
|
|
10
15
|
return (session: Session) => {
|
|
11
16
|
const merges =
|
|
12
17
|
session.task_status === "running" && session.pr_status === "open";
|
|
@@ -21,17 +26,19 @@ export function useDoneAction() {
|
|
|
21
26
|
merges ? `Merging ${session.name}…` : `Finishing ${session.name}…`
|
|
22
27
|
);
|
|
23
28
|
done.mutate(session.id, {
|
|
24
|
-
// Undo only
|
|
29
|
+
// Undo only reopens the entry: offered only when that's all done did.
|
|
25
30
|
onSuccess: (outcome) =>
|
|
26
31
|
toast.success(outcome.text, {
|
|
27
32
|
id,
|
|
28
33
|
duration: 8000,
|
|
29
34
|
...(!outcome.merged &&
|
|
35
|
+
!session.task_status &&
|
|
30
36
|
outcome.worktree.action !== "removed" &&
|
|
31
37
|
outcome.worktree.action !== "gone" && {
|
|
32
38
|
action: {
|
|
33
39
|
label: "Undo",
|
|
34
|
-
onClick: () =>
|
|
40
|
+
onClick: () =>
|
|
41
|
+
settle.mutate({ id: session.id, settled: false }),
|
|
35
42
|
},
|
|
36
43
|
}),
|
|
37
44
|
}),
|
|
@@ -39,3 +46,20 @@ export function useDoneAction() {
|
|
|
39
46
|
});
|
|
40
47
|
};
|
|
41
48
|
}
|
|
49
|
+
|
|
50
|
+
// Archive from a session's menu: puts it away; Unarchive brings it back.
|
|
51
|
+
export function useArchiveAction() {
|
|
52
|
+
const archive = useArchiveSession();
|
|
53
|
+
const unarchive = useUnarchiveSession();
|
|
54
|
+
return (session: Session) =>
|
|
55
|
+
archive.mutate(session.id, {
|
|
56
|
+
onSuccess: () =>
|
|
57
|
+
toast.success(`Archived ${session.name}`, {
|
|
58
|
+
action: {
|
|
59
|
+
label: "Undo",
|
|
60
|
+
onClick: () => unarchive.mutate(session.id),
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
onError: (e) => toast.error(e.message, { duration: 12000 }),
|
|
64
|
+
});
|
|
65
|
+
}
|
package/data/done/index.ts
CHANGED
|
@@ -46,6 +46,12 @@ export function useDoneSession() {
|
|
|
46
46
|
);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
export function useArchiveSession() {
|
|
50
|
+
return useDoneMutation(async (id: string) =>
|
|
51
|
+
json(await post(`/api/sessions/${id}/archive`))
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
49
55
|
export function useDoneIdle() {
|
|
50
56
|
return useDoneMutation(async (workspaceId: string) =>
|
|
51
57
|
json<BulkResult>(await post("/api/sessions/done-idle", { workspaceId }))
|
package/lib/db/migrations.ts
CHANGED
|
@@ -1994,6 +1994,22 @@ const migrations: Migration[] = [
|
|
|
1994
1994
|
);
|
|
1995
1995
|
},
|
|
1996
1996
|
},
|
|
1997
|
+
{
|
|
1998
|
+
id: 83,
|
|
1999
|
+
name: "native_devices_can_approve",
|
|
2000
|
+
up: (db) => {
|
|
2001
|
+
// New native pairings start with Can approve; this gives it once to the
|
|
2002
|
+
// ones paired before. The claim wasn't recorded, so the app is known by
|
|
2003
|
+
// its user agent (`AgentOS/<build> CFNetwork/...` on iOS) or by an
|
|
2004
|
+
// app push token. It only ever turns the flag on.
|
|
2005
|
+
db.exec(
|
|
2006
|
+
`UPDATE devices SET can_approve = 1
|
|
2007
|
+
WHERE revoked_at IS NULL AND can_approve = 0
|
|
2008
|
+
AND (user_agent LIKE 'AgentOS/% CFNetwork/%'
|
|
2009
|
+
OR push_platform IN ('ios', 'android'))`
|
|
2010
|
+
);
|
|
2011
|
+
},
|
|
2012
|
+
},
|
|
1997
2013
|
];
|
|
1998
2014
|
|
|
1999
2015
|
// `upTo`: stop after this id (tests that start from an older database).
|
package/lib/done/archive.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// orchestrator's view, but kept with their history and chat items.
|
|
3
3
|
|
|
4
4
|
import fs from "fs";
|
|
5
|
-
import { db, type Session } from "../db";
|
|
5
|
+
import { db, queries, type Session } from "../db";
|
|
6
|
+
import { statusOf } from "../orchestrator/facts";
|
|
7
|
+
import { peerSessionLink } from "../hosts/peer-actions";
|
|
6
8
|
|
|
7
9
|
export interface ArchivedView {
|
|
8
10
|
id: string;
|
|
@@ -73,3 +75,27 @@ export function listArchived(workspaceId?: string): ArchivedView[] {
|
|
|
73
75
|
archivedAt: s.archived_at!,
|
|
74
76
|
}));
|
|
75
77
|
}
|
|
78
|
+
|
|
79
|
+
// Puts a session away on purpose. Nothing is merged or stopped here: an
|
|
80
|
+
// agent still working, a task with an open PR and an orchestrator stay.
|
|
81
|
+
export async function archiveChecked(id: string): Promise<Session> {
|
|
82
|
+
const s = queries.getSession(db).get(id) as Session | undefined;
|
|
83
|
+
if (!s) throw new Error("Session not found");
|
|
84
|
+
if (s.archived_at) throw new Error(`${s.name} is already archived`);
|
|
85
|
+
if (s.role === "orchestrator")
|
|
86
|
+
throw new Error(
|
|
87
|
+
`${s.name} is a workspace's orchestrator; it can't be archived`
|
|
88
|
+
);
|
|
89
|
+
if (peerSessionLink(s))
|
|
90
|
+
throw new Error(`${s.name} runs on another machine: archive it there`);
|
|
91
|
+
if (s.task_status === "running" && s.pr_status === "open")
|
|
92
|
+
throw new Error(
|
|
93
|
+
`${s.name} has an open PR: Done it first, which merges it through the gates`
|
|
94
|
+
);
|
|
95
|
+
if ((await statusOf(s)).status === "running")
|
|
96
|
+
throw new Error(
|
|
97
|
+
`${s.name} is still working: wait for it, or stop it first`
|
|
98
|
+
);
|
|
99
|
+
archiveSession(s.id);
|
|
100
|
+
return queries.getSession(db).get(id) as Session;
|
|
101
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Done holds finished work; a session that has sat there for
|
|
2
|
+
// ARCHIVE_AFTER_MS with no activity is put away by itself. Never a pinned
|
|
3
|
+
// session, an orchestrator, one that needs input or is working, or one with
|
|
4
|
+
// an open PR. Mirrors the sidebar's Done placement (lib/sidebar/shelves).
|
|
5
|
+
|
|
6
|
+
import { db, type Session } from "../db";
|
|
7
|
+
import { fromSqliteTime } from "../session-meta";
|
|
8
|
+
import { statusOf } from "../orchestrator/facts";
|
|
9
|
+
import { statusDetector } from "../status-detector";
|
|
10
|
+
import { activeMs, ARCHIVE_AFTER_MS, snoozedUntil } from "../sidebar/shelves";
|
|
11
|
+
import { archiveSession } from "./archive";
|
|
12
|
+
|
|
13
|
+
export const AUTO_ARCHIVE_EVERY_MS = 60 * 60_000;
|
|
14
|
+
|
|
15
|
+
const ms = (t: string | null | undefined) =>
|
|
16
|
+
t ? fromSqliteTime(t).getTime() : 0;
|
|
17
|
+
|
|
18
|
+
// When a Done row last changed: its last activity, or when it was marked.
|
|
19
|
+
function sinceMs(s: Session, now: number): number {
|
|
20
|
+
return Math.max(
|
|
21
|
+
activeMs(s, now),
|
|
22
|
+
ms(s.settled_at),
|
|
23
|
+
s.task_status ? ms(s.updated_at) : 0
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// A task is in Done once finished; a plain session that has been quiet this
|
|
28
|
+
// long has long since settled.
|
|
29
|
+
const inDone = (s: Session) =>
|
|
30
|
+
!s.task_status ||
|
|
31
|
+
["merged", "dropped", "done", "moved"].includes(s.task_status);
|
|
32
|
+
|
|
33
|
+
export async function archiveStaleDone(now = Date.now()): Promise<string[]> {
|
|
34
|
+
const rows = db
|
|
35
|
+
.prepare(
|
|
36
|
+
`SELECT * FROM sessions
|
|
37
|
+
WHERE archived_at IS NULL AND pinned = 0 AND role IS NULL
|
|
38
|
+
AND peer_mirror = 0 AND quick_of IS NULL
|
|
39
|
+
AND (pr_status IS NULL OR pr_status != 'open')`
|
|
40
|
+
)
|
|
41
|
+
.all() as Session[];
|
|
42
|
+
const stale = rows.filter(
|
|
43
|
+
(s) =>
|
|
44
|
+
now - sinceMs(s, now) >= ARCHIVE_AFTER_MS &&
|
|
45
|
+
!snoozedUntil(s, now) &&
|
|
46
|
+
inDone(s)
|
|
47
|
+
);
|
|
48
|
+
if (!stale.length) return [];
|
|
49
|
+
await statusDetector.refreshCache();
|
|
50
|
+
const archived: string[] = [];
|
|
51
|
+
for (const s of stale) {
|
|
52
|
+
const { status, needsInput } = await statusOf(s);
|
|
53
|
+
if (needsInput || (status !== "idle" && status !== "dead")) continue;
|
|
54
|
+
archiveSession(s.id);
|
|
55
|
+
archived.push(s.id);
|
|
56
|
+
}
|
|
57
|
+
return archived;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function startAutoArchive(): void {
|
|
61
|
+
const run = () =>
|
|
62
|
+
archiveStaleDone().catch((error) =>
|
|
63
|
+
console.error("Archiving stale Done sessions failed:", error)
|
|
64
|
+
);
|
|
65
|
+
void run();
|
|
66
|
+
setInterval(run, AUTO_ARCHIVE_EVERY_MS);
|
|
67
|
+
}
|
package/lib/done/bulk.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Clean-up: done for every idle or stopped session in a
|
|
3
|
-
* workspace, each judged on its own. It never merges: a session with an
|
|
2
|
+
* Clean-up: done, then archived, for every idle or stopped session in a
|
|
3
|
+
* project or a workspace, each judged on its own. It never merges: a session with an
|
|
4
4
|
* open PR is listed for its own done, which merges through the gates.
|
|
5
5
|
* The preview says what will happen to each before anything does.
|
|
6
6
|
*/
|
|
@@ -11,6 +11,7 @@ import { workspaceProjects } from "../orchestrator/brief";
|
|
|
11
11
|
import { statusOf } from "../orchestrator/facts";
|
|
12
12
|
import { statusDetector } from "../status-detector";
|
|
13
13
|
import { doneSession, type DoneOptions, type DoneOutcome } from "./index";
|
|
14
|
+
import { archiveSession } from "./archive";
|
|
14
15
|
import { planDone, planLine } from "./plan";
|
|
15
16
|
|
|
16
17
|
export type DoneScope = { workspaceId: string } | { projectId: string };
|
|
@@ -105,7 +106,7 @@ export async function previewIdle(
|
|
|
105
106
|
action: plan.action,
|
|
106
107
|
removesWorktree:
|
|
107
108
|
plan.action === "cleanup" && plan.worktree.action === "remove",
|
|
108
|
-
line: planLine(plan),
|
|
109
|
+
line: planLine(plan, true),
|
|
109
110
|
});
|
|
110
111
|
}
|
|
111
112
|
return rows;
|
|
@@ -144,7 +145,10 @@ export async function doneIdle(
|
|
|
144
145
|
result.mergeable.push({ id: s.id, name: s.name, pr: plan.pr.number });
|
|
145
146
|
continue;
|
|
146
147
|
}
|
|
147
|
-
|
|
148
|
+
const outcome = await doneSession(s.id, { ...opts, noMerge: true });
|
|
149
|
+
// A clean-up is a deliberate sweep: it puts the sessions away.
|
|
150
|
+
archiveSession(s.id);
|
|
151
|
+
result.done.push({ ...outcome, text: `${outcome.text} Archived.` });
|
|
148
152
|
} catch (error) {
|
|
149
153
|
const reason = error instanceof Error ? error.message : String(error);
|
|
150
154
|
result.refused.push({ id: s.id, name: s.name, reason });
|
package/lib/done/index.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* gates at the judged head (refused, naming the gate, otherwise); one
|
|
5
5
|
* already merged, or a session with no PR, just cleans up. Cleanup stops
|
|
6
6
|
* the agent, removes the worktree only when nothing in it would be lost,
|
|
7
|
-
* and
|
|
7
|
+
* and marks the session finished so it sits on the Done shelf (archiving is
|
|
8
|
+
* its own action, archive.ts). What it will do is decided first (plan.ts).
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { db, queries, type Session } from "../db";
|
|
@@ -17,6 +18,7 @@ import { judge, mergeJudged } from "../orchestrator/signoff";
|
|
|
17
18
|
import { isPaused } from "../orchestrator/pause";
|
|
18
19
|
import { plain } from "../orchestrator/overview";
|
|
19
20
|
import { archiveSession } from "./archive";
|
|
21
|
+
import { settleSession } from "../sidebar/shelf-state";
|
|
20
22
|
import { demoMode } from "../security/demo";
|
|
21
23
|
import { planDone } from "./plan";
|
|
22
24
|
import { deleteMergedRemote } from "./remote";
|
|
@@ -187,7 +189,7 @@ export async function doneSession(
|
|
|
187
189
|
await stopAgent(s);
|
|
188
190
|
worktree = await settleWorktree(s, plan.merged);
|
|
189
191
|
}
|
|
190
|
-
|
|
192
|
+
settleSession(s.id);
|
|
191
193
|
const head = merged ?? `Done: ${s.name}.`;
|
|
192
194
|
const extra = notes.length ? `, ${notes.join(", ")}` : "";
|
|
193
195
|
return {
|
|
@@ -195,6 +197,6 @@ export async function doneSession(
|
|
|
195
197
|
name: s.name,
|
|
196
198
|
merged,
|
|
197
199
|
worktree,
|
|
198
|
-
text: `${head} Agent stopped, ${worktreeLine(worktree)}${extra}
|
|
200
|
+
text: `${head} Agent stopped, ${worktreeLine(worktree)}${extra}.`,
|
|
199
201
|
};
|
|
200
202
|
}
|
package/lib/done/plan.ts
CHANGED
|
@@ -92,7 +92,7 @@ export async function planDone(
|
|
|
92
92
|
): Promise<DonePlan> {
|
|
93
93
|
if (s.role === "orchestrator")
|
|
94
94
|
return refuse(`${s.name} is a workspace's orchestrator; it can't be done`);
|
|
95
|
-
if (s.archived_at) return refuse(`${s.name} is already
|
|
95
|
+
if (s.archived_at) return refuse(`${s.name} is already archived`);
|
|
96
96
|
if (callerId && callerId === s.id)
|
|
97
97
|
return refuse("A session can't mark itself done");
|
|
98
98
|
if ((await statusOf(s)).status === "running")
|
|
@@ -104,7 +104,7 @@ export async function planDone(
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
// One line for the preview: what done will do with it.
|
|
107
|
-
export function planLine(p: DonePlan): string {
|
|
107
|
+
export function planLine(p: DonePlan, archives = false): string {
|
|
108
108
|
if (p.action === "refuse") return p.reason;
|
|
109
109
|
if (p.action === "merge")
|
|
110
110
|
return `has an open PR #${p.pr.number}: Done it on its own to merge it through the gates`;
|
|
@@ -117,5 +117,5 @@ export function planLine(p: DonePlan): string {
|
|
|
117
117
|
: w.action === "gone"
|
|
118
118
|
? `its worktree is ${w.why}`
|
|
119
119
|
: "no worktree";
|
|
120
|
-
return `stop it, ${tree}, archive it`;
|
|
120
|
+
return `stop it, ${tree}, ${archives ? "archive it" : "mark it done"}`;
|
|
121
121
|
}
|
package/lib/orchestrator/act.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { getStack, previewStack, startStack } from "../stacks";
|
|
|
32
32
|
import { treeOrder } from "../stacks/tree";
|
|
33
33
|
import type { StackItemView } from "../stacks/types";
|
|
34
34
|
import { doneSession } from "../done";
|
|
35
|
+
import { archiveChecked } from "../done/archive";
|
|
35
36
|
import { amendBrief, amendmentsOf } from "./amendments";
|
|
36
37
|
import type { Session } from "../db";
|
|
37
38
|
import { approvePlanNow } from "../models/plan-step";
|
|
@@ -291,7 +292,7 @@ export async function drop(
|
|
|
291
292
|
}
|
|
292
293
|
|
|
293
294
|
// Finished work: merged through the gates if its PR is open, then
|
|
294
|
-
// stopped
|
|
295
|
+
// stopped and cleaned up, then left on the Done shelf (lib/done).
|
|
295
296
|
export async function done(workspaceId: string, ref: string): Promise<string> {
|
|
296
297
|
const s = findWorkspaceSession(workspaceId, ref);
|
|
297
298
|
const out = await doneSession(s.id, { by: "orchestrator" });
|
|
@@ -299,6 +300,17 @@ export async function done(workspaceId: string, ref: string): Promise<string> {
|
|
|
299
300
|
return out.text;
|
|
300
301
|
}
|
|
301
302
|
|
|
303
|
+
// Puts a session away, out of the sidebar; nothing else changes.
|
|
304
|
+
export async function archive(
|
|
305
|
+
workspaceId: string,
|
|
306
|
+
ref: string
|
|
307
|
+
): Promise<string> {
|
|
308
|
+
const s = findWorkspaceSession(workspaceId, ref);
|
|
309
|
+
await archiveChecked(s.id);
|
|
310
|
+
addNote(workspaceId, `Archived: ${s.name}.`);
|
|
311
|
+
return `Archived ${s.name}: out of the sidebar, in Archived.`;
|
|
312
|
+
}
|
|
313
|
+
|
|
302
314
|
// Stops the agent and keeps everything else: a chat's turn is interrupted,
|
|
303
315
|
// a terminal's tmux session ends. Its worktree and branch stay.
|
|
304
316
|
export async function stop(workspaceId: string, ref: string): Promise<string> {
|
|
@@ -129,7 +129,8 @@ Acting:
|
|
|
129
129
|
- \`${TOOL_NAMES.move}\` (task, machine): carry a running task to a linked machine (heavy test or build work), or back with machine "here". It keeps its branch, conversation and plan, and everything you do with a task works the same there: its events come to you, read, send, stop, done and drop reach it, and the gates judge its PR at its head. The machine it lands on holds it for you and can't merge it. It runs in the background; an event says when it lands. Refused while paused, and for a task waiting on Saad.
|
|
130
130
|
- \`${TOOL_NAMES.drop}\` (task, reason): close a task's PR and remove its worktree (on whichever machine holds it).
|
|
131
131
|
- \`${TOOL_NAMES.stop}\` (session): stop a session's agent, keeping its work.
|
|
132
|
-
- \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session
|
|
132
|
+
- \`${TOOL_NAMES.done}\` (session): finish a session whose work is complete. A task with an open PR merges through the same gates as sign_off first (refused naming the gate otherwise); then the agent stops, the worktree goes only if nothing in it would be lost, and the session lands on the sidebar's Done shelf (it is not archived). Not a rejection: use drop for that.
|
|
133
|
+
- \`${TOOL_NAMES.archive}\` (session): put a session away, out of the sidebar, when Saad asks. Nothing is merged or cleaned up; refused while it works or has an open PR.
|
|
133
134
|
- \`${TOOL_NAMES.add_project}\` (path, name?): make a git repository on this machine a project of this workspace, or move the project already at that path in when it's in no workspace. Use it rather than sending Saad steps. It refuses a folder that isn't a git repository or holds secrets, and a project another workspace has: moving that is Saad's call, so ask_saad.
|
|
134
135
|
- \`${TOOL_NAMES.note}\` (text): a line in this workspace's decision log, shown in your chat. Note each decision that matters, with why.
|
|
135
136
|
- \`${TOOL_NAMES.rule_add}\` (text): keep a standing rule for this workspace, in your brief on every start. Saad sees and edits them in Settings → Workspaces.
|
|
@@ -3,6 +3,7 @@ import { workspaceTask } from "./targets";
|
|
|
3
3
|
import { getWorkspace } from "../workspaces";
|
|
4
4
|
import {
|
|
5
5
|
approveTaskPlan,
|
|
6
|
+
archive,
|
|
6
7
|
done,
|
|
7
8
|
drop,
|
|
8
9
|
moveTaskTo,
|
|
@@ -190,6 +191,8 @@ export async function runTool(
|
|
|
190
191
|
return stop(w, p(tool, raw).session);
|
|
191
192
|
case "done":
|
|
192
193
|
return done(w, p(tool, raw).session);
|
|
194
|
+
case "archive":
|
|
195
|
+
return archive(w, p(tool, raw).session);
|
|
193
196
|
case "add_project": {
|
|
194
197
|
const a = p(tool, raw);
|
|
195
198
|
return addProject(w, a.path, a.name);
|
|
@@ -211,13 +211,14 @@ const PULL_MERGE = /(^|\/)pulls\/[^/?\s]+\/merge\/?(\?|$)/;
|
|
|
211
211
|
|
|
212
212
|
const MERGE_MSG = `Refused: a PR merges only through the gates: ${T.sign_off} (or ${T.arm}) for a task's or an external PR, ${T.land} for a stack. Nothing merges, closes or retargets a PR from the shell.`;
|
|
213
213
|
|
|
214
|
-
// aos move, done, stop and drop reach a session by name, past the tools'
|
|
214
|
+
// aos move, done, archive, stop and drop reach a session by name, past the tools'
|
|
215
215
|
// Pause check and logging; the tools do the same work.
|
|
216
216
|
function aosRefusal(args: string[]): string | null {
|
|
217
217
|
const [sub] = positionals(args);
|
|
218
218
|
const tool: Record<string, string> = {
|
|
219
219
|
move: T.move,
|
|
220
220
|
done: T.done,
|
|
221
|
+
archive: T.archive,
|
|
221
222
|
stop: T.stop,
|
|
222
223
|
};
|
|
223
224
|
if (sub && tool[sub])
|
|
@@ -82,7 +82,9 @@ const DESCRIPTIONS: Record<ToolName, string> = {
|
|
|
82
82
|
drop: "Reject a task: close its PR and remove its worktree. A task still waiting in the queue is removed by its id. Logged with the reason.",
|
|
83
83
|
move: 'Carry a running task to a linked machine, or back with "here": for heavy test or build work. It keeps its branch, conversation and plan; the gates, events, read, send, stop, done and drop work the same wherever it runs, and the machine it lands on can\'t merge it. Takes a few minutes and runs in the background; an event says when it lands. Refused while paused, for a task waiting on Saad (an escalation or open ask), and for card and stacked tasks.',
|
|
84
84
|
stop: "Stop a session's agent mid-turn, even while it's thinking, keeping its conversation, worktree and branch; your next message runs as its next turn.",
|
|
85
|
-
done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it's
|
|
85
|
+
done: "Finish a session whose work is complete: a task's open PR merges through the sign-off gates first (refused naming the failing gate), then its agent stops, its worktree is removed if nothing would be lost, and it lands on the sidebar's Done shelf; it no longer archives (use archive for that). Not a rejection.",
|
|
86
|
+
archive:
|
|
87
|
+
"Put a finished session away, out of the sidebar (it stays in Archived, and Saad can unarchive it). Nothing is merged or cleaned up: refused while the session is working or has an open PR; done first for finished work.",
|
|
86
88
|
add_project:
|
|
87
89
|
"Make a git repository on this machine a project of this workspace, by its absolute path, or move the project already at that path into this workspace when it's in no workspace. Says its default branch, detected from git. Refuses a folder that isn't a git repository or that holds secrets, and a project another workspace has.",
|
|
88
90
|
note: "Add a line to this workspace's decision log; it shows in your chat.",
|
package/lib/security/approver.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Who may answer the orchestrator's asks or pause it: this machine, the
|
|
3
|
-
* tailnet, or a paired device
|
|
3
|
+
* tailnet, or a paired device with Can approve (the native app gets it at
|
|
4
|
+
* pairing; Saad can turn it off or on in Devices). This
|
|
4
5
|
* is the coarse gate; approvals that matter also need a passkey (presence).
|
|
5
6
|
*/
|
|
6
7
|
|
|
@@ -11,13 +11,19 @@ export interface ClaimBody {
|
|
|
11
11
|
token?: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// The native app asks for its token and sends no Origin; a browser always
|
|
15
|
+
// sends one on a POST, so a page can't pass for the app.
|
|
16
|
+
export const isNativeClaim = (request: {
|
|
17
|
+
wantsToken?: unknown;
|
|
18
|
+
origin: string | null;
|
|
19
|
+
}) => request.wantsToken === true && !request.origin;
|
|
20
|
+
|
|
14
21
|
export function claimResponseBody(
|
|
15
22
|
device: { id: string; name: string },
|
|
16
23
|
token: string,
|
|
17
24
|
request: { wantsToken?: unknown; origin: string | null }
|
|
18
25
|
): ClaimBody {
|
|
19
|
-
|
|
20
|
-
return { device, ...(native ? { token } : {}) };
|
|
26
|
+
return { device, ...(isNativeClaim(request) ? { token } : {}) };
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
// Whether the reply also sets the device cookie: only for a browser. A
|
package/lib/security/devices.ts
CHANGED
|
@@ -20,7 +20,8 @@ export interface Device {
|
|
|
20
20
|
last_seen_at: string | null;
|
|
21
21
|
last_address: string | null;
|
|
22
22
|
revoked_at: string | null;
|
|
23
|
-
//
|
|
23
|
+
// It may answer the orchestrator's asks: on for the native app at
|
|
24
|
+
// pairing, off for a browser; Saad can flip it either way in Devices.
|
|
24
25
|
can_approve: number;
|
|
25
26
|
}
|
|
26
27
|
|
|
@@ -32,19 +33,22 @@ export const hashToken = (token: string) =>
|
|
|
32
33
|
|
|
33
34
|
export function mintDevice(
|
|
34
35
|
name: string,
|
|
35
|
-
userAgent?: string | null
|
|
36
|
+
userAgent?: string | null,
|
|
37
|
+
canApprove = false
|
|
36
38
|
): { device: Device; token: string } {
|
|
37
39
|
const id = crypto.randomUUID();
|
|
38
40
|
const token = TOKEN_PREFIX + crypto.randomBytes(32).toString("base64url");
|
|
39
41
|
getDb()
|
|
40
42
|
.prepare(
|
|
41
|
-
`INSERT INTO devices (id, name, token_hash, user_agent
|
|
43
|
+
`INSERT INTO devices (id, name, token_hash, user_agent, can_approve)
|
|
44
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
42
45
|
)
|
|
43
46
|
.run(
|
|
44
47
|
id,
|
|
45
48
|
name.trim().slice(0, 80) || "Device",
|
|
46
49
|
hashToken(token),
|
|
47
|
-
userAgent ?? null
|
|
50
|
+
userAgent ?? null,
|
|
51
|
+
canApprove ? 1 : 0
|
|
48
52
|
);
|
|
49
53
|
return { device: getDevice(id)!, token };
|
|
50
54
|
}
|
package/lib/security/pairing.ts
CHANGED
|
@@ -82,6 +82,8 @@ export function claimPairing(
|
|
|
82
82
|
name: string;
|
|
83
83
|
userAgent?: string | null;
|
|
84
84
|
address: string;
|
|
85
|
+
// The native app (claimResponseBody's test): it may approve from the start.
|
|
86
|
+
native?: boolean;
|
|
85
87
|
},
|
|
86
88
|
now = Date.now()
|
|
87
89
|
): ClaimResult {
|
|
@@ -103,7 +105,11 @@ export function claimPairing(
|
|
|
103
105
|
const p = state.codes.get(normalizeCode(input.code));
|
|
104
106
|
if (!p || p.claimed || p.expiresAt < now)
|
|
105
107
|
return { ok: false, error: "invalid" };
|
|
106
|
-
const { device, token } = mintDevice(
|
|
108
|
+
const { device, token } = mintDevice(
|
|
109
|
+
input.name,
|
|
110
|
+
input.userAgent,
|
|
111
|
+
input.native === true
|
|
112
|
+
);
|
|
107
113
|
p.claimed = { deviceId: device.id, name: device.name };
|
|
108
114
|
return { ok: true, device, token };
|
|
109
115
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Session } from "@/lib/db/types";
|
|
2
|
+
|
|
3
|
+
// What a row's ⋯ menu offers to finish it: Done (merge through the gates,
|
|
4
|
+
// stop, leave it on the Done shelf) for a task, Archive (put it away) for
|
|
5
|
+
// every session but an orchestrator. A plain session is finished with Mark
|
|
6
|
+
// done, so it only gets Archive here.
|
|
7
|
+
export type FinishAction = "done" | "archive";
|
|
8
|
+
|
|
9
|
+
export function finishActions(
|
|
10
|
+
session: Pick<Session, "role" | "task_status">,
|
|
11
|
+
plain: boolean
|
|
12
|
+
): FinishAction[] {
|
|
13
|
+
if (session.role === "orchestrator") return [];
|
|
14
|
+
return plain ? ["archive"] : ["done", "archive"];
|
|
15
|
+
}
|
package/lib/sidebar/shelves.ts
CHANGED
|
@@ -26,6 +26,8 @@ export const NEED_LABEL: Record<SessionNeed, string> = {
|
|
|
26
26
|
|
|
27
27
|
// A plain session quiet this long has settled by itself.
|
|
28
28
|
export const SETTLE_AFTER_MS = 3 * 24 * 3600_000;
|
|
29
|
+
// A session that has sat in Done this long is archived by the server.
|
|
30
|
+
export const ARCHIVE_AFTER_MS = 30 * 24 * 3600_000;
|
|
29
31
|
|
|
30
32
|
export const DONE_FIRST_PAGE = 10;
|
|
31
33
|
export const DONE_PAGE = 25;
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { buildId } from "./lib/build";
|
|
|
14
14
|
import { startOrchestratorWatcher } from "./lib/orchestrator/watcher";
|
|
15
15
|
import { realDeps, schedulesEnabled, startScheduler } from "./lib/schedules";
|
|
16
16
|
import { startSettling } from "./lib/recipes";
|
|
17
|
+
import { startAutoArchive } from "./lib/done/auto-archive";
|
|
17
18
|
import { startUsageRollup, usageRollupEnabled } from "./lib/usage/nightly";
|
|
18
19
|
import { resumePhoneOutbox } from "./lib/notify";
|
|
19
20
|
import { resumePushOutbox } from "./lib/notify/push";
|
|
@@ -413,6 +414,8 @@ app.prepare().then(async () => {
|
|
|
413
414
|
// Schedules tick here, once a minute, and nowhere else.
|
|
414
415
|
if (schedulesEnabled(process.env)) startScheduler(realDeps);
|
|
415
416
|
startSettling();
|
|
417
|
+
// Sessions that sat in Done for 30 days are archived.
|
|
418
|
+
startAutoArchive();
|
|
416
419
|
resumePhoneOutbox();
|
|
417
420
|
resumePushOutbox();
|
|
418
421
|
// The usage ledger's daily rollup and its ratchet alerts.
|