Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions backend/cli/src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export namespace Agent {
}),
user,
),
mode: "all",
mode: "subagent",
native: true,
},
// --- Physics ---
Expand All @@ -122,7 +122,7 @@ export namespace Agent {
}),
user,
),
mode: "all",
mode: "subagent",
native: true,
},
// --- Machine learning ---
Expand All @@ -139,7 +139,7 @@ export namespace Agent {
}),
user,
),
mode: "all",
mode: "subagent",
native: true,
},
// --- Utilities ---
Expand Down Expand Up @@ -450,7 +450,9 @@ export namespace Agent {
return agent.name
}

const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
const primaryVisible = Object.values(agents).find(
(agent) => agent.mode !== "subagent" && agent.hidden !== true && agent.name !== "plan",
)
if (!primaryVisible) throw new Error("no primary visible agent found")
return primaryVisible.name
}
Expand Down
11 changes: 11 additions & 0 deletions backend/cli/src/cli/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,17 @@ export const DoctorCommand = cmd({
prompts.log.info(`Cache root: ${Global.Path.cache}`)
prompts.log.info(`State root: ${Global.Path.state}`)

if (Global.DataMigration.migrated) {
prompts.log.success(
`Data copied to ~/.openscience and verified (${Global.DataMigration.migrated.files} files). The previous XDG directory remains as a safety copy.`,
)
}
if (Global.DataMigration.error) {
prompts.log.warn(
`Data migration to ~/.openscience did not complete; OpenScience is still using the previous directory. ${Global.DataMigration.error}`,
)
}

if (Global.LegacyConflicts.length) {
prompts.log.warn(
`Legacy data directories are ignored because current directories exist: ${Global.LegacyConflicts.map((item) => item.legacy).join(", ")}. Merge or remove them.`,
Expand Down
120 changes: 120 additions & 0 deletions backend/cli/src/global/data-dir.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import fs from "fs/promises"
import { createHash } from "node:crypto"
import { createReadStream } from "node:fs"
import path from "node:path"

const reserved = new Set(["bin", ".xdg-data-migration-v1.json"])

export interface DataResolution {
path: string
migrated?: { source: string; target: string; files: number; bytes: number }
conflict?: { legacy: string; current: string }
error?: string
}

async function entries(root: string) {
return fs.readdir(root, { withFileTypes: true }).catch(() => [])
}

async function hash(file: string) {
const value = createHash("sha256")
for await (const chunk of createReadStream(file)) value.update(chunk)
return value.digest("hex")
}

async function manifest(root: string) {
const stack = [root]
const files: Array<{ path: string; bytes: number; sha256: string }> = []
while (stack.length) {
const dir = stack.pop()
if (!dir) continue
for (const entry of await entries(dir)) {
const full = path.join(dir, entry.name)
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) {
stack.push(full)
continue
}
if (!entry.isFile()) continue
const stat = await fs.stat(full)
files.push({ path: path.relative(root, full), bytes: stat.size, sha256: await hash(full) })
}
}
return files.sort((a, b) => a.path.localeCompare(b.path))
}

async function copy(source: string, target: string) {
await fs.mkdir(target, { recursive: true })
for (const entry of await entries(source)) {
if (reserved.has(entry.name) || entry.isSymbolicLink()) continue
await fs.cp(path.join(source, entry.name), path.join(target, entry.name), {
recursive: true,
force: false,
errorOnExist: true,
})
}
}

export async function resolveDataDirectory(input: {
home: string
legacy: string
explicit?: string
pointer?: string
}): Promise<DataResolution> {
if (input.explicit) return { path: path.resolve(input.explicit) }
if (input.pointer) return { path: path.resolve(input.pointer) }

const target = path.join(path.resolve(input.home), ".openscience")
const legacy = path.resolve(input.legacy)
const migrated = await fs
.stat(path.join(target, ".xdg-data-migration-v1.json"))
.then((stat) => stat.isFile())
.catch(() => false)
if (migrated) return { path: target }
const source = await entries(legacy)
if (source.length === 0 || legacy === target) return { path: target }

const occupied = (await entries(target)).filter((entry) => !reserved.has(entry.name))
if (occupied.length > 0) {
return { path: target, conflict: { legacy, current: target } }
}

const stage = await fs.mkdtemp(path.join(path.resolve(input.home), ".openscience-migrate-"))
const moved: string[] = []
const result = await copy(legacy, stage)
.then(async () => {
const before = await manifest(stage)
const original = (await manifest(legacy)).filter((file) => !reserved.has(file.path.split(path.sep)[0]))
if (JSON.stringify(before) !== JSON.stringify(original)) throw new Error("checksum verification failed")
await fs.mkdir(target, { recursive: true })
for (const entry of await entries(stage)) {
await fs.rename(path.join(stage, entry.name), path.join(target, entry.name))
moved.push(entry.name)
}
const bytes = before.reduce((total, file) => total + file.bytes, 0)
const migrated = { source: legacy, target, files: before.length, bytes }
await Bun.write(
path.join(target, ".xdg-data-migration-v1.json"),
`${JSON.stringify({ ...migrated, migratedAt: Date.now() }, null, 2)}\n`,
{ mode: 0o600 },
)
return { path: target, migrated } satisfies DataResolution
})
.catch(async (error: unknown) => {
await Promise.all(
moved
.reverse()
.map((name) =>
fs
.rename(path.join(target, name), path.join(stage, name))
.catch(() => fs.rm(path.join(target, name), { recursive: true, force: true })),
),
)
return {
path: legacy,
error: error instanceof Error ? error.message : String(error),
}
})
await fs.rm(stage, { recursive: true, force: true })
return result
}
29 changes: 17 additions & 12 deletions backend/cli/src/global/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readFileSync, existsSync, renameSync } from "fs"
import { xdgData, xdgCache, xdgConfig, xdgState } from "xdg-basedir"
import path from "path"
import os from "os"
import { resolveDataDirectory } from "./data-dir"

const app = "openscience"

Expand Down Expand Up @@ -53,21 +54,24 @@ const config = override("OPENSCIENCE_CONFIG_DIR") ?? migrateDir(xdgConfig!)
const state = migrateDir(xdgState!)

// The data directory can be relocated from settings ▸ Storage. When a pointer
// file exists (config/data-location) we honour it; otherwise the XDG default.
// Read synchronously at boot so every Global.Path.data consumer sees one value.
function resolveDataDir(): string {
const explicit = override("OPENSCIENCE_DATA_DIR")
if (explicit) return explicit
const fallback = migrateDir(xdgData!)
// file exists (config/data-location) we honour it; otherwise ~/.openscience.
// Resolve once at boot so every Global.Path.data consumer sees one value.
const explicit = override("OPENSCIENCE_DATA_DIR")
const pointer = (() => {
try {
const pointer = readFileSync(path.join(config, "data-location"), "utf8").trim()
return pointer ? path.resolve(pointer) : fallback
return readFileSync(path.join(config, "data-location"), "utf8").trim() || undefined
} catch {
return fallback
return
}
}

const data = resolveDataDir()
})()
const resolved = await resolveDataDirectory({
home: process.env.OPENSCIENCE_TEST_HOME || os.homedir(),
legacy: migrateDir(xdgData!),
explicit,
pointer,
})
const data = resolved.path
if (resolved.conflict) detectedLegacyConflicts.push(resolved.conflict)

// Legacy file names inside the migrated dirs (pre-rename releases).
migrateFile(data, "synsci-session.json", "openscience-session.json")
Expand All @@ -77,6 +81,7 @@ migrateFile(config, "synsc.json", "openscience.json")

export namespace Global {
export const LegacyConflicts = detectedLegacyConflicts as readonly { legacy: string; current: string }[]
export const DataMigration = resolved
export const Path = {
// Allow override via OPENSCIENCE_TEST_HOME for test isolation
get home() {
Expand Down
4 changes: 4 additions & 0 deletions backend/cli/src/server/routes/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ export const SessionRoutes = lazy(() =>
time: z
.object({
archived: z.number().optional(),
pinned: z.number().optional(),
})
.optional(),
}),
Expand All @@ -454,6 +455,9 @@ export const SessionRoutes = lazy(() =>
session.title = updates.title
}
if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived
if (updates.time?.pinned !== undefined) {
session.time.pinned = updates.time.pinned || undefined
}
},
{ touch: false },
)
Expand Down
10 changes: 10 additions & 0 deletions backend/cli/src/server/routes/settings/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ export const Preferences = z.object({
// Soft managed-compute spend ceiling in USD the user sets for themselves
// (Usage → Extra usage budget). 0 = no extra budget beyond the plan.
extra_budget_usd: z.number().min(0).default(0),
// The session trace is an advanced observability surface. Keep the regular
// workspace quiet unless the user explicitly enables it in General.
show_trace: z.boolean().default(false),
// Atlas is part of the standard research workspace, but can be hidden from
// navigation without changing or deleting any Atlas data.
atlas_enabled: z.boolean().default(true),
// Composer delegation is available by default. A selected specialist makes
// the next normal prompt explicitly delegate to that subagent.
delegation_enabled: z.boolean().default(true),
delegation_specialist: z.string().nullable().default(null),
})
export type Preferences = z.infer<typeof Preferences>

Expand Down
2 changes: 1 addition & 1 deletion backend/cli/src/server/routes/settings/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ export const StorageRoutes = lazy(() =>
"/location",
describeRoute({
summary: "Reset data location",
description: "Remove the data-location pointer so the default location is used on next launch.",
description: "Remove the data-location pointer so ~/.openscience is used on next launch.",
operationId: "settings.storage.resetLocation",
responses: {
200: {
Expand Down
5 changes: 4 additions & 1 deletion backend/cli/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,12 @@ export namespace Session {
const childTitlePrefix = "Child session - "

function createDefaultTitle(isChild = false) {
return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString()
if (!isChild) return "New session"
return childTitlePrefix + new Date().toISOString()
}

export function isDefaultTitle(title: string) {
if (title === "New session") return true
return new RegExp(
`^(${parentTitlePrefix}|${childTitlePrefix})\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}\\.\\d{3}Z$`,
).test(title)
Expand Down Expand Up @@ -80,6 +82,7 @@ export namespace Session {
updated: z.number(),
compacting: z.number().optional(),
archived: z.number().optional(),
pinned: z.number().optional(),
}),
permission: PermissionNext.Ruleset.optional(),
revert: z
Expand Down
1 change: 1 addition & 0 deletions backend/cli/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ export namespace MessageV2 {
}),
system: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
delegation: z.boolean().optional(),
variant: z.string().optional(),
tier: z.string().optional(),
inference: Inference.Info.optional(),
Expand Down
9 changes: 9 additions & 0 deletions backend/cli/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ export namespace SessionPrompt {
.describe(
"@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
),
delegation: z.boolean().optional(),
system: z.string().optional(),
variant: z.string().optional(),
tier: z.string().optional(),
Expand Down Expand Up @@ -805,6 +806,7 @@ export namespace SessionPrompt {
session,
model,
tools: lastUser.tools,
delegation: lastUser.delegation,
processor,
bypassAgentCheck,
messages: msgs,
Expand Down Expand Up @@ -959,6 +961,7 @@ export namespace SessionPrompt {
model: Provider.Model
session: Session.Info
tools?: Record<string, boolean>
delegation?: boolean
processor: SessionProcessor.Info
bypassAgentCheck: boolean
messages: MessageV2.WithParts[]
Expand Down Expand Up @@ -1140,9 +1143,14 @@ export namespace SessionPrompt {
tools[key] = item
}

if (!allowsDelegation(input.delegation, input.bypassAgentCheck)) delete tools.task
return tools
}

export function allowsDelegation(enabled: boolean | undefined, explicit: boolean) {
return enabled !== false || explicit
}

async function createUserMessage(input: PromptInput) {
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
const session = await Session.get(input.sessionID)
Expand All @@ -1167,6 +1175,7 @@ export namespace SessionPrompt {
created: Date.now(),
},
tools: input.tools,
delegation: input.delegation,
agent: agent.name,
model,
system: input.system,
Expand Down
2 changes: 2 additions & 0 deletions backend/cli/src/session/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,9 +166,11 @@ export namespace SessionReview {
export async function start(sessionID: string, target?: Target): Promise<Bound | undefined> {
if (!target) await grant(sessionID)
const review = await packet(sessionID, target)
const settings = await ReviewSettings.get().catch(() => undefined)
void SessionPrompt.prompt({
sessionID,
agent: review.agent,
model: settings?.model ?? undefined,
parts: [{ type: "text", text: review.text }],
}).catch((error) => log.error("review pass failed", { sessionID, error }))
return "target" in review ? review.target : undefined
Expand Down
7 changes: 6 additions & 1 deletion backend/cli/src/settings/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@ import { Global } from "../global"
// kicking off a reviewer pass automatically after a significant result (a
// durable artifact save). Persisted like the other settings stores.
export namespace ReviewSettings {
export const Model = z.object({
providerID: z.string(),
modelID: z.string(),
})
export const State = z.object({
auto: z.boolean(),
model: Model.nullable().default(null),
})
export type State = z.infer<typeof State>

const file = path.join(Global.Path.data, "settings", "review.json")

function fallback(): State {
return { auto: false }
return { auto: false, model: null }
}

export async function get(): Promise<State> {
Expand Down
2 changes: 1 addition & 1 deletion backend/cli/src/skill/install/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface InstallResult {
}

function installedDir(): string {
// Same path the loader scans (Global.Path.data resolves XDG_DATA_HOME first).
// Same path the loader scans (Global.Path.data defaults to ~/.openscience).
// Allow tests to override via OPENSCIENCE_DATA_DIR without monkey-patching globals.
const base = process.env.OPENSCIENCE_DATA_DIR ?? Global.Path.data
return path.join(base, "installed-skills")
Expand Down
Loading