@saadnvd1/agent-os 0.3.595 → 0.3.596

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,53 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type { SessionArtifact } from "@/lib/session-artifacts/types";
3
+ import { sessionArtifactKeys } from "./keys";
4
+
5
+ export type { SessionArtifact } from "@/lib/session-artifacts/types";
6
+
7
+ export interface SessionArtifacts {
8
+ artifacts: SessionArtifact[];
9
+ // A linked machine's session: which one, and whether it answered.
10
+ host?: string;
11
+ unreachable?: boolean;
12
+ }
13
+
14
+ export const artifactContentUrl = (sessionId: string, id: string) =>
15
+ `/api/sessions/${encodeURIComponent(sessionId)}/artifacts/${encodeURIComponent(id)}`;
16
+
17
+ /** What a session made. Read only while the view is open. */
18
+ export function useSessionArtifacts(sessionId: string | null | undefined) {
19
+ return useQuery({
20
+ queryKey: sessionArtifactKeys.session(sessionId ?? ""),
21
+ queryFn: async (): Promise<SessionArtifacts> => {
22
+ const res = await fetch(
23
+ `/api/sessions/${encodeURIComponent(sessionId!)}/artifacts`
24
+ );
25
+ const data = await res.json().catch(() => ({}));
26
+ if (!res.ok) throw new Error(data.error || "Couldn't load artifacts");
27
+ return data;
28
+ },
29
+ enabled: !!sessionId,
30
+ staleTime: 5_000,
31
+ refetchInterval: 20_000,
32
+ });
33
+ }
34
+
35
+ /** A text artifact's content: markdown, a file, a log. */
36
+ export function useArtifactText(url: string | null) {
37
+ return useQuery({
38
+ queryKey: sessionArtifactKeys.content(url ?? ""),
39
+ queryFn: async () => {
40
+ const res = await fetch(url!);
41
+ if (!res.ok) {
42
+ const data = await res.json().catch(() => ({}));
43
+ throw new Error(data.error || "Couldn't open it");
44
+ }
45
+ return {
46
+ text: await res.text(),
47
+ truncated: res.headers.get("X-Artifact-Truncated") === "1",
48
+ };
49
+ },
50
+ enabled: !!url,
51
+ staleTime: 5_000,
52
+ });
53
+ }
@@ -10,7 +10,7 @@ const found = new Map<string, string>();
10
10
  // Claude Code writes each background task's output under
11
11
  // <tmp>/claude-<uid>/<project>/<session>/tasks/<task id>.output; the path is
12
12
  // only reported when the task ends, so a running one is found by name.
