Skip to content
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ __pycache__/

# Ralph - AI agent loop files
.ralph*
.opencode/

# Ralph - AI agent loop files
# Ralph - AI agent loop files (plugin exception)
!.opencode/plugin/
.opencode/plugin/ralph-write-guardrail.ts
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"bun": ">=1.3.0"
},
"bin": {
"opencode-manager": "src/bin/opencode-manager.ts"
"opencode-manager": "src/bin/opencode-manager.ts",
"oqo-sess": "src/bin/opencode-manager.ts"
},
"files": [
"src",
Expand Down
29 changes: 18 additions & 11 deletions src/cli/commands/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,20 +278,27 @@ async function handleChatShow(

// Copy to clipboard if requested
if (showOpts.clipboard) {
const content = hydratedMessage.parts
?.map((p: { text: string }) => p.text)
.join("\n\n") ?? hydratedMessage.previewText
try {
await copyToClipboard(content)
if (globalOpts.format === "table") {
console.log("(copied to clipboard)")
}
} catch {
// Clipboard copy failed (e.g., xclip/pbcopy not available)
// Warn but continue - the user still gets the message output
const canAttemptClipboard = !process.env.CI && !process.env.BUN_TEST
if (!canAttemptClipboard) {
if (globalOpts.format === "table") {
console.error("Warning: Could not copy to clipboard")
}
} else {
const content = hydratedMessage.parts
?.map((p: { text: string }) => p.text)
.join("\n\n") ?? hydratedMessage.previewText
try {
await copyToClipboard(content)
if (globalOpts.format === "table") {
console.log("(copied to clipboard)")
}
} catch {
// Clipboard copy failed (e.g., xclip/pbcopy not available)
// Warn but continue - the user still gets the message output
if (globalOpts.format === "table") {
console.error("Warning: Could not copy to clipboard")
}
}
}
}

Expand Down
110 changes: 108 additions & 2 deletions src/cli/commands/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { Command, type OptionValues } from "commander"
import { realpath } from "node:fs/promises"
import { parseGlobalOptions, type GlobalOptions } from "../index"
import {
copySession,
Expand Down Expand Up @@ -44,6 +45,8 @@ function collectOptions(cmd: Command): OptionValues {
export interface SessionsListOptions {
/** Filter sessions by project ID */
project?: string
/** List all sessions globally (conflicts with --project) */
global?: boolean
/** Search query to filter sessions (fuzzy match) */
search?: string
}
Expand Down Expand Up @@ -104,12 +107,14 @@ export function registerSessionsCommands(parent: Command): void {
.command("list")
.description("List sessions")
.option("-p, --project <projectId>", "Filter by project ID")
.option("-g, --global", "List all sessions globally (default: sessions for current directory)")
.option("-s, --search <query>", "Search query to filter sessions")
.action(function (this: Command) {
const globalOpts = parseGlobalOptions(collectOptions(this))
const cmdOpts = this.opts()
const listOpts: SessionsListOptions = {
project: cmdOpts.project as string | undefined,
global: cmdOpts.global as boolean | undefined,
search: cmdOpts.search as string | undefined,
}
handleSessionsList(globalOpts, listOpts)
Expand Down Expand Up @@ -197,6 +202,9 @@ export function registerSessionsCommands(parent: Command): void {
[
"",
"Examples:",
" opencode-manager sessions list # List sessions for current directory",
" opencode-manager sessions list --global # List all sessions globally",
" opencode-manager sessions list -p my-project # List sessions for specific project",
" opencode-manager sessions list --experimental-sqlite",
" opencode-manager sessions list --db ~/.local/share/opencode/opencode.db",
].join("\n")
Expand All @@ -216,20 +224,113 @@ function buildSessionSearchText(session: SessionRecord): string {
].join(" ").replace(/\s+/g, " ").trim()
}

/**
* Result of inferring project from cwd.
*/
interface InferredProject {
projectId: string
worktree: string
}

/**
* Infer the project ID from the current working directory.
* Finds projects whose worktree is a parent of (or equal to) cwd,
* then selects the deepest match (longest path).
*
* @throws UsageError if no project matches cwd or if multiple projects match at the same depth
*/
async function inferProjectFromCwd(
provider: ReturnType<typeof createProviderFromGlobalOptions>
): Promise<InferredProject> {
const projects = await provider.loadProjectRecords()
const cwd = process.cwd()

// Find all projects whose worktree is a parent of or equal to cwd
const candidates: Array<{ project: typeof projects[0]; depth: number }> = []

for (const project of projects) {
let worktree = project.worktree
try {
// Resolve symlinks to get the real path (cwd is already resolved by process.cwd())
worktree = await realpath(worktree)
} catch {
// If realpath fails (path doesn't exist), use original worktree path
worktree = project.worktree
}
// Normalize to remove trailing slashes for consistent comparison
worktree = worktree.replace(/\/+$/, "")
// Check if cwd is inside or equal to worktree
if (cwd === worktree || cwd.startsWith(worktree + "/")) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hardcoded / separator won't work on Windows - use path.sep instead

Suggested change
if (cwd === worktree || cwd.startsWith(worktree + "/")) {
if (cwd === worktree || cwd.startsWith(worktree + path.sep)) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cli/commands/sessions.ts
Line: 253

Comment:
hardcoded `/` separator won't work on Windows - use `path.sep` instead

```suggestion
    if (cwd === worktree || cwd.startsWith(worktree + path.sep)) {
```

How can I resolve this? If you propose a fix, please make it concise.

// Depth is the number of path segments in worktree
const depth = worktree.split("/").length
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hardcoded / separator won't work on Windows - use path.sep instead

Suggested change
const depth = worktree.split("/").length
const depth = worktree.split(path.sep).length
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/cli/commands/sessions.ts
Line: 255

Comment:
hardcoded `/` separator won't work on Windows - use `path.sep` instead

```suggestion
      const depth = worktree.split(path.sep).length
```

How can I resolve this? If you propose a fix, please make it concise.

candidates.push({ project, depth })
}
}

if (candidates.length === 0) {
throw new UsageError(
`No project found for current directory: ${cwd}\n` +
`Use --global to list all sessions, or --project <id> to specify a project.`
)
}

// Find the maximum depth
const maxDepth = Math.max(...candidates.map((c) => c.depth))

// Filter to only candidates at max depth
const deepestCandidates = candidates.filter((c) => c.depth === maxDepth)

if (deepestCandidates.length > 1) {
const projectIds = deepestCandidates.map((c) => c.project.projectId).join(", ")
throw new UsageError(
`Ambiguous project match for current directory: ${cwd}\n` +
`Multiple projects match at the same depth: ${projectIds}\n` +
`Use --project <id> to specify which project.`
)
}

const winner = deepestCandidates[0].project
return {
projectId: winner.projectId,
worktree: winner.worktree,
}
}

/**
* Handle the sessions list command.
*/
async function handleSessionsList(
globalOpts: GlobalOptions,
listOpts: SessionsListOptions
): Promise<void> {
// Validate that --global and --project are not used together
if (listOpts.global && listOpts.project) {
throw new UsageError(
"Cannot use --global and --project together. Use one or the other."
)
}

// Create data provider based on global options (JSONL or SQLite backend)
const provider = createProviderFromGlobalOptions(globalOpts)

// Determine the project filter
let projectIdFilter: string | undefined
if (listOpts.global) {
// --global: list all sessions, no project filter
projectIdFilter = undefined
} else if (listOpts.project) {
// --project specified: use it
projectIdFilter = listOpts.project
} else {
// Default: infer project from current working directory
const inferred = await inferProjectFromCwd(provider)
projectIdFilter = inferred.projectId
}

// Load session records from the data layer
// If a project filter is provided, pass it to the loader
let sessions = await provider.loadSessionRecords({
projectId: listOpts.project,
projectId: projectIdFilter,
})

// Apply fuzzy search if search query is provided
Expand Down Expand Up @@ -489,21 +590,26 @@ async function handleSessionsCopy(
): Promise<void> {
const outputOpts = getOutputOptions(globalOpts)

// Create data provider based on global options (JSONL or SQLite backend)
const provider = createProviderFromGlobalOptions(globalOpts)

// Resolve session ID to a session record
const { session } = await resolveSessionId(copyOpts.session, {
root: globalOpts.root,
allowPrefix: true,
provider,
})

// Validate target project exists
// Use prefix matching for convenience, but require exactly one match
const { project: targetProject } = await resolveProjectId(copyOpts.to, {
root: globalOpts.root,
allowPrefix: true,
provider,
})

// Copy the session
const newRecord = await copySession(session, targetProject.projectId, globalOpts.root)
const newRecord = await provider.copySession(session, targetProject.projectId)

// Output success
printSuccessOutput(
Expand Down
6 changes: 4 additions & 2 deletions src/cli/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ export function exitWithError(
exitCode: ExitCodeValue = ExitCode.ERROR,
format: OutputFormat = "table"
): never {
console.error(formatErrorOutput(error, format))
const output = formatErrorOutput(error, format)
process.stderr.write(output.endsWith("\n") ? output : `${output}\n`)
process.exit(exitCode)
}

Expand All @@ -122,7 +123,8 @@ export function exitWithCLIError(
error: CLIError,
format: OutputFormat = "table"
): never {
console.error(formatErrorOutput(error, format))
const output = formatErrorOutput(error, format)
process.stderr.write(output.endsWith("\n") ? output : `${output}\n`)
process.exit(error.exitCode)
}

Expand Down
39 changes: 32 additions & 7 deletions src/lib/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { exec } from "node:child_process"
import { spawn } from "node:child_process"

/**
* Copy text to the system clipboard.
Expand All @@ -9,15 +9,40 @@ import { exec } from "node:child_process"
*/
export function copyToClipboard(text: string): Promise<void> {
return new Promise((resolve, reject) => {
const cmd =
process.platform === "darwin" ? "pbcopy" : "xclip -selection clipboard"
const proc = exec(cmd, (error) => {
if (error) {
reject(error)
} else {
const isDarwin = process.platform === "darwin"
const isLinux = process.platform === "linux"
const displayValue = process.env.DISPLAY?.trim()
const waylandValue = process.env.WAYLAND_DISPLAY?.trim()
const hasDisplay = Boolean(displayValue || waylandValue)
if (isLinux && !hasDisplay) {
reject(new Error("Clipboard not available (no display)"))
return
}

const command = isDarwin ? "pbcopy" : "xclip"
const args = isDarwin ? [] : ["-selection", "clipboard"]
const proc = spawn(command, args, { stdio: ["pipe", "ignore", "ignore"] })
proc.unref()
const timeout = setTimeout(() => {
proc.kill("SIGKILL")
reject(new Error(`${command} timed out`))
}, 2000)

proc.on("error", (error) => {
clearTimeout(timeout)
reject(error)
})

proc.on("close", (code) => {
clearTimeout(timeout)
if (code === 0) {
resolve()
} else {
const suffix = code === null ? "unknown" : String(code)
reject(new Error(`${command} exited with code ${suffix}`))
}
})

proc.stdin?.write(text)
proc.stdin?.end()
})
Expand Down
Loading