@saadnvd1/agent-os 0.3.585 → 0.3.588

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.
Files changed (49) hide show
  1. package/README.md +1 -0
  2. package/app/api/bookmarks/[id]/route.ts +35 -0
  3. package/app/api/bookmarks/route.test.ts +103 -0
  4. package/app/api/bookmarks/route.ts +18 -0
  5. package/app/api/sessions/[id]/bookmarks/route.ts +38 -0
  6. package/app/api/tasks/[id]/merge/route.ts +5 -1
  7. package/app/globals.css +1 -0
  8. package/app/page.tsx +16 -0
  9. package/components/Bookmarks/BookmarkButton.tsx +149 -0
  10. package/components/Bookmarks/BookmarkRow.tsx +161 -0
  11. package/components/Bookmarks/BookmarksDialog.tsx +186 -0
  12. package/components/Bookmarks/ChatBookmarks.tsx +58 -0
  13. package/components/Bookmarks/ChatBookmarksButton.tsx +38 -0
  14. package/components/Bookmarks/EmptyBookmarks.tsx +20 -0
  15. package/components/Bookmarks/context.ts +41 -0
  16. package/components/Bookmarks/helpers.test.ts +69 -0
  17. package/components/Bookmarks/helpers.ts +65 -0
  18. package/components/Bookmarks/index.ts +4 -0
  19. package/components/Bookmarks/useBookmarkToggle.ts +51 -0
  20. package/components/Chat/ChatPanel.tsx +73 -32
  21. package/components/Chat/Messages.test.ts +14 -9
  22. package/components/Chat/Messages.tsx +59 -43
  23. package/components/Chat/Tools.tsx +3 -1
  24. package/components/Chat/composer/ComposerBody.tsx +13 -5
  25. package/components/Chat/useLongPress.ts +43 -0
  26. package/components/CommandPalette/useAppCommands.ts +10 -0
  27. package/components/Pane/DesktopTabBar.tsx +10 -0
  28. package/components/Pane/MobileTabBar.tsx +10 -0
  29. package/components/Pane/index.tsx +8 -0
  30. package/components/SessionList/SidebarHeader.tsx +5 -0
  31. package/data/bookmarks/index.ts +2 -0
  32. package/data/bookmarks/keys.ts +7 -0
  33. package/data/bookmarks/queries.ts +146 -0
  34. package/data/chat/useChat.ts +4 -0
  35. package/data/push/topics.ts +3 -0
  36. package/lib/bookmarks/body.ts +23 -0
  37. package/lib/bookmarks/snapshot.ts +33 -0
  38. package/lib/bookmarks/store.ts +333 -0
  39. package/lib/bookmarks/types.ts +38 -0
  40. package/lib/chat/commands.ts +9 -5
  41. package/lib/db/changes.ts +3 -0
  42. package/lib/db/migrations.ts +28 -0
  43. package/lib/tasks/finish.ts +26 -4
  44. package/lib/tasks/move-bundle.ts +28 -0
  45. package/lib/tasks/move-chat.ts +10 -1
  46. package/lib/tasks/remote.ts +21 -3
  47. package/package.json +1 -1
  48. package/stores/bookmarksUi.ts +50 -0
  49. package/styles/themes.css +3 -0
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The text a bookmark keeps of what it marks: readable, clipped, and with
3
+ * secrets masked so a key in a tool's output doesn't sit in a second table.
4
+ */
5
+
6
+ import type { ChatItem } from "../chat/events";
7
+ import { toRaw } from "../share/snapshot";
8
+ import { NO_CONTEXT, redactForShare } from "../share/redact";
9
+ import { envSecrets } from "../share/context";
10
+ import { MAX_SNAPSHOT_CHARS } from "./types";
11
+
12
+ function plain(item: ChatItem): string {
13
+ const raw = toRaw(item);
14
+ if (!raw) return "";
15
+ return raw.label ? `${raw.label}\n${raw.text}` : raw.text;
16
+ }
17
+
18
+ // A turn reads as what was asked and the start of the answer.
19
+ export function snapshotText(item: ChatItem, reply?: ChatItem): string {
20
+ const text = [plain(item), reply ? plain(reply) : ""]
21
+ .filter((t) => t.trim())
22
+ .join("\n\n")
23
+ .trim();
24
+ // Redacted before the cut, so a secret the cut splits is still caught; the
25
+ // wide first cut only bounds the work on a huge tool output.
26
+ const safe = redactForShare(text.slice(0, MAX_SNAPSHOT_CHARS * 4), {
27
+ ...NO_CONTEXT,
28
+ secrets: envSecrets(),
29
+ }).text;
30
+ return safe.length > MAX_SNAPSHOT_CHARS
31
+ ? safe.slice(0, MAX_SNAPSHOT_CHARS) + "…"
32
+ : safe;
33
+ }
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Bookmarks: a person's "come back to this" marks on single chat items,
3
+ * keyed by the item's stable id. Each keeps a redacted snapshot of what it
4
+ * marks, so it can be searched and still reads after the chat is gone.
5
+ */
6
+
7
+ import { randomUUID } from "crypto";
8
+ import { db, type Session } from "../db";
9
+ import type { ChatItem } from "../chat/events";
10
+ import { getItem } from "../chat/store";
11
+ import { getProject } from "../projects";
12
+ import { snapshotText } from "./snapshot";
13
+ import {
14
+ BOOKMARKABLE_KINDS,
15
+ MAX_NOTE_CHARS,
16
+ type Bookmark,
17
+ type BookmarkKind,
18
+ type BookmarkScope,
19
+ type BookmarksPage,
20
+ } from "./types";
21
+
22
+ // An orchestrator's own workspace, or its project's (as sessionWorkspace in
23
+ // lib/schedules/store, without pulling the scheduler into a move).
24
+ const sessionWorkspace = (s: Session): string | null =>
25
+ s.workspace_id ??
26
+ (s.project_id ? (getProject(s.project_id)?.workspace_id ?? null) : null);
27
+
28
+ /** A refusal the routes turn into its status. */
29
+ export class BookmarkError extends Error {
30
+ constructor(
31
+ message: string,
32
+ readonly status: 400 | 404
33
+ ) {
34
+ super(message);
35
+ }
36
+ }
37
+
38
+ interface Row {
39
+ id: string;
40
+ session_id: string;
41
+ item_id: string;
42
+ scope: BookmarkScope;
43
+ workspace_id: string | null;
44
+ kind: BookmarkKind;
45
+ text: string;
46
+ session_name: string;
47
+ note: string | null;
48
+ created_at: string;
49
+ }
50
+
51
+ type Joined = Row & {
52
+ s_id: string | null;
53
+ task_status: string | null;
54
+ archived_at: string | null;
55
+ };
56
+
57
+ // The bookmark with its chat's state as of now.
58
+ const SELECT = `
59
+ SELECT b.*, s.id AS s_id, s.task_status, s.archived_at
60
+ FROM chat_bookmarks b LEFT JOIN sessions s ON s.id = b.session_id`;
61
+
62
+ function toBookmark(r: Joined): Bookmark {
63
+ return {
64
+ id: r.id,
65
+ sessionId: r.session_id,
66
+ itemId: r.item_id,
67
+ scope: r.scope,
68
+ kind: r.kind,
69
+ text: r.text,
70
+ note: r.note,
71
+ sessionName: r.session_name,
72
+ workspaceId: r.workspace_id,
73
+ createdAt: r.created_at,
74
+ state: !r.s_id
75
+ ? "deleted"
76
+ : r.task_status === "moved"
77
+ ? "moved"
78
+ : r.archived_at
79
+ ? "archived"
80
+ : "live",
81
+ };
82
+ }
83
+
84
+ export function getBookmark(id: string): Bookmark | null {
85
+ const r = db.prepare(`${SELECT} WHERE b.id = ?`).get(id) as
86
+ | Joined
87
+ | undefined;
88
+ return r ? toBookmark(r) : null;
89
+ }
90
+
91
+ const cleanNote = (note: unknown): string | null => {
92
+ if (note === null || note === undefined) return null;
93
+ if (typeof note !== "string") throw new BookmarkError("Bad note", 400);
94
+ const t = note.replace(/\s+/g, " ").trim().slice(0, MAX_NOTE_CHARS);
95
+ return t || null;
96
+ };
97
+
98
+ // The first reply in the turn a user item starts, for its excerpt.
99
+ function firstReply(
100
+ sessionId: string,
101
+ userItemId: string
102
+ ): ChatItem | undefined {
103
+ const rows = db
104
+ .prepare(
105
+ `SELECT data FROM chat_items
106
+ WHERE session_id = ? AND seq > (
107
+ SELECT seq FROM chat_items WHERE session_id = ? AND item_id = ?)
108
+ ORDER BY seq LIMIT 200`
109
+ )
110
+ .all(sessionId, sessionId, userItemId) as { data: string }[];
111
+ for (const r of rows) {
112
+ let item: ChatItem | null;
113
+ try {
114
+ item = JSON.parse(r.data) as ChatItem | null;
115
+ } catch {
116
+ item = null;
117
+ }
118
+ // A row that isn't an item just isn't the reply.
119
+ if (!item || typeof item !== "object") continue;
120
+ if (item.kind === "user" || item.kind === "turn_end") return undefined;
121
+ if (item.kind === "assistant" && item.text.trim()) return item;
122
+ }
123
+ return undefined;
124
+ }
125
+
126
+ /**
127
+ * Marks an item. Idempotent: the same item and scope again returns the
128
+ * bookmark it already has, with `created: false`.
129
+ */
130
+ export function createBookmark(input: {
131
+ sessionId: string;
132
+ itemId?: unknown;
133
+ scope?: unknown;
134
+ note?: unknown;
135
+ }): { bookmark: Bookmark; created: boolean } {
136
+ const { sessionId } = input;
137
+ if (typeof input.itemId !== "string" || !input.itemId)
138
+ throw new BookmarkError("itemId is required", 400);
139
+ const scope = input.scope ?? "item";
140
+ if (scope !== "item" && scope !== "turn")
141
+ throw new BookmarkError("scope must be 'item' or 'turn'", 400);
142
+ const note = cleanNote(input.note);
143
+ const session = db
144
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
145
+ .get(sessionId) as Session | undefined;
146
+ if (!session) throw new BookmarkError("Session not found", 404);
147
+ const existing = db
148
+ .prepare(
149
+ `SELECT id FROM chat_bookmarks WHERE session_id = ? AND item_id = ? AND scope = ?`
150
+ )
151
+ .get(sessionId, input.itemId, scope) as { id: string } | undefined;
152
+ if (existing) return { bookmark: getBookmark(existing.id)!, created: false };
153
+ const item = getItem(sessionId, input.itemId);
154
+ if (!item) throw new BookmarkError("Message not found", 404);
155
+ if (!(BOOKMARKABLE_KINDS as readonly string[]).includes(item.kind))
156
+ throw new BookmarkError("That can't be bookmarked", 400);
157
+ if (scope === "turn" && item.kind !== "user")
158
+ throw new BookmarkError("A turn is bookmarked by its first message", 400);
159
+ const kind = (scope === "turn" ? "turn" : item.kind) as BookmarkKind;
160
+ const text = snapshotText(
161
+ item,
162
+ scope === "turn" ? firstReply(sessionId, item.id) : undefined
163
+ );
164
+ const id = randomUUID();
165
+ db.prepare(
166
+ `INSERT INTO chat_bookmarks
167
+ (id, session_id, item_id, scope, workspace_id, kind, text, session_name, note)
168
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
169
+ ON CONFLICT (session_id, item_id, scope) DO NOTHING`
170
+ ).run(
171
+ id,
172
+ sessionId,
173
+ item.id,
174
+ scope,
175
+ sessionWorkspace(session),
176
+ kind,
177
+ text,
178
+ session.name,
179
+ note
180
+ );
181
+ // A request that raced this one may have made it first.
182
+ const made = db
183
+ .prepare(
184
+ `SELECT id FROM chat_bookmarks WHERE session_id = ? AND item_id = ? AND scope = ?`
185
+ )
186
+ .get(sessionId, item.id, scope) as { id: string };
187
+ return { bookmark: getBookmark(made.id)!, created: made.id === id };
188
+ }
189
+
190
+ /** One chat's bookmarks, in the order they appear in it. */
191
+ export function listSessionBookmarks(sessionId: string): Bookmark[] {
192
+ return (
193
+ db
194
+ .prepare(
195
+ `${SELECT}
196
+ LEFT JOIN chat_items c ON c.session_id = b.session_id AND c.item_id = b.item_id
197
+ WHERE b.session_id = ?
198
+ ORDER BY c.seq IS NULL, c.seq, b.created_at, b.id`
199
+ )
200
+ .all(sessionId) as Joined[]
201
+ ).map(toBookmark);
202
+ }
203
+
204
+ const escapeLike = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
205
+
206
+ const cursorOf = (b: Bookmark) => `${b.createdAt}|${b.id}`;
207
+
208
+ /**
209
+ * Bookmarks across chats, newest first: one workspace's (null: those with
210
+ * none) or every one's, matching `q` in the text, the note or the chat's
211
+ * name. The copy a move left behind isn't listed: its live twin is.
212
+ */
213
+ export function listBookmarks(opts: {
214
+ workspace: string | null | "all";
215
+ q?: string;
216
+ before?: string | null;
217
+ limit?: number;
218
+ }): BookmarksPage {
219
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
220
+ const where = [`(s.task_status IS NULL OR s.task_status != 'moved')`];
221
+ const args: (string | number)[] = [];
222
+ if (opts.workspace === null) where.push(`b.workspace_id IS NULL`);
223
+ else if (opts.workspace !== "all") {
224
+ where.push(`b.workspace_id = ?`);
225
+ args.push(opts.workspace);
226
+ }
227
+ const q = opts.q?.trim();
228
+ if (q) {
229
+ const like = `%${escapeLike(q)}%`;
230
+ where.push(
231
+ `(b.text LIKE ? ESCAPE '\\' OR b.note LIKE ? ESCAPE '\\' OR b.session_name LIKE ? ESCAPE '\\')`
232
+ );
233
+ args.push(like, like, like);
234
+ }
235
+ if (opts.before) {
236
+ const at = opts.before.lastIndexOf("|");
237
+ if (at > 0) {
238
+ where.push(`(b.created_at < ? OR (b.created_at = ? AND b.id < ?))`);
239
+ const created = opts.before.slice(0, at);
240
+ args.push(created, created, opts.before.slice(at + 1));
241
+ }
242
+ }
243
+ const rows = (
244
+ db
245
+ .prepare(
246
+ `${SELECT} WHERE ${where.join(" AND ")}
247
+ ORDER BY b.created_at DESC, b.id DESC LIMIT ?`
248
+ )
249
+ .all(...args, limit + 1) as Joined[]
250
+ ).map(toBookmark);
251
+ const more = rows.length > limit;
252
+ const bookmarks = more ? rows.slice(0, limit) : rows;
253
+ return {
254
+ bookmarks,
255
+ next: more ? cursorOf(bookmarks[bookmarks.length - 1]) : null,
256
+ };
257
+ }
258
+
259
+ export function setBookmarkNote(id: string, note: unknown): Bookmark | null {
260
+ const clean = cleanNote(note);
261
+ const changed = db
262
+ .prepare(`UPDATE chat_bookmarks SET note = ? WHERE id = ?`)
263
+ .run(clean, id).changes;
264
+ return changed ? getBookmark(id) : null;
265
+ }
266
+
267
+ export function removeBookmark(id: string): boolean {
268
+ return (
269
+ db.prepare(`DELETE FROM chat_bookmarks WHERE id = ?`).run(id).changes > 0
270
+ );
271
+ }
272
+
273
+ /** What a move carries: the chat's bookmarks, as stored. */
274
+ export interface MovedBookmark {
275
+ itemId: string;
276
+ scope: BookmarkScope;
277
+ kind: BookmarkKind;
278
+ text: string;
279
+ note: string | null;
280
+ createdAt: string;
281
+ }
282
+
283
+ export function bookmarksForMove(sessionId: string): MovedBookmark[] {
284
+ return (
285
+ db
286
+ .prepare(
287
+ `SELECT item_id, scope, kind, text, note, created_at
288
+ FROM chat_bookmarks WHERE session_id = ? ORDER BY created_at, id`
289
+ )
290
+ .all(sessionId) as Row[]
291
+ ).map((r) => ({
292
+ itemId: r.item_id,
293
+ scope: r.scope,
294
+ kind: r.kind,
295
+ text: r.text,
296
+ note: r.note,
297
+ createdAt: r.created_at,
298
+ }));
299
+ }
300
+
301
+ /**
302
+ * Writes a moved chat's bookmarks under the row it arrived as, scoped to
303
+ * that row's workspace here. Run inside the transaction that makes the row.
304
+ */
305
+ export function insertMovedBookmarks(
306
+ sessionId: string,
307
+ rows: MovedBookmark[]
308
+ ): void {
309
+ if (!rows.length) return;
310
+ const session = db
311
+ .prepare(`SELECT * FROM sessions WHERE id = ?`)
312
+ .get(sessionId) as Session | undefined;
313
+ if (!session) return;
314
+ const workspace = sessionWorkspace(session);
315
+ const insert = db.prepare(
316
+ `INSERT OR IGNORE INTO chat_bookmarks
317
+ (id, session_id, item_id, scope, workspace_id, kind, text, session_name, note, created_at)
318
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
319
+ );
320
+ for (const r of rows)
321
+ insert.run(
322
+ randomUUID(),
323
+ sessionId,
324
+ r.itemId,
325
+ r.scope,
326
+ workspace,
327
+ r.kind,
328
+ r.text,
329
+ session.name,
330
+ r.note,
331
+ r.createdAt
332
+ );
333
+ }
@@ -0,0 +1,38 @@
1
+ // A person's mark on one chat item, shared by the routes, the web app and
2
+ // the native app.
3
+
4
+ export type BookmarkScope = "item" | "turn";
5
+ export type BookmarkKind = "user" | "assistant" | "tool" | "turn";
6
+ // live: the chat is there; archived: it is, archived; moved: this row is the
7
+ // copy left behind by a move; deleted: the chat is gone, the snapshot stays.
8
+ export type BookmarkState = "live" | "archived" | "moved" | "deleted";
9
+
10
+ export interface Bookmark {
11
+ id: string;
12
+ sessionId: string;
13
+ itemId: string;
14
+ scope: BookmarkScope;
15
+ kind: BookmarkKind;
16
+ text: string;
17
+ note: string | null;
18
+ sessionName: string;
19
+ workspaceId: string | null;
20
+ createdAt: string;
21
+ state: BookmarkState;
22
+ }
23
+
24
+ export interface BookmarksPage {
25
+ bookmarks: Bookmark[];
26
+ // Pass as `before` for the next page; null on the last one.
27
+ next: string | null;
28
+ }
29
+
30
+ export const BOOKMARKABLE_KINDS = ["user", "assistant", "tool"] as const;
31
+
32
+ export const MAX_NOTE_CHARS = 500;
33
+ // A snapshot is clipped to this, plus the ellipsis that marks the cut.
34
+ export const MAX_SNAPSHOT_CHARS = 4_000;
35
+
36
+ // The key the stars look a bookmark up by.
37
+ export const bookmarkKey = (itemId: string, scope: BookmarkScope) =>
38
+ `${itemId}:${scope}`;
@@ -1,10 +1,14 @@
1
1
  import type { ChatCommand } from "./events";
2
2
 
3
- // The command being typed, if the message is still just "/something".
4
- // Agents only run a command that opens the message.
5
- export function slashQuery(text: string): string | null {
6
- const m = /^\/(\S*)$/.exec(text);
7
- return m ? m[1].toLowerCase() : null;
3
+ // The /command being typed: a "/" at the start of a line or after whitespace,
4
+ // then the partial name, right up to the caret. Not "a/b" or "http://x".
5
+ // `start` is where the "/" sits in the text before the caret.
6
+ export function slashQuery(
7
+ beforeCaret: string
8
+ ): { query: string; start: number } | null {
9
+ const m = /(^|\s)\/([^\s/]*)$/.exec(beforeCaret);
10
+ if (!m) return null;
11
+ return { query: m[2].toLowerCase(), start: m.index + m[1].length };
8
12
  }
9
13
 
10
14
  function isSubsequence(q: string, s: string): boolean {
package/lib/db/changes.ts CHANGED
@@ -41,6 +41,8 @@ export const TABLES_WATCHED_80 = [
41
41
  "feature_pieces",
42
42
  ] as const;
43
43
 
44
+ export const TABLES_WATCHED_CHAT_BOOKMARKS = ["chat_bookmarks"] as const;
45
+
44
46
  // Every table any migration watches, for the browser's topic map to cover.
45
47
  export const WATCHED_TABLES: readonly string[] = [
46
48
  ...TABLES_WATCHED_45,
@@ -49,6 +51,7 @@ export const WATCHED_TABLES: readonly string[] = [
49
51
  ...TABLES_WATCHED_58,
50
52
  ...TABLES_WATCHED_78,
51
53
  ...TABLES_WATCHED_80,
54
+ ...TABLES_WATCHED_CHAT_BOOKMARKS,
52
55
  ];
53
56
 
54
57
  export function installChangeTriggers(
@@ -6,6 +6,7 @@ import {
6
6
  TABLES_WATCHED_58,
7
7
  TABLES_WATCHED_78,
8
8
  TABLES_WATCHED_80,
9
+ TABLES_WATCHED_CHAT_BOOKMARKS,
9
10
  } from "./changes";
10
11
  import type Database from "better-sqlite3";
11
12
  import { moveSchedulesToRecipes } from "./schedules-move";
@@ -2297,6 +2298,33 @@ export const migrations: Migration[] = [
2297
2298
  `);
2298
2299
  },
