@saadnvd1/agent-os 0.3.594 → 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.
- package/README.md +1 -0
- package/app/api/sessions/[id]/artifacts/[artifactId]/route.ts +34 -0
- package/app/api/sessions/[id]/artifacts/route.ts +25 -9
- package/components/Artifacts/ArtifactPreview.tsx +239 -0
- package/components/Artifacts/ArtifactRow.tsx +147 -0
- package/components/Artifacts/ArtifactsPanel.tsx +244 -0
- package/components/Artifacts/helpers.test.ts +90 -0
- package/components/Artifacts/helpers.ts +99 -0
- package/components/Artifacts/index.ts +1 -0
- package/components/Chat/ArtifactFrame.tsx +4 -1
- package/components/Pane/DesktopTabBar.tsx +2 -2
- package/components/Pane/MobileTabBar.tsx +14 -2
- package/components/Pane/PaneViewToggle.tsx +20 -3
- package/components/Pane/index.tsx +35 -3
- package/components/Providers.tsx +5 -1
- package/components/SessionList/LiveLine.tsx +1 -1
- package/components/SessionList/OrchestratorPinRow.tsx +10 -5
- package/components/SessionList/QuickChangeRow.tsx +1 -1
- package/components/SessionList/RowParts.tsx +37 -2
- package/components/SessionList/RowShell.tsx +6 -2
- package/components/SessionList/RowSubtitle.tsx +20 -13
- package/components/SessionList/SessionRow.tsx +28 -17
- package/components/SessionList/SessionShelves.tsx +40 -8
- package/components/SessionList/SidebarExtras.tsx +7 -1
- package/components/SessionList/index.tsx +2 -0
- package/components/SessionList/useSessionTips.ts +62 -0
- package/components/ui/HoverTip.tsx +20 -0
- package/contexts/PaneContext.tsx +7 -1
- package/data/presence/index.ts +14 -7
- package/data/session-artifacts/index.ts +2 -0
- package/data/session-artifacts/keys.ts +7 -0
- package/data/session-artifacts/queries.ts +53 -0
- package/lib/chat/task-output.ts +1 -1
- package/lib/done/worktree.ts +3 -0
- package/lib/hover-tip.ts +161 -0
- package/lib/security/demo.ts +1 -0
- package/lib/session-artifacts/access.ts +81 -0
- package/lib/session-artifacts/collect.ts +318 -0
- package/lib/session-artifacts/derive.ts +437 -0
- package/lib/session-artifacts/keep.ts +119 -0
- package/lib/session-artifacts/peer.ts +187 -0
- package/lib/session-artifacts/serve.ts +265 -0
- package/lib/session-artifacts/types.ts +43 -0
- package/lib/sidebar/row-badge.ts +40 -0
- package/lib/sidebar/row-details.ts +53 -0
- package/lib/tasks/finish.ts +2 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/lib/chat/task-output.ts
CHANGED
|
@@ -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?.() ?? "";
|
package/lib/done/worktree.ts
CHANGED
|
@@ -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 };
|
package/lib/hover-tip.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// One hover tooltip for the whole app. An element opts in with data-tip
|
|
2
|
+
// (plain text) or data-tip-key ("session:<id>", resolved when it opens by
|
|
3
|
+
// whoever registered that prefix). Rows carry an attribute and nothing
|
|
4
|
+
// else: one element and a few passive document listeners do the rest, and
|
|
5
|
+
// a touch screen attaches none. It opens after resting on something, and
|
|
6
|
+
// once one is open the next opens at once, so a list can be scanned.
|
|
7
|
+
|
|
8
|
+
export interface TipContent {
|
|
9
|
+
title: string;
|
|
10
|
+
lines: [label: string, value: string][];
|
|
11
|
+
}
|
|
12
|
+
export type TipValue = string | TipContent | null | undefined;
|
|
13
|
+
|
|
14
|
+
export const OPEN_DELAY_MS = 600;
|
|
15
|
+
export const WARM_MS = 400;
|
|
16
|
+
const SELECTOR = "[data-tip],[data-tip-key]";
|
|
17
|
+
const TIP_ID = "hover-tip";
|
|
18
|
+
|
|
19
|
+
const resolvers = new Map<string, (id: string) => TipValue>();
|
|
20
|
+
|
|
21
|
+
export function registerTip(
|
|
22
|
+
prefix: string,
|
|
23
|
+
resolve: (id: string) => TipValue
|
|
24
|
+
): () => void {
|
|
25
|
+
resolvers.set(prefix, resolve);
|
|
26
|
+
return () => {
|
|
27
|
+
if (resolvers.get(prefix) === resolve) resolvers.delete(prefix);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function resolveTip(el: Element): TipValue {
|
|
32
|
+
const key = el.getAttribute("data-tip-key");
|
|
33
|
+
if (key) {
|
|
34
|
+
const at = key.indexOf(":");
|
|
35
|
+
const value = resolvers.get(key.slice(0, at))?.(key.slice(at + 1));
|
|
36
|
+
if (value) return value;
|
|
37
|
+
}
|
|
38
|
+
return el.getAttribute("data-tip");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Text only: names are user text and never parsed as HTML.
|
|
42
|
+
function fill(el: HTMLElement, value: string | TipContent) {
|
|
43
|
+
if (typeof value === "string") {
|
|
44
|
+
el.textContent = value;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const doc = el.ownerDocument;
|
|
48
|
+
const title = doc.createElement("div");
|
|
49
|
+
title.className = "font-medium";
|
|
50
|
+
title.textContent = value.title;
|
|
51
|
+
const rows = value.lines.map(([label, text]) => {
|
|
52
|
+
const line = doc.createElement("div");
|
|
53
|
+
const k = doc.createElement("span");
|
|
54
|
+
k.className = "text-muted-foreground";
|
|
55
|
+
k.textContent = `${label} `;
|
|
56
|
+
line.append(k, text);
|
|
57
|
+
return line;
|
|
58
|
+
});
|
|
59
|
+
el.replaceChildren(title, ...rows);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Beside the target (a sidebar row's tip opens over the page, not the
|
|
63
|
+
// list), flipped or dropped below when there's no room. One measure.
|
|
64
|
+
function place(el: HTMLElement, target: Element, win: Window) {
|
|
65
|
+
const r = target.getBoundingClientRect();
|
|
66
|
+
const w = el.offsetWidth;
|
|
67
|
+
const h = el.offsetHeight;
|
|
68
|
+
const gap = 8;
|
|
69
|
+
let x = r.right + gap;
|
|
70
|
+
let y = r.top;
|
|
71
|
+
if (x + w > win.innerWidth - gap) x = r.left - w - gap;
|
|
72
|
+
if (x < gap) {
|
|
73
|
+
x = Math.min(Math.max(gap, r.left), win.innerWidth - w - gap);
|
|
74
|
+
y = r.bottom + gap;
|
|
75
|
+
}
|
|
76
|
+
y = Math.max(gap, Math.min(y, win.innerHeight - h - gap));
|
|
77
|
+
el.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Works across documents (tests, frames), unlike instanceof Element.
|
|
81
|
+
const isElement = (node: EventTarget | null): node is Element =>
|
|
82
|
+
!!node && typeof (node as Element).closest === "function";
|
|
83
|
+
|
|
84
|
+
export function installHoverTip(win: Window = window): () => void {
|
|
85
|
+
if (!win.matchMedia?.("(hover: hover) and (pointer: fine)").matches)
|
|
86
|
+
return () => {};
|
|
87
|
+
const doc = win.document;
|
|
88
|
+
const el = doc.createElement("div");
|
|
89
|
+
el.id = TIP_ID;
|
|
90
|
+
el.setAttribute("role", "tooltip");
|
|
91
|
+
el.className =
|
|
92
|
+
"popover-surface text-popover-foreground pointer-events-none fixed top-0 left-0 z-[60] max-w-72 rounded-lg px-2.5 py-1.5 text-xs leading-relaxed whitespace-pre-line";
|
|
93
|
+
el.hidden = true;
|
|
94
|
+
doc.body.append(el);
|
|
95
|
+
|
|
96
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
97
|
+
let pending: Element | null = null;
|
|
98
|
+
let shown: Element | null = null;
|
|
99
|
+
let warmUntil = 0;
|
|
100
|
+
|
|
101
|
+
const hide = () => {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
timer = undefined;
|
|
104
|
+
pending = null;
|
|
105
|
+
if (shown) {
|
|
106
|
+
shown.removeAttribute("aria-describedby");
|
|
107
|
+
warmUntil = Date.now() + WARM_MS;
|
|
108
|
+
shown = null;
|
|
109
|
+
}
|
|
110
|
+
el.hidden = true;
|
|
111
|
+
};
|
|
112
|
+
const show = (target: Element) => {
|
|
113
|
+
timer = undefined;
|
|
114
|
+
if (!target.isConnected) return;
|
|
115
|
+
const value = resolveTip(target);
|
|
116
|
+
if (!value) return;
|
|
117
|
+
fill(el, value);
|
|
118
|
+
el.hidden = false;
|
|
119
|
+
place(el, target, win);
|
|
120
|
+
target.setAttribute("aria-describedby", TIP_ID);
|
|
121
|
+
shown = target;
|
|
122
|
+
};
|
|
123
|
+
const arm = (target: Element | null) => {
|
|
124
|
+
if (target && (target === pending || target === shown)) return;
|
|
125
|
+
hide();
|
|
126
|
+
if (!target) return;
|
|
127
|
+
pending = target;
|
|
128
|
+
if (Date.now() < warmUntil) show(target);
|
|
129
|
+
else timer = setTimeout(() => show(target), OPEN_DELAY_MS);
|
|
130
|
+
};
|
|
131
|
+
const tipOf = (node: EventTarget | null) =>
|
|
132
|
+
isElement(node) ? node.closest(SELECTOR) : null;
|
|
133
|
+
|
|
134
|
+
const onOver = (e: Event) => arm(tipOf(e.target));
|
|
135
|
+
const onOut = (e: Event) => {
|
|
136
|
+
if (!(e as PointerEvent).relatedTarget) hide();
|
|
137
|
+
};
|
|
138
|
+
const onFocusIn = (e: Event) => {
|
|
139
|
+
const t = e.target;
|
|
140
|
+
if (isElement(t) && t.matches(SELECTOR) && t.matches(":focus-visible"))
|
|
141
|
+
arm(t);
|
|
142
|
+
};
|
|
143
|
+
const passive = { passive: true } as const;
|
|
144
|
+
const listeners: [string, (e: Event) => void, AddEventListenerOptions][] = [
|
|
145
|
+
["pointerover", onOver, passive],
|
|
146
|
+
["pointerout", onOut, passive],
|
|
147
|
+
["pointerdown", hide, passive],
|
|
148
|
+
["keydown", hide, passive],
|
|
149
|
+
["focusin", onFocusIn, passive],
|
|
150
|
+
["focusout", hide, passive],
|
|
151
|
+
["scroll", hide, { passive: true, capture: true }],
|
|
152
|
+
];
|
|
153
|
+
for (const [type, fn, opts] of listeners)
|
|
154
|
+
doc.addEventListener(type, fn, opts);
|
|
155
|
+
return () => {
|
|
156
|
+
hide();
|
|
157
|
+
for (const [type, fn, opts] of listeners)
|
|
158
|
+
doc.removeEventListener(type, fn, opts);
|
|
159
|
+
el.remove();
|
|
160
|
+
};
|
|
161
|
+
}
|
package/lib/security/demo.ts
CHANGED
|
@@ -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
|
+
}
|