@saadnvd1/agent-os 0.3.547 → 0.3.548

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -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 { useDoneAction } from "./useDoneAction";
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
- <Item onClick={() => markDone(session)}>
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 { useDoneSession, useUnarchiveSession } from "@/data/done";
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 unarchive = useUnarchiveSession();
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 puts the entry back: offered only when that's all done did.
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: () => unarchive.mutate(session.id),
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
+ }
@@ -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 }))
@@ -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 project or 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
- result.done.push(await doneSession(s.id, { ...opts, noMerge: true }));
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 archives the session. What it will do is decided first (plan.ts).
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
- archiveSession(s.id);
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}; archived.`,
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 done`);
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
  }
@@ -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, cleaned up and archived (lib/done).
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 is archived out of view. Not a rejection: use drop for that.
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])
@@ -28,6 +28,7 @@ export const TOOL_NAMES = {
28
28
  move: name("move"),
29
29
  stop: name("stop"),
30
30
  done: name("done"),
31
+ archive: name("archive"),
31
32
  add_project: name("add_project"),
32
33
  note: name("note"),
33
34
  question: name("question"),
@@ -235,6 +235,7 @@ export const TOOL_SHAPES = {
235
235
  },
236
236
  stop: { session },
237
237
  done: { session },
238
+ archive: { session },
238
239
  add_project: {
239
240
  path: z
240
241
  .string()
@@ -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 archived. Not a rejection.",
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.",
@@ -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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.547",
3
+ "version": "0.3.548",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
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.