@saadnvd1/agent-os 0.3.489 → 0.3.494
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 +38 -2
- package/app/api/sessions/[id]/fresh/route.ts +31 -0
- package/components/Chat/ChatPanel.tsx +19 -3
- package/components/Chat/Tools.tsx +9 -3
- package/components/Pane/index.tsx +4 -0
- package/components/SessionList/OrchestratorPinRow.test.ts +3 -0
- package/components/SessionList/OrchestratorPinRow.tsx +36 -0
- package/components/SessionList/RowContext.tsx +5 -0
- package/components/SessionList/SessionRow.tsx +17 -4
- package/components/SessionList/SessionRowMenu.tsx +10 -0
- package/components/SessionList/SessionShelves.tsx +8 -2
- package/components/SessionList/SidebarBody.tsx +17 -1
- package/components/SessionList/index.tsx +23 -3
- package/components/SessionList/useSidebarData.ts +64 -18
- package/data/chat/useChat.ts +12 -1
- package/data/sessions/fresh.ts +20 -0
- package/data/sessions/index.ts +1 -0
- package/lib/chat/bodyCache.ts +69 -0
- package/lib/chat/commands.ts +16 -0
- package/lib/chat/refresh.ts +18 -0
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +50 -0
- package/lib/chat/store.ts +89 -0
- package/lib/chat/worker/host.ts +46 -12
- package/lib/chat/worker/main.ts +1 -1
- package/lib/chat/worker/protocol.ts +4 -0
- package/lib/orchestrator/brief.ts +1 -1
- package/lib/orchestrator/shell-guard.ts +536 -0
- package/lib/orchestrator/shell-parse.ts +289 -0
- package/lib/orchestrator/tool-names.ts +13 -22
- package/lib/orchestrator/write-guard.ts +54 -57
- package/lib/sidebar/queued.ts +3 -0
- package/lib/sidebar/rollup.ts +58 -0
- package/lib/sidebar/shelves.ts +24 -0
- package/lib/tasks/queue.ts +3 -0
- package/package.json +1 -1
- package/stores/sidebarUi.ts +15 -3
- package/lib/orchestrator/git-guard.ts +0 -201
package/data/chat/useChat.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
UndoPreview,
|
|
20
20
|
} from "@/lib/chat/events";
|
|
21
21
|
import type { SearchResult } from "@/lib/chat/find";
|
|
22
|
+
import { addBody, touchBody } from "@/lib/chat/bodyCache";
|
|
22
23
|
|
|
23
24
|
// The index of the item with `id`, searched from the end: what changes is
|
|
24
25
|
// almost always the latest.
|
|
@@ -129,6 +130,13 @@ export function useChat(
|
|
|
129
130
|
const [toolBodies, setToolBodies] = useState<Record<string, ToolBody | null>>(
|
|
130
131
|
{}
|
|
131
132
|
);
|
|
133
|
+
// Bodies open on screen, never evicted from toolBodies.
|
|
134
|
+
const visibleBodies = useRef(new Set<string>());
|
|
135
|
+
const pinToolBody = useCallback((id: string) => {
|
|
136
|
+
visibleBodies.current.add(id);
|
|
137
|
+
setToolBodies((prev) => touchBody(prev, id));
|
|
138
|
+
return () => void visibleBodies.current.delete(id);
|
|
139
|
+
}, []);
|
|
132
140
|
const [state, setState] = useState<ChatState>("idle");
|
|
133
141
|
const [connected, setConnected] = useState(false);
|
|
134
142
|
const [commands, setCommands] = useState<ChatCommand[]>([]);
|
|
@@ -249,7 +257,9 @@ export function useChat(
|
|
|
249
257
|
const have = new Set(prev.map((p) => p.id));
|
|
250
258
|
setItems(m.items.filter((i) => !have.has(i.id)).concat(prev));
|
|
251
259
|
} else if (m.type === "tool_body") {
|
|
252
|
-
setToolBodies((prev) =>
|
|
260
|
+
setToolBodies((prev) =>
|
|
261
|
+
addBody(prev, m.id, m.body, visibleBodies.current)
|
|
262
|
+
);
|
|
253
263
|
} else if (m.type === "item") {
|
|
254
264
|
withDeltas(() => {
|
|
255
265
|
const routed = routeItem(
|
|
@@ -457,6 +467,7 @@ export function useChat(
|
|
|
457
467
|
earlierTasks,
|
|
458
468
|
toolBodies,
|
|
459
469
|
requestToolBody,
|
|
470
|
+
pinToolBody,
|
|
460
471
|
state,
|
|
461
472
|
connected,
|
|
462
473
|
commands,
|
|
@@ -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
|
+
}
|
package/data/sessions/index.ts
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ToolBody } from "./events";
|
|
2
|
+
|
|
3
|
+
// Opened tool output kept in memory, least recently used first out.
|
|
4
|
+
export const MAX_LOADED_BODIES = 40;
|
|
5
|
+
export const MAX_LOADED_BODY_CHARS = 5_000_000;
|
|
6
|
+
|
|
7
|
+
export type Bodies = Record<string, ToolBody | null>;
|
|
8
|
+
|
|
9
|
+
export function bodySize(body: ToolBody | null): number {
|
|
10
|
+
if (!body) return 0;
|
|
11
|
+
const d = body.diff;
|
|
12
|
+
return (
|
|
13
|
+
(body.output?.length ?? 0) +
|
|
14
|
+
(d ? d.path.length + d.before.length + d.after.length : 0)
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Object keys keep insertion order, so the first key is the least recently
|
|
19
|
+
// used. Puts `id` last, loaded or being opened again.
|
|
20
|
+
export function touchBody(bodies: Bodies, id: string): Bodies {
|
|
21
|
+
if (!(id in bodies) || Object.keys(bodies).pop() === id) return bodies;
|
|
22
|
+
const { [id]: body, ...rest } = bodies;
|
|
23
|
+
return { ...rest, [id]: body };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Drops the least recently used bodies until both limits hold. A body on
|
|
27
|
+
// screen stays, even when that leaves the cache over a limit.
|
|
28
|
+
export function capBodies(
|
|
29
|
+
bodies: Bodies,
|
|
30
|
+
visible: ReadonlySet<string>
|
|
31
|
+
): Bodies {
|
|
32
|
+
const ids = Object.keys(bodies);
|
|
33
|
+
let count = ids.length;
|
|
34
|
+
let chars = ids.reduce((n, id) => n + bodySize(bodies[id]), 0);
|
|
35
|
+
if (count <= MAX_LOADED_BODIES && chars <= MAX_LOADED_BODY_CHARS)
|
|
36
|
+
return bodies;
|
|
37
|
+
const drop = new Set<string>();
|
|
38
|
+
for (const id of ids) {
|
|
39
|
+
if (count <= MAX_LOADED_BODIES && chars <= MAX_LOADED_BODY_CHARS) break;
|
|
40
|
+
if (visible.has(id)) continue;
|
|
41
|
+
drop.add(id);
|
|
42
|
+
count--;
|
|
43
|
+
chars -= bodySize(bodies[id]);
|
|
44
|
+
}
|
|
45
|
+
return Object.fromEntries(
|
|
46
|
+
ids.filter((id) => !drop.has(id)).map((id) => [id, bodies[id]])
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A body arriving: it is the most recently used.
|
|
51
|
+
export function addBody(
|
|
52
|
+
bodies: Bodies,
|
|
53
|
+
id: string,
|
|
54
|
+
body: ToolBody | null,
|
|
55
|
+
visible: ReadonlySet<string>
|
|
56
|
+
): Bodies {
|
|
57
|
+
const { [id]: _old, ...rest } = bodies;
|
|
58
|
+
return capBodies({ ...rest, [id]: body }, visible);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Whether an opened tool must ask for its body: deferred, not loaded (never
|
|
62
|
+
// was, or evicted), and the socket is up.
|
|
63
|
+
export function needsBody(
|
|
64
|
+
deferred: boolean | undefined,
|
|
65
|
+
body: ToolBody | null | undefined,
|
|
66
|
+
connected: boolean
|
|
67
|
+
): boolean {
|
|
68
|
+
return !!deferred && body === undefined && connected;
|
|
69
|
+
}
|
package/lib/chat/commands.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/chat/refresh.ts
CHANGED
|
@@ -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.)`;
|
package/lib/chat/registry.ts
CHANGED
|
@@ -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;
|
package/lib/chat/runner.ts
CHANGED
|
@@ -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) {
|
package/lib/chat/store.ts
CHANGED
|
@@ -4,22 +4,102 @@ import type { ChatItem } from "./events";
|
|
|
4
4
|
// Items keep the position they were first written at, so replacing one
|
|
5
5
|
// (a finished tool call, a streamed message's final text) never reorders.
|
|
6
6
|
export function saveItem(sessionId: string, item: ChatItem): void {
|
|
7
|
+
flushItems();
|
|
7
8
|
db.prepare(
|
|
8
9
|
`INSERT INTO chat_items (session_id, item_id, seq, data)
|
|
9
10
|
VALUES (?, ?, (SELECT COALESCE(MAX(seq), 0) + 1 FROM chat_items WHERE session_id = ?), ?)
|
|
10
11
|
ON CONFLICT(session_id, item_id) DO UPDATE SET data = excluded.data`
|
|
11
12
|
).run(sessionId, item.id, sessionId, JSON.stringify(item));
|
|
13
|
+
seqs.delete(sessionId);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// The streaming path. Every update of an item is a write, so they're held
|
|
17
|
+
// in memory (the latest state of each item) and written together, in one
|
|
18
|
+
// transaction, every FLUSH_MS. Callers flush at anything that mustn't be
|
|
19
|
+
// lost: an item finishing, a turn ending, an error, an interrupt, a close.
|
|
20
|
+
// Every reader here flushes first; one that queries the table itself may be
|
|
21
|
+
// up to FLUSH_MS behind.
|
|
22
|
+
export const FLUSH_MS = 250;
|
|
23
|
+
|
|
24
|
+
type Pending = { seq: number; data: string };
|
|
25
|
+
type Seqs = { next: number; of: Map<string, number> };
|
|
26
|
+
|
|
27
|
+
const pending = new Map<string, Map<string, Pending>>();
|
|
28
|
+
const seqs = new Map<string, Seqs>();
|
|
29
|
+
let timer: NodeJS.Timeout | undefined;
|
|
30
|
+
|
|
31
|
+
// Seeded once from the table, then kept: an item gets its seq the first
|
|
32
|
+
// time it's seen, the same one it would have got from MAX(seq) + 1.
|
|
33
|
+
function seqsOf(sessionId: string): Seqs {
|
|
34
|
+
let s = seqs.get(sessionId);
|
|
35
|
+
if (!s) {
|
|
36
|
+
const row = db
|
|
37
|
+
.prepare(
|
|
38
|
+
`SELECT COALESCE(MAX(seq), 0) AS m FROM chat_items WHERE session_id = ?`
|
|
39
|
+
)
|
|
40
|
+
.get(sessionId) as { m: number };
|
|
41
|
+
s = { next: row.m + 1, of: new Map() };
|
|
42
|
+
seqs.set(sessionId, s);
|
|
43
|
+
}
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function queueItem(
|
|
48
|
+
sessionId: string,
|
|
49
|
+
item: ChatItem,
|
|
50
|
+
{ flush = false }: { flush?: boolean } = {}
|
|
51
|
+
): void {
|
|
52
|
+
const s = seqsOf(sessionId);
|
|
53
|
+
let seq = s.of.get(item.id);
|
|
54
|
+
if (seq === undefined) {
|
|
55
|
+
seq = s.next++;
|
|
56
|
+
s.of.set(item.id, seq);
|
|
57
|
+
}
|
|
58
|
+
let items = pending.get(sessionId);
|
|
59
|
+
if (!items) pending.set(sessionId, (items = new Map()));
|
|
60
|
+
items.set(item.id, { seq, data: JSON.stringify(item) });
|
|
61
|
+
if (flush) flushItems();
|
|
62
|
+
else if (!timer) {
|
|
63
|
+
timer = setTimeout(flushItems, FLUSH_MS);
|
|
64
|
+
timer.unref();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function flushItems(): void {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
timer = undefined;
|
|
71
|
+
if (!pending.size) return;
|
|
72
|
+
try {
|
|
73
|
+
const insert = db.prepare(
|
|
74
|
+
`INSERT INTO chat_items (session_id, item_id, seq, data) VALUES (?, ?, ?, ?)
|
|
75
|
+
ON CONFLICT(session_id, item_id) DO UPDATE SET data = excluded.data`
|
|
76
|
+
);
|
|
77
|
+
db.transaction(() => {
|
|
78
|
+
for (const [sessionId, items] of pending)
|
|
79
|
+
for (const [itemId, p] of items)
|
|
80
|
+
insert.run(sessionId, itemId, p.seq, p.data);
|
|
81
|
+
})();
|
|
82
|
+
pending.clear();
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// Kept for the next try rather than lost.
|
|
85
|
+
console.error("Chat items not written:", error);
|
|
86
|
+
timer = setTimeout(flushItems, FLUSH_MS);
|
|
87
|
+
timer.unref();
|
|
88
|
+
}
|
|
12
89
|
}
|
|
13
90
|
|
|
14
91
|
// Moves an item after every other, as if first written now.
|
|
15
92
|
export function moveToEnd(sessionId: string, itemId: string): void {
|
|
93
|
+
flushItems();
|
|
16
94
|
db.prepare(
|
|
17
95
|
`UPDATE chat_items SET seq = (SELECT MAX(seq) + 1 FROM chat_items WHERE session_id = ?)
|
|
18
96
|
WHERE session_id = ? AND item_id = ?`
|
|
19
97
|
).run(sessionId, sessionId, itemId);
|
|
98
|
+
seqs.delete(sessionId);
|
|
20
99
|
}
|
|
21
100
|
|
|
22
101
|
export function listItems(sessionId: string): ChatItem[] {
|
|
102
|
+
flushItems();
|
|
23
103
|
return (
|
|
24
104
|
db
|
|
25
105
|
.prepare(`SELECT data FROM chat_items WHERE session_id = ? ORDER BY seq`)
|
|
@@ -31,6 +111,7 @@ export function getItem(
|
|
|
31
111
|
sessionId: string,
|
|
32
112
|
itemId: string
|
|
33
113
|
): ChatItem | undefined {
|
|
114
|
+
flushItems();
|
|
34
115
|
const row = db
|
|
35
116
|
.prepare(`SELECT data FROM chat_items WHERE session_id = ? AND item_id = ?`)
|
|
36
117
|
.get(sessionId, itemId) as { data: string } | undefined;
|
|
@@ -38,6 +119,7 @@ export function getItem(
|
|
|
38
119
|
}
|
|
39
120
|
|
|
40
121
|
export function hasItem(sessionId: string, itemId: string): boolean {
|
|
122
|
+
flushItems();
|
|
41
123
|
return !!db
|
|
42
124
|
.prepare(`SELECT 1 FROM chat_items WHERE session_id = ? AND item_id = ?`)
|
|
43
125
|
.get(sessionId, itemId);
|
|
@@ -49,6 +131,7 @@ export function lastItems(
|
|
|
49
131
|
n: number,
|
|
50
132
|
kind?: ChatItem["kind"]
|
|
51
133
|
): ChatItem[] {
|
|
134
|
+
flushItems();
|
|
52
135
|
const rows = (
|
|
53
136
|
kind
|
|
54
137
|
? db
|
|
@@ -69,6 +152,7 @@ export function lastItems(
|
|
|
69
152
|
// What the agent has said since the last message to it, as text: where a
|
|
70
153
|
// chat task's BLOCKED: line stays until someone answers it.
|
|
71
154
|
export function saidSinceLastMessage(sessionId: string, n = 20): string {
|
|
155
|
+
flushItems();
|
|
72
156
|
const items = lastItems(sessionId, n);
|
|
73
157
|
return items
|
|
74
158
|
.slice(items.findLastIndex((i) => i.kind === "user") + 1)
|
|
@@ -81,6 +165,7 @@ export function itemsOfKind<K extends ChatItem["kind"]>(
|
|
|
81
165
|
sessionId: string,
|
|
82
166
|
kind: K
|
|
83
167
|
): Extract<ChatItem, { kind: K }>[] {
|
|
168
|
+
flushItems();
|
|
84
169
|
return (
|
|
85
170
|
db
|
|
86
171
|
.prepare(
|
|
@@ -95,6 +180,7 @@ export function itemsOfKind<K extends ChatItem["kind"]>(
|
|
|
95
180
|
export function runningTools(
|
|
96
181
|
sessionId: string
|
|
97
182
|
): Extract<ChatItem, { kind: "tool" }>[] {
|
|
183
|
+
flushItems();
|
|
98
184
|
return (
|
|
99
185
|
db
|
|
100
186
|
.prepare(
|
|
@@ -107,6 +193,8 @@ export function runningTools(
|
|
|
107
193
|
}
|
|
108
194
|
|
|
109
195
|
export function deleteItems(sessionId: string): void {
|
|
196
|
+
pending.delete(sessionId);
|
|
197
|
+
seqs.delete(sessionId);
|
|
110
198
|
db.prepare(`DELETE FROM chat_items WHERE session_id = ?`).run(sessionId);
|
|
111
199
|
}
|
|
112
200
|
|
|
@@ -135,6 +223,7 @@ export function settle(items: ChatItem[]): ChatItem[] {
|
|
|
135
223
|
|
|
136
224
|
// What a chat session is working on: the first line of its latest message.
|
|
137
225
|
export function lastUserTask(sessionId: string): string | null {
|
|
226
|
+
flushItems();
|
|
138
227
|
const row = db
|
|
139
228
|
.prepare(
|
|
140
229
|
`SELECT data FROM chat_items WHERE session_id = ? AND item_id LIKE 'user-%'
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -22,9 +22,10 @@ import type {
|
|
|
22
22
|
} from "../events";
|
|
23
23
|
import {
|
|
24
24
|
itemsOfKind,
|
|
25
|
+
flushItems,
|
|
25
26
|
moveToEnd,
|
|
27
|
+
queueItem,
|
|
26
28
|
runningTools,
|
|
27
|
-
saveItem,
|
|
28
29
|
settle,
|
|
29
30
|
} from "../store";
|
|
30
31
|
import { claimNext, enqueue, listQueue, moveToFront } from "../queued";
|
|
@@ -40,7 +41,13 @@ import {
|
|
|
40
41
|
visualsTools,
|
|
41
42
|
} from "../../artifacts/tools";
|
|
42
43
|
import type { RoleExtras } from "./extras";
|
|
43
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
REQUESTED_FRESH,
|
|
46
|
+
compactThreshold,
|
|
47
|
+
refreshSettings,
|
|
48
|
+
refreshedNote,
|
|
49
|
+
releaseConversation,
|
|
50
|
+
} from "../refresh";
|
|
44
51
|
import { compactContext } from "../compact-context";
|
|
45
52
|
import { COMPACT_BRIEF, COMPACT_INSTRUCTIONS } from "../working-state";
|
|
46
53
|
import { compactionDue, staleLabel, type Staleness } from "../stale";
|
|
@@ -261,7 +268,10 @@ export class ChatHost {
|
|
|
261
268
|
}
|
|
262
269
|
|
|
263
270
|
private record(item: ChatItem): void {
|
|
264
|
-
|
|
271
|
+
// Streaming updates are written in batches; anything else right away.
|
|
272
|
+
queueItem(this.session.id, item, {
|
|
273
|
+
flush: !("streaming" in item && item.streaming),
|
|
274
|
+
});
|
|
265
275
|
this.emit({ type: "item", item });
|
|
266
276
|
}
|
|
267
277
|
|
|
@@ -354,6 +364,7 @@ export class ChatHost {
|
|
|
354
364
|
}
|
|
355
365
|
} else if (e.type === "state") {
|
|
356
366
|
if (e.state === "idle") {
|
|
367
|
+
flushItems();
|
|
357
368
|
// A turn cut off mid-sentence (Esc, Stop) keeps what it streamed.
|
|
358
369
|
this.settleStreaming();
|
|
359
370
|
this.closeTools();
|
|
@@ -406,6 +417,7 @@ export class ChatHost {
|
|
|
406
417
|
this.settleStreaming();
|
|
407
418
|
this.closeTools();
|
|
408
419
|
this.showHandedOver();
|
|
420
|
+
flushItems();
|
|
409
421
|
this.setState("idle");
|
|
410
422
|
}
|
|
411
423
|
}
|
|
@@ -485,7 +497,7 @@ export class ChatHost {
|
|
|
485
497
|
checkpoint,
|
|
486
498
|
};
|
|
487
499
|
if (handOver) {
|
|
488
|
-
|
|
500
|
+
queueItem(this.session.id, user, { flush: true });
|
|
489
501
|
this.handedOver.push(user);
|
|
490
502
|
} else this.record(user);
|
|
491
503
|
this.setState("running");
|
|
@@ -631,6 +643,7 @@ export class ChatHost {
|
|
|
631
643
|
}
|
|
632
644
|
return;
|
|
633
645
|
case "interrupt":
|
|
646
|
+
flushItems();
|
|
634
647
|
await this.modeChange.catch(() => {});
|
|
635
648
|
if (cmd.hard) {
|
|
636
649
|
this.hardStopAt = Date.now();
|
|
@@ -672,11 +685,32 @@ export class ChatHost {
|
|
|
672
685
|
case "retire":
|
|
673
686
|
this.retiring = true;
|
|
674
687
|
return this.retireIfDone();
|
|
688
|
+
case "fresh":
|
|
689
|
+
return this.freshOnRequest();
|
|
675
690
|
case "close":
|
|
676
691
|
return this.close();
|
|
677
692
|
}
|
|
678
693
|
}
|
|
679
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
|
+
|
|
680
714
|
private hardStopped(): boolean {
|
|
681
715
|
return this.hardStopAt > 0 && Date.now() - this.hardStopAt < HARD_STOP_MS;
|
|
682
716
|
}
|
|
@@ -842,20 +876,19 @@ export class ChatHost {
|
|
|
842
876
|
// with the brief and tells it why. The chat Saad reads goes on, with a
|
|
843
877
|
// line saying where it started over.
|
|
844
878
|
private endConversation(): void {
|
|
845
|
-
|
|
846
|
-
`UPDATE sessions SET claude_session_id = NULL, chat_resume_at = NULL,
|
|
847
|
-
chat_suggestion = NULL, chat_context = NULL, chat_restarted = 0,
|
|
848
|
-
chat_refreshed = ? WHERE id = ?`
|
|
849
|
-
).run(this.refreshAt, this.session.id);
|
|
879
|
+
releaseConversation(this.session.id, this.refreshAt);
|
|
850
880
|
const brief = `its brief${this.session.role === "orchestrator" ? " and the current state from AgentOS" : ""}`;
|
|
851
881
|
const stale = this.staleRefresh;
|
|
882
|
+
const requested = this.refreshAt === REQUESTED_FRESH;
|
|
852
883
|
this.record({
|
|
853
884
|
id: `note-refresh-${Date.now()}`,
|
|
854
885
|
kind: "note",
|
|
855
886
|
tone: "note",
|
|
856
|
-
text:
|
|
857
|
-
?
|
|
858
|
-
:
|
|
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.`,
|
|
859
892
|
createdAt: Date.now(),
|
|
860
893
|
});
|
|
861
894
|
if (stale)
|
|
@@ -877,6 +910,7 @@ export class ChatHost {
|
|
|
877
910
|
clearTimeout(this.retireTimer);
|
|
878
911
|
clearTimeout(this.restTimer);
|
|
879
912
|
this.closed = true;
|
|
913
|
+
flushItems();
|
|
880
914
|
this.onClose();
|
|
881
915
|
this.conversation.close();
|
|
882
916
|
}
|
package/lib/chat/worker/main.ts
CHANGED
|
@@ -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.
|
|
@@ -124,7 +124,7 @@ Acting:
|
|
|
124
124
|
- \`${TOOL_NAMES.disarm}\` (task): stop an armed task merging by itself.
|
|
125
125
|
- \`${TOOL_NAMES.ask_saad}\` (title, detail, link?, kind, task?, sha?): park an item on Saad's asks list and carry on; it never waits.
|
|
126
126
|
|
|
127
|
-
Act through these tools, not the shell: they're scoped to this workspace and braked. The shell runs
|
|
127
|
+
Act through these tools, not the shell: they're scoped to this workspace and braked. The shell runs anything without asking (Saad chose this on 2026-10-11), so use it to read and investigate yourself: \`gh pr list\`, \`gh run view --log-failed\`, \`gh api\` GETs, \`curl\`, \`sqlite3 -readonly\`, git in any repository, a skill's scripts, pipes and chains. Never use it to get around the tools. A deny list refuses, naming the tool to use instead: merging, closing or retargeting a PR (\`${TOOL_NAMES.sign_off}\`, \`${TOOL_NAMES.arm}\`, \`${TOOL_NAMES.land}\`, \`${TOOL_NAMES.drop}\`), \`git push\` (\`${TOOL_NAMES.quick_pr}\`), writing in a project repository or task worktree other than your own quick changes (\`${TOOL_NAMES.start_task}\`, \`${TOOL_NAMES.quick_change}\`), writing AgentOS's state (sqlite3 without \`-readonly\`, or files in ~/.agent-os outside your folder), stopping or restarting AgentOS or its tmux, and reading secret files or tokenvault. The list catches honest mistakes, not a determined bypass: a script or \`python -c\` could do any of these unseen, and doing so would break Saad's trust in every gate. \`aos notify "<text>"\` pushes a message to Saad's phone. Use it for what he'd want to see now and asked for: the morning report, a real milestone (a stack landed, a blocker only he can clear). Never for routine status: one a minute at most, and he reads the rest here. You can read files anywhere (Read, Grep, Glob), and write them (Write, Edit, NotebookEdit) anywhere except: a project repository or task worktree other than your own open quick changes' worktrees (start a task, or a quick change for a small edit, so the change goes through CI, review and scope), AgentOS's own state in ~/.agent-os outside your folder, secret files (.env, keys, ~/.ssh, ~/.aws, tokenvault and the like), and files that make something run: dotfiles and dot-folders at the top of your home (shell, git, Claude and tool config), ~/Library and /Library's launch agents, git's core.hooksPath folders, the folders on PATH, and any .git, .claude, .mcp.json, CLAUDE.md or AGENTS.md, which would hand you a shell or run code around the gates. Each write is checked before it runs, on the path with symlinks and \`..\` resolved; a refused one says why.
|
|
128
128
|
|
|
129
129
|
## Untrusted text
|
|
130
130
|
|