// workspace tree + files import { z } from "zod"; import { ConversationIdSchema } from "../agent.js"; // Which copy of the workspace a read means: the shared /work tree, or (if `agent` is set) that conversation's own // checkout, resolved in one place so the escape guard, denylist and ignore rules apply the same either way. export const WorkspaceScopeSchema = z.object({ agent: ConversationIdSchema.optional().describe( "Read a conversation's own private copy of the workspace rather than the shared tree. Leave it out for the shared tree. A conversation that is not working privately resolves back to the shared tree rather than failing, so a link need not know which mode it runs in.", ), }); export type WorkspaceScope = z.infer; // path is root-relative with forward slashes, feeding straight into the file routes. The interface is declared by hand: // zod's inferred type for this recursive getter collapses to `{}` past the first level. // `type` alongside a link is its target's type, so a directory link opens like a directory. `to` is the link's literal // text, verbatim, what hovering shows and what the author would edit. // broken: nothing at the other end; listed anyway. // outside: resolves outside the workspace; shown but refused, like a locked entry. export interface WorkspaceLink { readonly to: string; readonly state?: "broken" | "outside" | undefined; } export const WorkspaceLinkSchema = z.object({ to: z .string() .describe("What the link says, verbatim, rather than where it ends up. That is what the person who made it wrote, and what they would edit."), state: z .enum(["broken", "outside"]) .optional() .describe( "Absent for an ordinary link. Broken means there is nothing at the other end, and it is listed anyway because a dangling link is worth seeing. Outside means it leads out of the workspace, so it is shown and refused.", ), }); export interface WorkspaceTreeEntry { readonly name: string; readonly path: string; readonly type: "file" | "dir"; readonly size?: number | undefined; // When a file last changed, in epoch milliseconds, from the stat that already reads its size; a directory has none, // since a plain one is listed from its dirent alone. The home's date grouping reads it (homeOrder.ts). readonly mtime?: number | undefined; // Ignored by tooling (node_modules, .git, gitignored paths, browser profiles); client greys the row. readonly ignored?: boolean | undefined; // Set when this entry is a symlink; `type` above is then its target's type. readonly link?: WorkspaceLink | undefined; // Absent means not descended (ignored or budget-stopped); lazy-load via /workspace/children, distinct from []. readonly children?: readonly WorkspaceTreeEntry[] | undefined; } export const WorkspaceTreeEntrySchema: z.ZodType = z.object({ name: z.string().describe("Just this entry's own name."), path: z.string().describe("Its full path from the workspace root, which feeds straight back into the file routes."), type: z.enum(["file", "dir"]).describe("What it is. For a link, what it points at, so a link to a folder opens like a folder."), size: z.number().optional().describe("Size in bytes, for a file."), mtime: z .number() .optional() .describe("When a file last changed, in milliseconds since 1970. A folder has none. Absent too where the listing could not read it."), ignored: z .boolean() .optional() .describe("Tooling ignores it: installed packages, git internals, anything the ignore rules exclude. Usually drawn greyed out."), link: WorkspaceLinkSchema.optional().describe("Present when this entry is a link."), get children() { return z .array(WorkspaceTreeEntrySchema) .optional() .describe( "What is inside a folder. Absent means it was not opened, either because it is ignored or because the walk ran out of budget above it, so ask for it separately. An empty list means it really is empty.", ); }, }); export const WorkspaceTreeSchema = z.object({ root: z.string().describe("The path everything below is relative to."), tree: z.array(WorkspaceTreeEntrySchema).describe("The workspace, one entry per file and folder."), // How many of the root's own entries the budget cut (0 = complete); per-dir cuts count on each dir entry. hidden: z.number().describe("How many entries at the top level were cut for size. Zero means the listing is complete."), // From its own walk (empty-dirs.ts), not derived from `tree`, which stops at budget and would undercount. barren: z .array(z.string()) .describe( "Folders whose whole contents are empty folders, and nothing else. Complete for the workspace, however much of the tree above was listed, and ordered like the tree, so a parent comes before the branch below it.", ), generation: z .number() .int() .nonnegative() .optional() .describe( "Which state of the shared tree this is; the changes that follow count from it. Absent for a conversation's own checkout, which is listed when asked and followed by no changes.", ), }); export type WorkspaceTree = z.infer; // What moved in the shared tree between two generations: every folder whose own entries changed, parents first. export const WorkspaceTreeDeltaSchema = z.object({ from: z .number() .int() .nonnegative() .describe("The generation this applies on top of. A reader holding any other fetches the tree afresh instead."), generation: z.number().int().nonnegative().describe("The generation it leaves the tree at."), dirs: z .array( z.object({ path: z.string().describe("The folder, as a workspace path; empty for the workspace root."), entries: z .array(WorkspaceTreeEntrySchema) .describe("Its entries now, without their contents: a folder whose own entries also changed comes as an item of its own."), }), ) .describe("Each folder that changed, parents before the folders inside them."), barren: z.array(z.string()).optional().describe("The barren folders now, present only when that list moved."), }); export type WorkspaceTreeDelta = z.infer; // Lazy-loads one directory's children past the tree walk's budget; depth reads several levels into one flat `entries` // list instead of one request per directory. export const WorkspaceChildrenQuerySchema = WorkspaceScopeSchema.extend({ path: z.string().min(1).describe("The folder to open, as a workspace path."), depth: z.coerce .number() .int() .min(1) .max(5) .optional() .describe("How many levels to include. Omitted means direct children only; at most five levels can be read in one request."), }); export const WorkspaceChildrenSchema = z.object({ entries: z .array(WorkspaceTreeEntrySchema) .describe( "What is inside it, as a flat list. With the default depth these are direct children; a deeper request also includes descendants, whose full paths say where they belong. Folders carry no nested contents of their own.", ), hidden: z.number().describe("How many entries were cut for size. Zero means the listing is complete."), }); export type WorkspaceChildren = z.infer; // Write routes (delete) and the read they mirror. No scope: a conversation's own checkout is read-only through the file // API, refused daemon-side. export const WorkspaceFileQuerySchema = z.object({ path: z.string().min(1).describe("The file or folder, as a workspace path.") }); export const WorkspaceMediaTicketQuerySchema = WorkspaceScopeSchema.extend({ path: z.string().min(1).describe("The media file the ticket should cover."), }); // Credential for