2299
2300
  },
2301
+ {
2302
+ key: "20261014T2000_chat_bookmarks",
2303
+ up: (db) => {
2304
+ // A person's marks on single chat items. No FK to sessions: a
2305
+ // bookmark outlives a deleted chat with its own snapshot. Additive.
2306
+ db.exec(`
2307
+ CREATE TABLE IF NOT EXISTS chat_bookmarks (
2308
+ id TEXT PRIMARY KEY,
2309
+ session_id TEXT NOT NULL,
2310
+ item_id TEXT NOT NULL,
2311
+ scope TEXT NOT NULL DEFAULT 'item',
2312
+ workspace_id TEXT,
2313
+ kind TEXT NOT NULL,
2314
+ text TEXT NOT NULL,
2315
+ session_name TEXT NOT NULL,
2316
+ note TEXT,
2317
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
2318
+ UNIQUE (session_id, item_id, scope)
2319
+ )
2320
+ `);
2321
+ db.exec(
2322
+ `CREATE INDEX IF NOT EXISTS idx_chat_bookmarks_ws
2323
+ ON chat_bookmarks(workspace_id, created_at DESC)`
2324
+ );
2325
+ installChangeTriggers(db, TABLES_WATCHED_CHAT_BOOKMARKS);
2326
+ },
2327
+ },
2300
2328
  ];