13
- function findOutputFile(taskId: string): string | null {
13
+ export function findOutputFile(taskId: string): string | null {
14
14
  const cached = found.get(taskId);
15
15
  if (cached && fs.existsSync(cached)) return cached;
16
16
  const uid = process.getuid?.() ?? "";
@@ -11,6 +11,7 @@ import { git as runGit } from "../git-exec";
11
11
  import { mergePolicy } from "../tasks/merge-policy";
12
12
  import { getProject } from "../projects";
13
13
  import { deleteWorktree, isAgentOSWorktree } from "../worktrees";
14
+ import { keepSessionFiles } from "../session-artifacts/collect";
14
15
  import { removalRunning, removalSettled } from "../worktree-removal";
15
16
  import { describeChanges, unsavedChanges } from "../worktree-placed";
16
17
  import { dropSnapshot, snapshotWorktree } from "../resurrect/snapshot";
@@ -234,6 +235,8 @@ export async function settleWorktree(
234
235
  }
235
236
  await snapshot;
236
237
  const path = session.worktree_path!;
238
+ // What it made that git doesn't hold (a plan, a mockup) outlives it.
239
+ if (session.id) await keepSessionFiles(session.id);
237
240
  await deleteWorktree(path, fate.repo, true).catch(() => {});
238
241
  if (fs.existsSync(path))
239
242
  return { action: "kept", why: "removing it failed", path };
@@ -84,6 +84,7 @@ const READS = [
84
84
  "sessions/[id]/messages",
85
85
  "sessions/[id]/pr",
86
86
  "sessions/[id]/artifacts",
87
+ "sessions/[id]/artifacts/[id]",
87
88
  "sessions/[id]/setup",
88
89
  "sessions/[id]/preview",
89
90
  "projects",
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Which files the Artifacts view may open: inside the session's own folders
3
+ * or the few AgentOS folders agents write output to, never a secret, judged
4
+ * on the real path so a link can't lead out. Stricter than the file browser.
5
+ */
6
+
7
+ import fs from "fs";
8
+ import os from "os";
9
+ import path from "path";
10
+ import { artifactsDir } from "../artifacts/store";
11
+ import { auditsRoot } from "../session-audit/store";
12
+ import { WORKTREES_DIR } from "../worktrees";
13
+ import { isSecretPath } from "../secret-paths";
14
+ import { keptDir } from "./keep";
15
+
16
+ export interface RootsFor {
17
+ worktree_path?: string | null;
18
+ working_directory?: string | null;
19
+ projectPath?: string | null;
20
+ }
21
+
22
+ const expand = (p: string) =>
23
+ p === "~" || p.startsWith("~/") ? os.homedir() + p.slice(1) : p;
24
+
25
+ // A folder and, when it differs, its real path (/tmp is /private/tmp).
26
+ function withReal(dirs: string[]): string[] {
27
+ const out = new Set<string>();
28
+ for (const d of dirs) {
29
+ const abs = path.resolve(expand(d));
30
+ out.add(abs);
31
+ try {
32
+ out.add(fs.realpathSync(abs));
33
+ } catch {
34
+ // Not there (a removed worktree): its literal path still counts.
35
+ }
36
+ }
37
+ return [...out];
38
+ }
39
+
40
+ export function allowedRoots(s: RootsFor): string[] {
41
+ const dirs = [
42
+ s.worktree_path,
43
+ s.working_directory,
44
+ s.projectPath,
45
+ artifactsDir(),
46
+ keptDir(),
47
+ auditsRoot(),
48
+ WORKTREES_DIR,
49
+ os.tmpdir(),
50
+ "/tmp",
51
+ ].filter((d): d is string => !!d);
52
+ // A session started in ~ (or /) would otherwise open everything.
53
+ const home = path.resolve(os.homedir());
54
+ return withReal(dirs).filter((d) => d !== path.parse(d).root && d !== home);
55
+ }
56
+
57
+ export const within = (file: string, roots: string[]) =>
58
+ roots.some((r) => file === r || file.startsWith(r + path.sep));
59
+
60
+ // The real file to serve, or why not. Regular files only.
61
+ export function resolveAllowed(
62
+ file: string,
63
+ roots: string[]
64
+ ): { ok: true; real: string; size: number } | { ok: false; status: 403 | 404 } {
65
+ if (isSecretPath(file)) return { ok: false, status: 403 };
66
+ let real: string;
67
+ try {
68
+ real = fs.realpathSync(file);
69
+ } catch {
70
+ return { ok: false, status: 404 };
71
+ }
72
+ if (isSecretPath(real) || !within(real, roots))
73
+ return { ok: false, status: 403 };
74
+ try {
75
+ const stat = fs.statSync(real);
76
+ if (!stat.isFile()) return { ok: false, status: 404 };
77
+ return { ok: true, real, size: stat.size };
78
+ } catch {
79
+ return { ok: false, status: 404 };
80
+ }
81
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * A session's artifacts, read cheaply: one indexed query over its chat items
3
+ * (never a tool's output or a Write's content), a stat per file, one git
4
+ * status while its worktree is there, and its PR, recipe run and log rows.
5
+ * Cached until the chat moves on, and for a minute at most.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import path from "path";
11
+ import { db, queries, type Session } from "../db";
12
+ import { flushItems } from "../chat/store";
13
+ import { git } from "../git-exec";
14
+ import { PLAN_FILE } from "../models/plan-step";
15
+ import { listArtifacts } from "../artifacts/store";
16
+ import { allowedRoots, within } from "./access";
17
+ import {
18
+ fromItems,
19
+ fromPr,
20
+ fromRun,
21
+ fromSessionLog,
22
+ fromStatus,
23
+ mergePlan,
24
+ parsePorcelain,
25
+ sortEntries,
26
+ stateOf,
27
+ toArtifact,
28
+ type Candidate,
29
+ type Entry,
30
+ type FileFacts,
31
+ type ItemRow,
32
+ type RunRow,
33
+ } from "./derive";
34
+ import { keepFiles, readManifest, relInside, sessionKeptDir } from "./keep";
35
+
36
+ export const MAX_FILES = 500;
37
+ const CACHE_MS = 60_000;
38
+ const CACHE_SIZE = 50;
39
+ const STATUS_TIMEOUT_MS = 3000;
40
+
41
+ const ITEMS_SQL = `
42
+ SELECT item_id, kind,
43
+ json_extract(data, '$.createdAt') AS at,
44
+ json_extract(data, '$.name') AS name,
45
+ json_extract(data, '$.status') AS status,
46
+ json_extract(data, '$.input.file_path') AS file_path,
47
+ json_extract(data, '$.input.notebook_path') AS notebook_path,
48
+ json_extract(data, '$.input.path') AS input_path,
49
+ json_extract(data, '$.input.filePath') AS file_path_camel,
50
+ json_extract(data, '$.input.changes') AS changes,
51
+ json_extract(data, '$.artifactId') AS artifact_id,
52
+ json_extract(data, '$.title') AS title,
53
+ json_extract(data, '$.taskId') AS task_id,
54
+ json_extract(data, '$.outputFile') AS output_file,
55
+ json_extract(data, '$.taskType') AS task_type,
56
+ json_extract(data, '$.subagentType') AS subagent_type,
57
+ json_extract(data, '$.description') AS description,
58
+ json_extract(data, '$.ambient') AS ambient,
59
+ json_array_length(data, '$.images') AS image_count
60
+ FROM chat_items
61
+ WHERE session_id = ? AND kind IN ('tool', 'artifact', 'task', 'plan', 'user')
62
+ ORDER BY seq`;
63
+
64
+ const expand = (p: string) =>
65
+ p === "~" || p.startsWith("~/") ? os.homedir() + p.slice(1) : p;
66
+
67
+ const sqliteMs = (t: string | null | undefined) =>
68
+ t ? Date.parse(t.includes("T") ? t : `${t.replace(" ", "T")}Z`) || 0 : 0;
69
+
70
+ export function getSession(id: string): Session | undefined {
71
+ return queries.getSession(db).get(id) as Session | undefined;
72
+ }
73
+
74
+ function projectPath(session: Session): string | null {
75
+ if (!session.project_id) return null;
76
+ const row = db
77
+ .prepare(`SELECT working_directory FROM projects WHERE id = ?`)
78
+ .get(session.project_id) as { working_directory: string } | undefined;
79
+ return row ? expand(row.working_directory) : null;
80
+ }
81
+
82
+ export function rootsOf(session: Session): string[] {
83
+ return allowedRoots({
84
+ worktree_path: session.worktree_path,
85
+ working_directory: session.working_directory,
86
+ projectPath: projectPath(session),
87
+ });
88
+ }
89
+
90
+ function runs(sessionId: string): RunRow[] {
91
+ return (
92
+ db
93
+ .prepare(
94
+ `SELECT r.id, r.status, x.name AS recipe_name,
95
+ COALESCE(r.ended_at, r.started_at, r.created_at) AS t
96
+ FROM recipe_runs r LEFT JOIN recipes x ON x.id = r.recipe_id
97
+ WHERE r.session_id = ? ORDER BY r.id`
98
+ )
99
+ .all(sessionId) as (RunRow & { t: string })[]
100
+ ).map((r) => ({ ...r, at: sqliteMs(r.t) }));
101
+ }
102
+
103
+ function logAt(sessionId: string): number | null {
104
+ const row = db
105
+ .prepare(`SELECT created_at FROM session_logs WHERE session_id = ?`)
106
+ .get(sessionId) as { created_at: string } | undefined;
107
+ return row ? sqliteMs(row.created_at) : null;
108
+ }
109
+
110
+ const filePathOf = (c: Candidate): string | undefined =>
111
+ c.source.type === "file"
112
+ ? c.source.path
113
+ : c.source.type === "plan" || c.source.type === "log"
114
+ ? c.source.path
115
+ : undefined;
116
+
117
+ async function sizeOf(file: string): Promise<number | undefined> {
118
+ try {
119
+ const stat = await fs.promises.stat(file);
120
+ return stat.isFile() ? stat.size : undefined;
121
+ } catch {
122
+ return undefined;
123
+ }
124
+ }
125
+
126
+ function realWithin(file: string, roots: string[]): boolean {
127
+ if (!within(file, roots)) return false;
128
+ try {
129
+ return within(fs.realpathSync(file), roots);
130
+ } catch {
131
+ return true;
132
+ }
133
+ }
134
+
135
+ async function build(session: Session): Promise<Entry[]> {
136
+ flushItems();
137
+ const rows = db.prepare(ITEMS_SQL).all(session.id) as ItemRow[];
138
+ const cwd = session.working_directory
139
+ ? expand(session.working_directory)
140
+ : null;
141
+ let candidates = fromItems(rows, cwd);
142
+
143
+ const isLocal = !session.host_id || session.host_id === "local";
144
+ const worktree =
145
+ isLocal && session.worktree_path ? expand(session.worktree_path) : null;
146
+ const worktreeThere = !!worktree && fs.existsSync(worktree);
147
+ const latest = rows.reduce((m, r) => Math.max(m, r.at ?? 0), 0);
148
+
149
+ if (worktreeThere) {
150
+ const out = await git(
151
+ worktree,
152
+ ["status", "--porcelain", "-z", "--untracked-files=all"],
153
+ { timeout: STATUS_TIMEOUT_MS }
154
+ ).catch(() => "");
155
+ const known = new Set(candidates.map((c) => c.key));
156
+ candidates.push(
157
+ ...fromStatus(parsePorcelain(out), worktree, latest, known)
158
+ );
159
+ }
160
+
161
+ const manifest = worktree && !worktreeThere ? readManifest(session.id) : null;
162
+ if (worktree) {
163
+ const planFile = path.join(worktree, PLAN_FILE);
164
+ const named = candidates.some((c) => c.key === `file:${planFile}`);
165
+ const there = worktreeThere
166
+ ? fs.existsSync(planFile)
167
+ : !!manifest?.kept.includes(PLAN_FILE);
168
+ if (named || there) candidates = mergePlan(candidates, planFile);
169
+ }
170
+
171
+ const pr = fromPr({
172
+ pr_url: session.pr_url,
173
+ pr_number: session.pr_number,
174
+ pr_status: session.pr_status,
175
+ at: sqliteMs(session.merged_at ?? session.updated_at),
176
+ });
177
+ if (pr) candidates.push(pr);
178
+ candidates.push(...runs(session.id).map(fromRun));
179
+ const log = logAt(session.id);
180
+ if (log !== null) candidates.push(fromSessionLog(log));
181
+
182
+ // The newest files first, so the cap drops the oldest.
183
+ const fileBacked = candidates
184
+ .filter((c) => filePathOf(c))
185
+ .sort((a, b) => b.at - a.at);
186
+ const dropped = new Set(fileBacked.slice(MAX_FILES).map((c) => c.key));
187
+ candidates = candidates.filter((c) => !dropped.has(c.key));
188
+
189
+ const roots = rootsOf(session);
190
+ const keptBase = sessionKeptDir(session.id);
191
+ const tracked = new Set(manifest?.tracked);
192
+ const kept = new Set(manifest?.kept);
193
+ const pages = new Map(listArtifacts(session.id).map((a) => [a.id, a.path]));
194
+ const entries = await Promise.all(
195
+ candidates.map(async (c): Promise<Entry> => {
196
+ if (c.source.type === "page") {
197
+ const page = pages.get(c.source.artifactId);
198
+ const size = page ? await sizeOf(page) : undefined;
199
+ return toArtifact(
200
+ c,
201
+ size === undefined ? { state: "gone" } : { state: "live", size }
202
+ );
203
+ }
204
+ const file = filePathOf(c);
205
+ if (!file) return toArtifact(c, null);
206
+ const facts: FileFacts = {
207
+ size: await sizeOf(file),
208
+ allowed: realWithin(file, roots),
209
+ };
210
+ const rel = worktree ? relInside(worktree, file) : null;
211
+ let read: string | undefined =
212
+ facts.size !== undefined ? file : undefined;
213
+ if (facts.size === undefined && rel && manifest) {
214
+ if (kept.has(rel)) {
215
+ const copy = path.join(keptBase, rel);
216
+ facts.keptSize = await sizeOf(copy);
217
+ if (facts.keptSize !== undefined) read = copy;
218
+ }
219
+ facts.committed = tracked.has(rel) && !!session.pr_number;
220
+ }
221
+ const state = stateOf(facts);
222
+ // A plan proposed in chat still opens when its file has gone.
223
+ if (
224
+ c.source.type === "plan" &&
225
+ c.source.chatItemId &&
226
+ (state.state === "gone" || state.state === "in-pr")
227
+ )
228
+ return toArtifact(c, { state: "live", path: file });
229
+ // A running job's output file is named only once it ends.
230
+ if (c.source.type === "log" && !c.source.path)
231
+ return toArtifact(c, { state: "live" });
232
+ return toArtifact(c, {
233
+ ...state,
234
+ path: file,
235
+ read: state.state === "outside" ? undefined : read,
236
+ });
237
+ })
238
+ );
239
+ if (worktree)
240
+ for (const { artifact: a } of entries) {
241
+ const rel = a.path && relInside(worktree, untilde(a.path));
242
+ if (rel) a.folder = path.posix.dirname(rel).replace(/^\.$/, "");
243
+ }
244
+ return sortEntries(entries);
245
+ }
246
+
247
+ const untilde = (p: string) =>
248
+ p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p;
249
+
250
+ interface Cached {
251
+ sig: string;
252
+ at: number;
253
+ entries: Promise<Entry[]>;
254
+ }
255
+ const cache = new Map<string, Cached>();
256
+
257
+ function signature(session: Session): string {
258
+ const row = db
259
+ .prepare(`SELECT max(seq) AS seq FROM chat_items WHERE session_id = ?`)
260
+ .get(session.id) as { seq: number | null };
261
+ return [
262
+ row.seq ?? 0,
263
+ session.pr_status,
264
+ session.pr_number,
265
+ session.worktree_path,
266
+ session.updated_at,
267
+ ].join("|");
268
+ }
269
+
270
+ export function forgetArtifacts(sessionId: string): void {
271
+ cache.delete(sessionId);
272
+ }
273
+
274
+ export async function collectArtifacts(
275
+ session: Session,
276
+ now = Date.now()
277
+ ): Promise<Entry[]> {
278
+ flushItems();
279
+ const sig = signature(session);
280
+ const hit = cache.get(session.id);
281
+ if (hit && hit.sig === sig && now - hit.at < CACHE_MS) {
282
+ // Recently used stays.
283
+ cache.delete(session.id);
284
+ cache.set(session.id, hit);
285
+ return hit.entries;
286
+ }
287
+ const entries = build(session);
288
+ cache.set(session.id, { sig, at: now, entries });
289
+ entries.catch(() => cache.delete(session.id));
290
+ while (cache.size > CACHE_SIZE) cache.delete(cache.keys().next().value!);
291
+ return entries;
292
+ }
293
+
294
+ export const cachedCount = () => cache.size;
295
+
296
+ /**
297
+ * Before a session's worktree is removed: copy what it made there that git
298
+ * doesn't hold. Never throws, and never holds up the removal for long.
299
+ */
300
+ export async function keepSessionFiles(sessionId: string): Promise<void> {
301
+ try {
302
+ const session = getSession(sessionId);
303
+ if (!session?.worktree_path) return;
304
+ if (session.host_id && session.host_id !== "local") return;
305
+ const worktree = expand(session.worktree_path);
306
+ if (!fs.existsSync(worktree)) return;
307
+ forgetArtifacts(sessionId);
308
+ const entries = await collectArtifacts(session);
309
+ const files = entries
310
+ .map((e) => e.read)
311
+ .filter((f): f is string => !!f && !!relInside(worktree, f));
312
+ await keepFiles(sessionId, worktree, files);
313
+ } catch (error) {
314
+ console.error(`[session-artifacts] keeping ${sessionId}:`, error);
315
+ } finally {
316
+ forgetArtifacts(sessionId);
317
+ }
318
+ }