@saadnvd1/agent-os 0.3.493 → 0.3.495

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/README.md CHANGED
@@ -1221,6 +1221,12 @@ it may do on its own, and the lines that always come back to you as asks.
1221
1221
  Nothing is lost, because its state lives in the database; its chat goes on,
1222
1222
  with a line where it started over. The same can be switched on for chat
1223
1223
  tasks (off by default: a task keeps only its brief and its branch).
1224
+ **Start fresh by hand:** after a deploy that changes its permissions or
1225
+ brief, use **New conversation (keep brief)** in the orchestrator's row menu or type `/fresh`
1226
+ in its chat (task chats have both too). It takes the threshold's path: an
1227
+ idle agent starts over at once; a running turn finishes first, with a
1228
+ "Starts fresh after this turn" line. The next conversation launches with
1229
+ the brief, permissions and workspace state the server has then.
1224
1230
  - **Events:** the server sends it one short line per event (a PR opened, CI
1225
1231
  failed, or green once it has settled so `sign_off`'s ci gate passes, a `BLOCKED:` line, a merge, a session needing input, a
1226
1232
  stack step, a task idle 30 minutes with no PR, a review's verdict). Events
@@ -0,0 +1,31 @@
1
+ import { NextResponse } from "next/server";
2
+ import { queries, db, type Session } from "@/lib/db";
3
+ import { FreshRefusal, freshStartChat } from "@/lib/chat/runner";
4
+
5
+ // POST /api/sessions/:id/fresh - start the agent's conversation over (the
6
+ // path the context threshold takes, lib/chat/refresh): now if it's idle,
7
+ // else at the next turn boundary. Only for chats whose state lives outside
8
+ // the conversation: an orchestrator, or a task with a brief.
9
+ export async function POST(
10
+ _request: Request,
11
+ { params }: { params: Promise<{ id: string }> }
12
+ ) {
13
+ const { id } = await params;
14
+ const session = queries.getSession(db).get(id) as Session | undefined;
15
+ if (!session)
16
+ return NextResponse.json({ error: "Session not found" }, { status: 404 });
17
+ if (session.role !== "orchestrator" && !session.task_brief)
18
+ return NextResponse.json(
19
+ { error: "Only an orchestrator or a task can start fresh" },
20
+ { status: 400 }
21
+ );
22
+ try {
23
+ return NextResponse.json({ when: await freshStartChat(id) });
24
+ } catch (error) {
25
+ const message = error instanceof Error ? error.message : String(error);
26
+ return NextResponse.json(
27
+ { error: message },
28
+ { status: error instanceof FreshRefusal ? 409 : 500 }
29
+ );
30
+ }
31
+ }
@@ -19,6 +19,8 @@ import {
19
19
  type TimelineBlock,
20
20
  } from "@/lib/chat/group";
21
21
  import type { ApprovalDecision, ChatItem } from "@/lib/chat/events";
22
+ import { isFreshCommand, withFresh } from "@/lib/chat/commands";
23
+ import { useFreshStart } from "@/data/sessions";
22
24
  import {
23
25
  escapeAction,
24
26
  GHOST_SELECTOR,
@@ -206,6 +208,7 @@ export function ChatPanel({
206
208
  sessionName,
207
209
  accessLocked = false,
208
210
  orchestratorOf,
211
+ freshStart = false,
209
212
  active,
210
213
  }: {
211
214
  sessionId: string;
@@ -214,6 +217,8 @@ export function ChatPanel({
214
217
  accessLocked?: boolean;
215
218
  // The workspace it orchestrates: its header line, Pause and asks show on top.
216
219
  orchestratorOf?: string | null;
220
+ // An orchestrator or task: /fresh starts its conversation over.
221
+ freshStart?: boolean;
217
222
  // The tab on show in the focused pane: opening it takes the caret.
218
223
  active: boolean;
219
224
  }) {
@@ -259,6 +264,7 @@ export function ChatPanel({
259
264
  requestFiles,
260
265
  search,
261
266
  } = useChat(sessionId, (text) => setPrefill({ text, at: Date.now() }));
267
+ const startFresh = useFreshStart();
262
268
  const { isMobile } = useViewport();
263
269
  const scrollRef = useRef<HTMLDivElement>(null);
264
270
  const listRef = useRef<HTMLDivElement>(null);
@@ -580,6 +586,10 @@ export function ChatPanel({
580
586
  disabled={!connected}
581
587
  placeholder={connected ? `Message ${sessionName}` : "Reconnecting…"}
582
588
  onSend={(text, images) => {
589
+ if (freshStart && isFreshCommand(text)) {
590
+ startFresh.mutate(sessionId);
591
+ return;
592
+ }
583
593
  toEnd();
584
594
  const compact = composerCompacts(text, context, {
585
595
  state,
@@ -590,7 +600,7 @@ export function ChatPanel({
590
600
  send(text, images.length ? images : undefined, compact);
591
601
  }}
592
602
  onStop={interrupt}
593
- commands={commands}
603
+ commands={freshStart ? withFresh(commands) : commands}
594
604
  onRefreshCommands={refreshCommands}
595
605
  refreshingCommands={refreshingCommands}
596
606
  models={models}
@@ -289,6 +289,10 @@ export const Pane = memo(function Pane({
289
289
  sessionId={chat.id}
290
290
  sessionName={chat.name}
291
291
  accessLocked={chat.role === "orchestrator"}
292
+ freshStart={
293
+ (chat.role === "orchestrator" || !!chat.task_brief) &&
294
+ !chat.archived_at
295
+ }
292
296
  orchestratorOf={
293
297
  chat.role === "orchestrator" ? chat.workspace_id : null
294
298
  }
@@ -88,6 +88,7 @@ describe("OrchestratorPinRow", () => {
88
88
  onDelete: vi.fn(),
89
89
  onMoveToProject: vi.fn(),
90
90
  onPin: vi.fn(),
91
+ onFresh: vi.fn(),
91
92
  };
92
93
  const render = (over: Partial<RowContextValue> = {}, unread = false) =>
93
94
  act(async () =>
@@ -59,7 +59,7 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
59
59
  className={
60
60
  active
61
61
  ? "bg-primary/10 border-primary/30"
62
- : "bg-muted/50 hover:bg-muted"
62
+ : "bg-muted/50 hover:bg-muted has-[[data-chip]:hover]:bg-muted/50"
63
63
  }
64
64
  >
65
65
  <Workflow
@@ -110,7 +110,8 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
110
110
  <button
111
111
  type="button"
112
112
  aria-pressed={filtering}
113
- title={`Show only ${label}'s work`}
113
+ data-chip
114
+ aria-label={`Show only ${label}'s work`}
114
115
  onClick={(e) => {
115
116
  e.stopPropagation();
116
117
  sidebarUiActions.setOrchestrator(
@@ -118,9 +119,10 @@ export const OrchestratorPinRow = memo(function OrchestratorPinRow({
118
119
  );
119
120
  }}
120
121
  className={cn(
121
- "text-muted-foreground -mx-1 mt-0.5 flex min-h-6 flex-wrap items-center gap-x-2.5 rounded px-1 text-xs",
122
- "hover:bg-muted hover:text-foreground md:min-h-5",
123
- filtering && "bg-muted text-foreground"
122
+ "text-muted-foreground -mx-1.5 mt-0.5 flex min-h-6 cursor-pointer flex-wrap items-center gap-x-2.5 self-start rounded-md px-1.5 text-xs transition-colors md:min-h-5",
123
+ "hover:bg-foreground/10 hover:text-foreground",
124
+ "focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none",
125
+ filtering && "bg-foreground/10 text-foreground"
124
126
  )}
125
127
  >
126
128
  {chips.map((c) => (
@@ -29,6 +29,7 @@ export interface RowContextValue {
29
29
  onDelete: (sessionId: string) => void;
30
30
  onMoveToProject: (sessionId: string, projectId: string) => void;
31
31
  onPin: (sessionId: string, pinned: boolean) => void;
32
+ onFresh: (sessionId: string) => void;
32
33
  }
33
34
 
34
35
  const RowContext = createContext<RowContextValue | null>(null);
@@ -15,6 +15,7 @@ import {
15
15
  Pencil,
16
16
  Pin,
17
17
  PinOff,
18
+ RotateCcw,
18
19
  Sparkles,
19
20
  Trash2,
20
21
  } from "lucide-react";
@@ -174,6 +175,15 @@ export function SessionRowMenu({
174
175
  {summarizing ? "Summarizing..." : "Fresh start"}
175
176
  </Item>
176
177
  )}
178
+ {(session.role === "orchestrator" || session.task_brief) &&
179
+ session.view === "chat" &&
180
+ !session.archived_at &&
181
+ !blocked && (
182
+ <Item onClick={() => ctx.onFresh(session.id)}>
183
+ <RotateCcw className={icon} />
184
+ New conversation (keep brief)
185
+ </Item>
186
+ )}
177
187
  {session.pr_url && (
178
188
  <Item onClick={() => window.open(session.pr_url!, "_blank")}>
179
189
  <GitPullRequest className={icon} />
@@ -4,7 +4,7 @@ import { useLayoutEffect, useMemo, useRef, useState } from "react";
4
4
  import { Loader2 } from "lucide-react";
5
5
  import { ScrollArea } from "@/components/ui/scroll-area";
6
6
  import { useDocsWorkspace } from "@/data/lumifyhub/docs";
7
- import { usePinSession } from "@/data/sessions";
7
+ import { useFreshStart, usePinSession } from "@/data/sessions";
8
8
  import { useOrchestratorsQuery } from "@/data/orchestrators";
9
9
  import { docsUiActions } from "@/stores/docsUi";
10
10
  import type { SidebarRow } from "@/lib/sidebar/shelves";
@@ -47,6 +47,7 @@ export function SessionList({
47
47
  const mutations = useSessionListMutations({ onSelectSession: onSelect });
48
48
  const bulkDelete = useBulkDelete();
49
49
  const pin = usePinSession();
50
+ const fresh = useFreshStart();
50
51
  const { data: orchestrators = NO_ORCHESTRATORS } = useOrchestratorsQuery();
51
52
  const [dialog, setDialog] = useState<SidebarDialog | null>(null);
52
53
  const addProject = useAddProject();
@@ -70,9 +71,23 @@ export function SessionList({
70
71
  // Rows read their handlers through the latest render, so the context only
71
72
  // changes when what rows draw does: a status push redraws the rows it
72
73
  // touched, not the whole list.
73
- const latest = useRef({ orderedIds, onSelect, onOpenInTab, mutations, pin });
74
+ const latest = useRef({
75
+ orderedIds,
76
+ onSelect,
77
+ onOpenInTab,
78
+ mutations,
79
+ pin,
80
+ fresh,
81
+ });
74
82
  useLayoutEffect(() => {
75
- latest.current = { orderedIds, onSelect, onOpenInTab, mutations, pin };
83
+ latest.current = {
84
+ orderedIds,
85
+ onSelect,
86
+ onOpenInTab,
87
+ mutations,
88
+ pin,
89
+ fresh,
90
+ };
76
91
  });
77
92
  const canOpenInTab = !!onOpenInTab;
78
93
  const handlers = useMemo(
@@ -92,6 +107,7 @@ export function SessionList({
92
107
  latest.current.mutations.handleMoveSessionToProject(id, projectId),
93
108
  onPin: (id: string, pinned: boolean) =>
94
109
  latest.current.pin.mutate({ id, pinned }),
110
+ onFresh: (id: string) => latest.current.fresh.mutate(id),
95
111
  }),
96
112
  [canOpenInTab]
97
113
  );
@@ -0,0 +1,20 @@
1
+ import { useMutation } from "@tanstack/react-query";
2
+ import { toast } from "sonner";
3
+
4
+ // Starts an orchestrator's or task's conversation over (lib/chat/refresh):
5
+ // now when idle, else after the running turn.
6
+ export function useFreshStart() {
7
+ return useMutation({
8
+ mutationFn: async (id: string): Promise<"now" | "after-turn"> => {
9
+ const res = await fetch(`/api/sessions/${id}/fresh`, { method: "POST" });
10
+ const body = (await res.json().catch(() => ({}))) as {
11
+ when?: "now" | "after-turn";
12
+ error?: string;
13
+ };
14
+ if (!res.ok || !body.when)
15
+ throw new Error(body.error ?? "Couldn't start fresh");
16
+ return body.when;
17
+ },
18
+ onError: (e: Error) => toast.error(e.message),
19
+ });
20
+ }
@@ -11,4 +11,5 @@ export {
11
11
  useSessionSetup,
12
12
  } from "./queries";
13
13
  export { usePinSession } from "./pin";
14
+ export { useFreshStart } from "./fresh";
14
15
  export type { LaunchSessionInput, SessionSetup } from "./queries";
@@ -48,3 +48,19 @@ export function insertCommand(name: string): string {
48
48
  export function leadingCommand(text: string): string | null {
49
49
  return text.match(/^\/[\w:.-]+(?=\s|$)/)?.[0] ?? null;
50
50
  }
51
+
52
+ // AgentOS's own command for an orchestrator or task: starts the agent's
53
+ // conversation over, with today's brief and permissions.
54
+ export const FRESH_COMMAND: ChatCommand = {
55
+ name: "fresh",
56
+ description: "Start a fresh conversation with the current brief",
57
+ };
58
+
59
+ export const isFreshCommand = (text: string): boolean =>
60
+ text.trim().toLowerCase() === "/fresh";
61
+
62
+ export function withFresh(commands: ChatCommand[]): ChatCommand[] {
63
+ return commands.some((c) => c.name === "fresh")
64
+ ? commands
65
+ : [FRESH_COMMAND, ...commands];
66
+ }
@@ -143,8 +143,26 @@ export function autoCompactEnv(sessionId: string): Record<string, string> {
143
143
 
144
144
  const k = (tokens: number) => `${Math.round(tokens / 1000)}k`;
145
145
 
146
+ // Recorded as the size a conversation ended at when the reader asked for it.
147
+ export const REQUESTED_FRESH = -1;
148
+
149
+ // The agent's conversation is let go in the database: the next worker
150
+ // starts a new one with the brief and the permissions the server has then.
151
+ // `tokens` is the size it ended at, or REQUESTED_FRESH.
152
+ export function releaseConversation(sessionId: string, tokens: number): void {
153
+ db.prepare(
154
+ `UPDATE sessions SET claude_session_id = NULL, chat_resume_at = NULL,
155
+ chat_suggestion = NULL, chat_context = NULL, chat_restarted = 0,
156
+ chat_refreshed = ? WHERE id = ?`
157
+ ).run(tokens, sessionId);
158
+ }
159
+
146
160
  // What the agent reads ahead of its first message in the new conversation.
147
161
  export function refreshedNote(role: "orchestrator" | "task", tokens: number) {
162
+ if (tokens === REQUESTED_FRESH)
163
+ return role === "orchestrator"
164
+ ? `(AgentOS started you on a fresh conversation at the user's request. Your brief and "Where things stand" are current; carry on from them.)`
165
+ : `(AgentOS started you on a fresh conversation at the user's request. Your brief is above; check git status, git log and your PR to see how far you got, then carry on.)`;
148
166
  return role === "orchestrator"
149
167
  ? `(AgentOS started you on a fresh conversation: the last one passed ${k(tokens)} tokens of context. Your brief and "Where things stand" are current; carry on from them.)`
150
168
  : `(AgentOS started you on a fresh conversation: the last one passed ${k(tokens)} tokens of context. Your brief is above; check git status, git log and your PR to see how far you got, then carry on.)`;
@@ -24,6 +24,8 @@ export interface Live {
24
24
  // Whether it keeps the composer's queue and answers @mention lookups
25
25
  // (send_now, drain, files); an older worker drops those silently.
26
26
  canQueue?: boolean;
27
+ // Whether it takes "fresh" (a fresh start the reader asked for).
28
+ canFresh?: boolean;
27
29
  // From an older build and idle: retired once its guess at the next
28
30
  // message is in, or this fires.
29
31
  retiring?: NodeJS.Timeout;
@@ -50,6 +50,7 @@ import {
50
50
  queuedSessions,
51
51
  } from "./queued";
52
52
  import { fallbackFileSuggestions } from "./files";
53
+ import { REQUESTED_FRESH, releaseConversation } from "./refresh";
53
54
  import { holdsQueue, settingUp } from "../sessions/setup-progress";
54
55
  import { firstMessageId, launchPending } from "../tasks/launch-gate";
55
56
  import type { ChatItem, FileSuggestion } from "./events";
@@ -280,6 +281,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
280
281
  build: hello.build,
281
282
  canPlan: !!hello.caps?.includes("plan"),
282
283
  canQueue: !!hello.caps?.includes("queue"),
284
+ canFresh: !!hello.caps?.includes("fresh"),
283
285
  };
284
286
  // From an older build and busy, or running background work: it goes at
285
287
  // its next turn boundary, by itself if it can. Messages meanwhile wait
@@ -716,6 +718,54 @@ function retire(sessionId: string, live: Live, resume = true): void {
716
718
  if (resume) void resumeQueue(sessionId);
717
719
  }
718
720
 
721
+ // A fresh start the reader asked for, by the path the context threshold
722
+ // takes (the worker's endConversation): "now" when nothing is running, else
723
+ // "after-turn", at the next turn boundary. The next worker reads the brief
724
+ // and permissions as the server has them then, nothing held over.
725
+ export class FreshRefusal extends Error {}
726
+
727
+ export async function freshStartChat(
728
+ sessionId: string
729
+ ): Promise<"now" | "after-turn"> {
730
+ const session = getSession(sessionId);
731
+ // A terminal agent's conversation isn't ours to release: it keeps running.
732
+ if (session.view !== "chat" || session.archived_at)
733
+ throw new FreshRefusal("Only an open chat session can start fresh");
734
+ try {
735
+ checkChattable(session);
736
+ } catch (e) {
737
+ throw new FreshRefusal(e instanceof Error ? e.message : String(e));
738
+ }
739
+ const live =
740
+ registry.live.get(sessionId) ??
741
+ (runningWorkers().includes(sessionId)
742
+ ? await ensureLive(sessionId, false).catch(() => undefined)
743
+ : undefined);
744
+ if (live) {
745
+ // A worker from before this existed would ignore the command.
746
+ if (live.canFresh) {
747
+ live.worker.command({ type: "fresh" });
748
+ return live.state === "idle" && !live.activity.tasks.size
749
+ ? "now"
750
+ : "after-turn";
751
+ }
752
+ if (live.state !== "idle")
753
+ throw new FreshRefusal("Try again when the current turn has ended");
754
+ stopChat(sessionId);
755
+ }
756
+ releaseConversation(sessionId, REQUESTED_FRESH);
757
+ const item: ChatItem = {
758
+ id: `note-refresh-${Date.now()}`,
759
+ kind: "note",
760
+ tone: "note",
761
+ text: `Fresh start: the agent starts a new conversation with its brief${session.role === "orchestrator" ? " and the current state from AgentOS" : ""} at its next message.`,
762
+ createdAt: Date.now(),
763
+ };
764
+ saveItem(sessionId, item);
765
+ emit(sessionId, { type: "item", item });
766
+ return "now";
767
+ }
768
+
719
769
  export function stopChat(sessionId: string): void {
720
770
  const live = registry.live.get(sessionId);
721
771
  if (live) {
@@ -41,7 +41,13 @@ import {
41
41
  visualsTools,
42
42
  } from "../../artifacts/tools";
43
43
  import type { RoleExtras } from "./extras";
44
- import { compactThreshold, refreshSettings, refreshedNote } from "../refresh";
44
+ import {
45
+ REQUESTED_FRESH,
46
+ compactThreshold,
47
+ refreshSettings,
48
+ refreshedNote,
49
+ releaseConversation,
50
+ } from "../refresh";
45
51
  import { compactContext } from "../compact-context";
46
52
  import { COMPACT_BRIEF, COMPACT_INSTRUCTIONS } from "../working-state";
47
53
  import { compactionDue, staleLabel, type Staleness } from "../stale";
@@ -679,11 +685,32 @@ export class ChatHost {
679
685
  case "retire":
680
686
  this.retiring = true;
681
687
  return this.retireIfDone();
688
+ case "fresh":
689
+ return this.freshOnRequest();
682
690
  case "close":
683
691
  return this.close();
684
692
  }
685
693
  }
686
694
 
695
+ // A fresh start the reader asked for: the same retire as at the context
696
+ // threshold, so it waits for the turn boundary and says so meanwhile.
697
+ private freshOnRequest(): void {
698
+ if (this.refreshAt || this.closed) return;
699
+ this.refreshAt = REQUESTED_FRESH;
700
+ this.retiring = true;
701
+ this.retireWaited = true;
702
+ this.retireIfDone();
703
+ // Still here: a turn, background work or its rest timer holds it.
704
+ if (!this.closed)
705
+ this.record({
706
+ id: `note-fresh-pending-${Date.now()}`,
707
+ kind: "note",
708
+ tone: "note",
709
+ text: "Starts fresh after this turn",
710
+ createdAt: Date.now(),
711
+ });
712
+ }
713
+
687
714
  private hardStopped(): boolean {
688
715
  return this.hardStopAt > 0 && Date.now() - this.hardStopAt < HARD_STOP_MS;
689
716
  }
@@ -849,20 +876,19 @@ export class ChatHost {
849
876
  // with the brief and tells it why. The chat Saad reads goes on, with a
850
877
  // line saying where it started over.
851
878
  private endConversation(): void {
852
- db.prepare(
853
- `UPDATE sessions SET claude_session_id = NULL, chat_resume_at = NULL,
854
- chat_suggestion = NULL, chat_context = NULL, chat_restarted = 0,
855
- chat_refreshed = ? WHERE id = ?`
856
- ).run(this.refreshAt, this.session.id);
879
+ releaseConversation(this.session.id, this.refreshAt);
857
880
  const brief = `its brief${this.session.role === "orchestrator" ? " and the current state from AgentOS" : ""}`;
858
881
  const stale = this.staleRefresh;
882
+ const requested = this.refreshAt === REQUESTED_FRESH;
859
883
  this.record({
860
884
  id: `note-refresh-${Date.now()}`,
861
885
  kind: "note",
862
886
  tone: "note",
863
- text: stale
864
- ? `${staleLabel(stale)}, past its prompt cache: the agent starts a fresh conversation with ${brief} before the next message.`
865
- : `Context reached ${Math.round(this.refreshAt / 1000)}k tokens: the agent starts a fresh conversation with ${brief} at its next message.`,
887
+ text: requested
888
+ ? `Fresh start: the agent starts a new conversation with ${brief} at its next message.`
889
+ : stale
890
+ ? `${staleLabel(stale)}, past its prompt cache: the agent starts a fresh conversation with ${brief} before the next message.`
891
+ : `Context reached ${Math.round(this.refreshAt / 1000)}k tokens: the agent starts a fresh conversation with ${brief} at its next message.`,
866
892
  createdAt: Date.now(),
867
893
  });
868
894
  if (stale)
@@ -75,7 +75,7 @@ async function main(sessionId: string) {
75
75
  build: buildId(),
76
76
  state: host.state,
77
77
  streaming: [...host.streaming.values()],
78
- caps: ["plan", "queue", "retire"],
78
+ caps: ["plan", "queue", "retire", "fresh"],
79
79
  } satisfies WorkerEvent)}\n`
80
80
  );
81
81
  client.on(
@@ -58,6 +58,10 @@ export type WorkerCommand =
58
58
  // wait in the queue) and closes at its next turn boundary, so a current
59
59
  // worker resumes the conversation with today's tools.
60
60
  | { type: "retire" }
61
+ // The reader asked for a fresh conversation: at the next turn boundary
62
+ // (now, if idle) the agent's conversation ends and the next worker
63
+ // starts a new one with today's brief and permissions.
64
+ | { type: "fresh" }
61
65
  | { type: "close" };
62
66
 
63
67
  // Worker -> server: what's live right now on connecting, then events.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.493",
3
+ "version": "0.3.495",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"