2301
2329
 
2302
2330
  // `upTo`: stop after this legacy id or timestamp key, inclusive (tests that
@@ -24,7 +24,13 @@ import {
24
24
  import { mergePR } from "./merge-pr";
25
25
  import { getProject } from "../projects";
26
26
  import { canSignOff } from "./state";
27
- import { forgetPR, getTaskSession, prFor, projectPathFor } from "./session";
27
+ import {
28
+ forgetPR,
29
+ getTaskSession,
30
+ isAllowedBase,
31
+ prFor,
32
+ projectPathFor,
33
+ } from "./session";
28
34
  import { dropSessionDatabase } from "../project-config/database";
29
35
  import { captureMergedTask } from "../session-logs";
30
36
  import { sessionEnded } from "../experiments/hooks";
@@ -114,7 +120,13 @@ async function finishMerge(
114
120
  // machine merged it and didn't say).
115
121
  export async function signOffTask(
116
122
  id: string,
117
- opts: { wait?: boolean; head?: string; device?: string | null } = {}
123
+ opts: {
124
+ wait?: boolean;
125
+ head?: string;
126
+ device?: string | null;
127
+ // The base another machine judged the gates against (a forwarded merge).
128
+ base?: string;
129
+ } = {}
118
130
  ): Promise<MergeMethod | null> {
119
131
  const session = getTaskSession(id);
120
132
  if (session.task_status !== "running")
@@ -124,14 +136,24 @@ export async function signOffTask(
124
136
  if (held) throw new Error(held);
125
137
  // Its own machine checks the gates and merges.
126
138
  // That machine's settings chose the method; null if it didn't say.
127
- if (isMirror(session)) return remoteTaskAction(session, "merge", opts.head);
139
+ if (isMirror(session))
140
+ return remoteTaskAction(session, "merge", opts.head, session.base_branch);
128
141
  const repo = projectPathFor(session);
129
142
  if (!repo) throw new Error("Task has no project");
130
143
  const refusal = signOffRefusal(id);
131
144
  if (refusal) throw new Error(refusal);
132
145
  // prFor follows a retarget onto the record, so compare with what the
133
146
  // checks above were judged against.
134
- const judgedBase = session.base_branch;
147
+ // A forwarded merge was judged on the other machine against the base it
148
+ // recorded, which may be newer than this machine's row; only a base this
149
+ // task may have is taken.
150
+ if (
151
+ opts.base &&
152
+ opts.base !== session.base_branch &&
153
+ !(await isAllowedBase(session, repo, opts.base))
154
+ )
155
+ throw new Error(`${opts.base} isn't a base ${session.name} may merge into`);
156
+ const judgedBase = opts.base ?? session.base_branch;
135
157
  const pr = await prFor(session, true);
136
158
  const verdict = canSignOff(pr);
137
159
  if (!verdict.ok) throw new Error(verdict.reason);
@@ -6,6 +6,8 @@
6
6
  import { isBranchName } from "../git";
7
7
  import { CHAT_ACCESS, type ChatAccess } from "../chat/events";
8
8
  import type { ProjectRef } from "./project-ref";
9
+ import type { MovedBookmark } from "../bookmarks/store";
10
+ import { MAX_NOTE_CHARS, MAX_SNAPSHOT_CHARS } from "../bookmarks/types";
9
11
  import { isClaudeSessionId } from "./transcript";
10
12
 
11
13
  // A long task's conversation runs to a few MB; this is far past that.
@@ -92,11 +94,17 @@ export interface ChatBundle {
92
94
  // An undo's resume point not yet used up, and the context meter.
93
95
  resumeAt: string | null;
94
96
  context: string | null;
97
+ // Its bookmarks; absent from a machine that predates them.
98
+ bookmarks?: MovedBookmark[];
95
99
  }
96
100
 
97
101
  const isStr = (v: unknown): v is string => typeof v === "string";
98
102
  const isStrOrNull = (v: unknown) => v === null || isStr(v);
99
103
 
104
+ const BOOKMARK_KINDS = ["user", "assistant", "tool", "turn"];
105
+ // Far past what a person stars in one chat.
106
+ export const MAX_MOVED_BOOKMARKS = 2000;
107
+
100
108
  function validateChat(c: ChatBundle): ChatBundle {
101
109
  if (!c || typeof c !== "object" || !Array.isArray(c.items))
102
110
  throw new Error("Bad chat");
@@ -119,6 +127,26 @@ function validateChat(c: ChatBundle): ChatBundle {
119
127
  throw new Error("Bad queued message");
120
128
  bytes += m.id.length + m.text.length + (m.images?.length ?? 0);
121
129
  }
130
+ if (c.bookmarks !== undefined && !Array.isArray(c.bookmarks))
131
+ throw new Error("Bad chat bookmarks");
132
+ if ((c.bookmarks?.length ?? 0) > MAX_MOVED_BOOKMARKS)
133
+ throw new Error("Too many bookmarks to move");
134
+ for (const b of c.bookmarks ?? []) {
135
+ if (
136
+ !b ||
137
+ !isStr(b.itemId) ||
138
+ b.itemId.length > 200 ||
139
+ !(b.scope === "item" || b.scope === "turn") ||
140
+ !BOOKMARK_KINDS.includes(b.kind) ||
141
+ !isStr(b.text) ||
142
+ b.text.length > MAX_SNAPSHOT_CHARS + 1 ||
143
+ !isStrOrNull(b.note) ||
144
+ (b.note?.length ?? 0) > MAX_NOTE_CHARS ||
145
+ !isStr(b.createdAt)
146
+ )
147
+ throw new Error("Bad bookmark");
148
+ bytes += b.itemId.length + b.text.length + (b.note?.length ?? 0);
149
+ }
122
150
  if (bytes > MAX_CHAT_BYTES) throw new Error("The chat is too large to move");
123
151
  if (!isStrOrNull(c.resumeAt) || !isStrOrNull(c.context))
124
152
  throw new Error("Bad chat");
@@ -11,6 +11,7 @@ import { settle, listItems } from "../chat/store";
11
11
  import { stopChatAtTurnEnd } from "../chat/stop";
12
12
  import { rewritePaths } from "./transcript";
13
13
  import { MAX_CHAT_BYTES, type ChatBundle } from "./move-bundle";
14
+ import { bookmarksForMove, insertMovedBookmarks } from "../bookmarks/store";
14
15
 
15
16
  // A turn this long is left to finish: the move is refused, not cut short.
16
17
  export const MOVE_TURN_WAIT_MS = 3 * 60_000;
@@ -50,6 +51,7 @@ export function packChat(session: Session): ChatBundle {
50
51
  sentBy: r.sent_by,
51
52
  createdAt: r.created_at,
52
53
  }));
54
+ const bookmarks = bookmarksForMove(session.id);
53
55
  const chat: ChatBundle = {
54
56
  items,
55
57
  queue,
@@ -57,10 +59,15 @@ export function packChat(session: Session): ChatBundle {
57
59
  plan: !!session.chat_plan,
58
60
  resumeAt: session.chat_resume_at,
59
61
  context: session.chat_context,
62
+ bookmarks,
60
63
  };
61
64
  const bytes =
62
65
  items.reduce((n, i) => n + i.id.length + i.data.length, 0) +
63
- queue.reduce((n, m) => n + m.text.length + (m.images?.length ?? 0), 0);
66
+ queue.reduce((n, m) => n + m.text.length + (m.images?.length ?? 0), 0) +
67
+ bookmarks.reduce(
68
+ (n, b) => n + b.itemId.length + b.text.length + (b.note?.length ?? 0),
69
+ 0
70
+ );
64
71
  if (bytes > MAX_CHAT_BYTES) throw new Error("The chat is too large to move");
65
72
  return chat;
66
73
  }
@@ -103,6 +110,7 @@ export function unpackChat(
103
110
  m.createdAt
104
111
  )
105
112
  );
113
+ insertMovedBookmarks(id, chat.bookmarks ?? []);
106
114
  }
107
115
 
108
116
  const imageCount = (images: string) => {
@@ -118,4 +126,5 @@ const imageCount = (images: string) => {
118
126
  export function dropChat(id: string): void {
119
127
  db.prepare(`DELETE FROM chat_items WHERE session_id = ?`).run(id);
120
128
  db.prepare(`DELETE FROM chat_queue WHERE session_id = ?`).run(id);
129
+ db.prepare(`DELETE FROM chat_bookmarks WHERE session_id = ?`).run(id);
121
130
  }
@@ -191,7 +191,8 @@ export function setMirrorStatus(id: string, status: TaskStatus): void {
191
191
  export async function remoteTaskAction(
192
192
  session: Session,
193
193
  action: "merge" | "drop",
194
- head?: string
194
+ head?: string,
195
+ base?: string | null
195
196
  ): Promise<MergeMethod | null> {
196
197
  const link = requireHostLink(session.host_id);
197
198
  // An AgentOS that predates pinned merges would ignore the head and merge
@@ -203,11 +204,28 @@ export async function remoteTaskAction(
203
204
  `${link.hostName}'s AgentOS can't pin a merge to a commit; update it first`
204
205
  );
205
206
  }
207
+ // base: what the gates here were judged against; the machine that merges
208
+ // compares the PR with it, not with a base only it recorded.
206
209
  const res = await hostApi<{ head?: string | null; method?: unknown }>(
207
210
  link,
208
211
  `/api/tasks/${encodeURIComponent(session.id)}/${action}`,
209
- { body: head ? { head } : {}, timeout: 300000 }
210
- );
212
+ {
213
+ body: { ...(head && { head }), ...(head && base && { base }) },
214
+ timeout: 300000,
215
+ }
216
+ ).catch(async (err) => {
217
+ if (action !== "merge" || !unknownOutcome(err)) throw err;
218
+ // A proxy or a dropped connection after the merge ran answers 502 with
219
+ // nothing from AgentOS: ask what state the task is in before failing.
220
+ const merged = await hostTasks(link, true)
221
+ .then((l) => l.tasks.find((t) => t.id === session.id)?.state === "merged")
222
+ .catch(() => false);
223
+ if (!merged)
224
+ throw new Error(
225
+ `${(err as Error).message}: couldn't confirm whether ${link.hostName} merged ${session.name}; check its PR before signing off again`
226
+ );
227
+ return { head: head ?? null, method: undefined };
228
+ });
211
229
  forgetHostTasks(link.hostId);
212
230
  setMirrorStatus(session.id, action === "merge" ? "merged" : "dropped");
213
231
  if (head && res.head !== head)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.585",
3
+ "version": "0.3.588",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"