diff --git a/app/components/CardDetail.tsx b/app/components/CardDetail.tsx index 092bb7f..5cca924 100644 --- a/app/components/CardDetail.tsx +++ b/app/components/CardDetail.tsx @@ -458,6 +458,22 @@ export const CardDetail = observer(function CardDetail({ )} + {/* PR URL */} + {hasSession && ( +
+ + { + const val = e.target.value.trim() || null; + if (val !== card.prUrl) cardStore.updateCard({ id: card.id, prUrl: val }); + }} + placeholder="https://github.com/org/repo/pull/123" + /> +
+ )} + {/* Model & Thinking */} {!hasSession && (
diff --git a/app/components/ProjectForm.tsx b/app/components/ProjectForm.tsx index 5471ccf..86cd3ae 100644 --- a/app/components/ProjectForm.tsx +++ b/app/components/ProjectForm.tsx @@ -124,7 +124,7 @@ export default observer(function ProjectForm({ project, onDone }: ProjectFormPro type="text" value={path} onChange={(e) => setPath(e.target.value)} - placeholder="/home/ryan/Code/my-project" + placeholder="~/Code/my-project" className="font-mono" />
diff --git a/app/components/SessionView.tsx b/app/components/SessionView.tsx index 4364e73..4320005 100644 --- a/app/components/SessionView.tsx +++ b/app/components/SessionView.tsx @@ -1,14 +1,24 @@ -import { useState, useRef, useEffect, useCallback } from 'react'; +import { + AlertCircle, + GitPullRequestArrow, + LoaderCircle, + Paperclip, + Play, + Send, + Square, + WifiOff, + X, +} from 'lucide-react'; import { observer } from 'mobx-react-lite'; -import { Send, Square, Play, AlertCircle, Paperclip, X, WifiOff } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Badge } from '~/components/ui/badge'; import { Button } from '~/components/ui/button'; import { Textarea } from '~/components/ui/textarea'; -import { Badge } from '~/components/ui/badge'; +import { useCardStore, useConfigStore, useSessionStore, useStore } from '~/stores/context'; +import type { FileRef } from '../../src/shared/ws-protocol'; import { ContextGauge } from './ContextGauge'; -import { SubagentFeed } from './SubagentFeed'; import { LazyTranscript } from './LazyTranscript'; -import { useSessionStore, useCardStore, useConfigStore, useStore } from '~/stores/context'; -import type { FileRef } from '../../src/shared/ws-protocol'; +import { SubagentFeed } from './SubagentFeed'; type Props = { cardId: number; @@ -134,9 +144,7 @@ export const SessionView = observer(function SessionView({ const showCounters = promptsSent > 0 || turnsCompleted > 0; const contextPercent = contextWindow > 0 ? Math.min(100, (contextTokens / contextWindow) * 100) : 0; const retryAfterMs = session?.accumulator.retryAfterMs ?? null; - const retryInfo = sessionStatus === 'retry' && retryAfterMs != null - ? { retryAfterMs } - : null; + const retryInfo = sessionStatus === 'retry' && retryAfterMs != null ? { retryAfterMs } : null; async function handleSend(message: string, files?: FileRef[]) { try { @@ -198,9 +206,7 @@ export const SessionView = observer(function SessionView({ {/* Status bar — above prompt input */} {(isStreaming || conversation.length > 0) && (
- + {retryInfo && ( Rate limited — retrying in {Math.ceil(retryInfo.retryAfterMs / 1000)}s @@ -262,15 +268,20 @@ export const SessionView = observer(function SessionView({ {isStopping ? 'Stopping...' : 'Stop'} ) : sessionId ? ( - +
+ {card?.column === 'review' && card?.prUrl && ( + handleSend(prompt)} /> + )} + +
) : null}
)} @@ -289,7 +300,13 @@ export const SessionView = observer(function SessionView({ isPending={isStarting} onSend={handleSend} onStop={handleStop} - onCompact={!!sessionId || sessionActive ? (bgcInProgress ? undefined : () => sessionStore.compactSession(cardId)) : undefined} + onCompact={ + !!sessionId || sessionActive + ? bgcInProgress + ? undefined + : () => sessionStore.compactSession(cardId) + : undefined + } onPromptSent={onPromptSent} sendPending={false} contextPercent={contextPercent} @@ -300,6 +317,104 @@ export const SessionView = observer(function SessionView({ ); }); +function buildPrCommentsPrompt(prUrl: string): string { + return `Review and address all comments on this pull request: ${prUrl} + +Steps: +1. Run \`gh pr view ${prUrl} --json comments,reviews\` to get all PR comments and review comments +2. Also run \`gh api repos/{owner}/{repo}/pulls/{number}/comments\` for inline code comments +3. For each comment: + - Evaluate whether the feedback is actionable + - If it requires a code change, make the fix + - If it's a question, add a reply via \`gh pr comment\` +4. After making all changes, commit with a message referencing the PR review +5. Push the changes + +Be thorough — address every comment, don't skip any.`; +} + +function buildFailedChecksPrompt( + prUrl: string, + failedChecks: { name: string; conclusion: string; detailsUrl: string }[], +): string { + const checkList = failedChecks + .map((c) => `- **${c.name}** (${c.conclusion})${c.detailsUrl ? `: ${c.detailsUrl}` : ''}`) + .join('\n'); + return `CI checks failed on this pull request: ${prUrl} + +Failed checks: +${checkList} + +Steps: +1. For each failed check, run \`gh run view --log-failed\` to get failure logs (extract run ID from the details URL) +2. Analyze the failure — is it a test failure, lint error, build error, or flaky test? +3. Fix the root cause in code +4. After making all fixes, commit with a message referencing the CI failures +5. Push the changes + +If a check failed due to a flaky test (not related to this PR's changes), note it but focus on genuine failures.`; +} + +// --- Check PR button --- + +function CheckPrButton({ + prUrl, + cardId, + onAddressComments, +}: { + prUrl: string; + cardId: number; + onAddressComments: (prompt: string) => void; +}) { + const [checking, setChecking] = useState(false); + const cardStore = useCardStore(); + + async function handleCheck() { + setChecking(true); + try { + const res = await fetch('/api/pr-check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prUrl }), + }); + if (!res.ok) return; + + const data = (await res.json()) as { + merged: boolean; + hasComments: boolean; + failedChecks: { name: string; conclusion: string; detailsUrl: string }[]; + }; + + if (data.merged) { + await cardStore.updateCard({ id: cardId, column: 'done' }); + } else if (data.failedChecks?.length > 0 && data.hasComments) { + onAddressComments( + buildFailedChecksPrompt(prUrl, data.failedChecks) + '\n\n---\n\nAlso, ' + buildPrCommentsPrompt(prUrl), + ); + } else if (data.failedChecks?.length > 0) { + onAddressComments(buildFailedChecksPrompt(prUrl, data.failedChecks)); + } else if (data.hasComments) { + onAddressComments(buildPrCommentsPrompt(prUrl)); + } + } finally { + setChecking(false); + } + } + + return ( + + ); +} + // --- Status badge --- function StatusBadge({ status }: { status: string }) { diff --git a/config.example.yaml b/config.example.yaml index d1c44fe..677a5c2 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -2,10 +2,16 @@ # Used by both orcd (daemon) and orc (backend/UI). # Copy to config.yaml and fill in your providers. config.yaml is gitignored. -socket: ~/.orc/orcd.sock # orcd UNIX socket path +socket: ~/.orc/orcd.sock # orcd UNIX socket path defaultProvider: anthropic defaultModel: sonnet -defaultCwd: ~/Code # where new cards default their working directory +defaultCwd: ~/Code # where new cards default their working directory +# claudeCodePath: /usr/local/bin/claude # path to claude CLI binary (omit to use PATH default) + +# Extra Claude settings files loaded into every session (highest priority). +# Permissions arrays merge; other fields use last-wins. +# extraSettings: +# - ~/Me/.claude/settings.local.json providers: # Claude via Anthropic API. @@ -13,9 +19,9 @@ providers: anthropic: label: Anthropic models: - opus: { label: "Opus 4.7", modelID: claude-opus-4-7, contextWindow: 1000000 } - sonnet: { label: "Sonnet 4.6", modelID: claude-sonnet-4-6, contextWindow: 1000000 } - haiku: { label: "Haiku 4.5", modelID: claude-haiku-4-5-20251001, contextWindow: 200000 } + opus: { label: 'Opus 4.7', modelID: claude-opus-4-7, contextWindow: 1000000 } + sonnet: { label: 'Sonnet 4.6', modelID: claude-sonnet-4-6, contextWindow: 1000000 } + haiku: { label: 'Haiku 4.5', modelID: claude-haiku-4-5-20251001, contextWindow: 200000 } # Example: a local proxy (like a Claude-compatible pool proxy). # proxy: diff --git a/scripts/backup-db.sh b/scripts/backup-db.sh index c02d3ff..89e5f00 100755 --- a/scripts/backup-db.sh +++ b/scripts/backup-db.sh @@ -1,8 +1,9 @@ #!/bin/bash set -ex -DB="/home/ryan/Code/orchestrel/data/orchestrel.db" -BACKUP_DIR="/mnt/D/Sync/orchestra-backups" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DB="${ORC_DB_PATH:-$SCRIPT_DIR/../data/orchestrel.db}" +BACKUP_DIR="${ORC_BACKUP_DIR:-/mnt/D/Sync/orchestra-backups}" MAX_AGE_DAYS=3 # SQLite-safe backup using .backup command diff --git a/src/lib/memory-upsert.ts b/src/lib/memory-upsert.ts index d4b1546..cff8e65 100644 --- a/src/lib/memory-upsert.ts +++ b/src/lib/memory-upsert.ts @@ -17,7 +17,7 @@ */ import { readFile } from 'fs/promises'; import { z } from 'zod'; -import { resolveJsonlPath, parseLines, buildExcerpt, queryAgentSdk } from './session-compactor'; +import { buildExcerpt, parseLines, queryAgentSdk, resolveJsonlPath } from './session-compactor'; const LOG = '[memory-upsert]'; @@ -42,6 +42,7 @@ export interface MemoryUpsertOpts { projectName: string; model: string; env?: Record; + claudeCodePath?: string; memoryBaseUrl: string; memoryApiKey: string; maxExcerptChars?: number; @@ -78,7 +79,7 @@ async function httpSearch( const body = await res.text().catch(() => ''); throw new Error(`memory search ${res.status}: ${body}`); } - const json = await res.json() as { data: SearchHit[] }; + const json = (await res.json()) as { data: SearchHit[] }; return json.data; } @@ -102,7 +103,7 @@ async function httpStore( const body = await res.text().catch(() => ''); throw new Error(`memory store ${res.status}: ${body}`); } - const json = await res.json() as { id: string }; + const json = (await res.json()) as { id: string }; return { id: json.id }; } @@ -129,11 +130,7 @@ async function httpUpdate( } } -async function httpDelete( - baseUrl: string, - apiKey: string, - id: string, -): Promise { +async function httpDelete(baseUrl: string, apiKey: string, id: string): Promise { const res = await fetch(`${baseUrl}/api/v1/memories/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${apiKey}` }, @@ -175,13 +172,15 @@ async function buildMemoryTools( async (args) => { counters.search++; const hits = await httpSearch(baseUrl, apiKey, args.query, project, args.limit ?? 10); - return asText(hits.map(h => ({ - id: h.id, - title: h.title, - score: Number(h.score.toFixed(3)), - tags: h.tags, - preview: (h.text ?? '').slice(0, 400), - }))); + return asText( + hits.map((h) => ({ + id: h.id, + title: h.title, + score: Number(h.score.toFixed(3)), + tags: h.tags, + preview: (h.text ?? '').slice(0, 400), + })), + ); }, ); @@ -190,7 +189,10 @@ async function buildMemoryTools( 'Create a NEW memory. Only call this after search_memory confirms no existing memory covers this topic. Title should be a short descriptive label (max ~10 words) optimised for semantic search. Text is the full content.', { title: z.string().min(1).max(200).describe('Short descriptive title, <= 10 words.'), - text: z.string().min(1).describe('Full memory content. Be thorough — include context, file paths, commands, rationale.'), + text: z + .string() + .min(1) + .describe('Full memory content. Be thorough — include context, file paths, commands, rationale.'), tags: z.array(z.string()).optional().describe('Optional tags for categorization.'), }, async (args) => { @@ -206,7 +208,10 @@ async function buildMemoryTools( { id: z.string().describe('Memory id returned by search_memory.'), title: z.string().min(1).max(200).describe('New title (may be same as old).'), - text: z.string().min(1).describe('New full text. Replaces the old text entirely — include everything that should remain.'), + text: z + .string() + .min(1) + .describe('New full text. Replaces the old text entirely — include everything that should remain.'), tags: z.array(z.string()).optional().describe('New tags (replaces old tag list).'), }, async (args) => { @@ -315,7 +320,9 @@ export async function upsertMemories(opts: MemoryUpsertOpts): Promise; + claudeCodePath?: string; ratio?: number; maxExcerptChars?: number; dryRun?: boolean; @@ -41,7 +42,6 @@ export interface IndexedMessage { isToolUse: boolean; } - // ─── JSONL path resolution ────────────────────────────────────────────────── export function computeSlug(realPath: string): string { @@ -97,9 +97,9 @@ export function parseLines(lines: string[]): { lastBoundaryLine: number; message if (!text.trim()) continue; // Detect tool_result / tool_use content blocks for boundary snapping - const blocks = Array.isArray(content) ? content as Array> : []; - const isToolResult = blocks.some(b => b.type === 'tool_result'); - const isToolUse = blocks.some(b => b.type === 'tool_use'); + const blocks = Array.isArray(content) ? (content as Array>) : []; + const isToolResult = blocks.some((b) => b.type === 'tool_result'); + const isToolUse = blocks.some((b) => b.type === 'tool_use'); messages.push({ lineIndex: i, role: role as 'user' | 'assistant', text, isToolResult, isToolUse }); } @@ -200,6 +200,7 @@ export interface QueryAgentSdkOpts { disallowedSkills?: string[]; /** Default: disabled. */ thinking?: Options['thinking']; + claudeCodePath?: string; } /** @@ -223,7 +224,7 @@ export async function queryAgentSdk( maxTurns: opts.maxTurns ?? 1, permissionMode: 'bypassPermissions', allowDangerouslySkipPermissions: true, - pathToClaudeCodeExecutable: '/home/ryan/.local/bin/claude', + ...(opts?.claudeCodePath ? { pathToClaudeCodeExecutable: opts?.claudeCodePath } : {}), tools: opts.tools ?? [], disallowedTools: [...DEFAULT_DISABLED_TOOLS, ...(opts.disallowedTools ?? [])], settings: { skillOverrides: disabledSkillOverrides(disabledSkills) }, @@ -262,7 +263,9 @@ function findVersion(lines: string[]): string { try { const obj = JSON.parse(trimmed) as Record; if (typeof obj.version === 'string') return obj.version; - } catch { /* skip */ } + } catch { + /* skip */ + } } return '2.1.108'; // fallback } diff --git a/src/orcd/config.ts b/src/orcd/config.ts index 51fa8f9..acf230f 100644 --- a/src/orcd/config.ts +++ b/src/orcd/config.ts @@ -17,6 +17,8 @@ export interface OrcdConfig { defaultProvider: string; defaultModel: string; defaultCwd?: string; + claudeCodePath?: string; + extraSettings?: string[]; providers: Record; memoryUpsert?: MemoryUpsertConfig; } @@ -43,6 +45,8 @@ function toOrcdShape(cfg: OrchestrelConfig): OrcdConfig { defaultProvider: cfg.defaultProvider, defaultModel: cfg.defaultModel, defaultCwd: cfg.defaultCwd, + claudeCodePath: cfg.claudeCodePath, + extraSettings: cfg.extraSettings, providers, memoryUpsert: cfg.memoryUpsert, }; diff --git a/src/orcd/index.ts b/src/orcd/index.ts index de72885..d9a71fc 100644 --- a/src/orcd/index.ts +++ b/src/orcd/index.ts @@ -10,10 +10,14 @@ async function main() { // Resolve ~ in socket path const socketPath = config.socket.replace(/^~/, homedir()); + const extraSettings = (config.extraSettings ?? []).map( + (p) => p.replace(/^~/, homedir()), + ); + const server = new OrcdServer(socketPath, config.providers, { provider: config.defaultProvider, model: config.defaultModel, - }, config.memoryUpsert); + }, config.memoryUpsert, config.claudeCodePath, extraSettings); await server.start(); diff --git a/src/orcd/session.ts b/src/orcd/session.ts index 7aed749..7ea6ff5 100644 --- a/src/orcd/session.ts +++ b/src/orcd/session.ts @@ -1,13 +1,11 @@ +import type { Options, Query } from '@anthropic-ai/claude-agent-sdk'; +import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk'; import { randomUUID } from 'crypto'; +import { readFileSync } from 'fs'; import { readFile } from 'fs/promises'; -import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk'; -import type { Query, Options } from '@anthropic-ai/claude-agent-sdk'; import { resolveJsonlPath } from '../lib/session-compactor'; import { DEFAULT_DISABLED_SKILLS, disabledSkillOverrides } from '../shared/agent-sdk-skills'; import { AUTO_COMPACT_RATIO } from '../shared/constants'; -import { AsyncTaskTracker, extractAsyncAgentLaunches, parseTaskNotification } from './async-task-tracker'; -import { RingBuffer } from './ring-buffer'; -import type { SessionState } from './types'; import type { ContextUsageMessage, SessionErrorMessage, @@ -17,9 +15,52 @@ import type { StreamEventMessage, } from '../shared/orcd-protocol'; import type { TaskNotificationEvent, TaskStartedEvent } from './async-task-tracker'; +import { AsyncTaskTracker, extractAsyncAgentLaunches, parseTaskNotification } from './async-task-tracker'; +import { RingBuffer } from './ring-buffer'; +import type { SessionState } from './types'; + +type SettingsObj = Record; + +function loadAndMergeSettings(paths: string[]): SettingsObj | undefined { + if (paths.length === 0) { + console.log(`[orcd:effort] settings → no paths`); + return undefined; + } + const merged: SettingsObj = {}; + for (const p of paths) { + try { + const raw = JSON.parse(readFileSync(p, 'utf-8')) as SettingsObj; + for (const [k, v] of Object.entries(raw)) { + if (k === 'permissions' && typeof v === 'object' && v !== null) { + const existing = (merged.permissions ?? {}) as Record; + const incoming = v as Record; + for (const [pk, pv] of Object.entries(incoming)) { + if (Array.isArray(pv) && Array.isArray(existing[pk])) { + existing[pk] = [...(existing[pk] as unknown[]), ...pv]; + } else { + existing[pk] = pv; + } + } + merged.permissions = existing; + } else { + merged[k] = v; + } + } + } catch (err) { + console.warn(`[orcd] failed to load extra settings ${p}: ${err instanceof Error ? err.message : err}`); + } + } + return Object.keys(merged).length > 0 ? merged : undefined; +} export type SessionEventCallback = ( - msg: StreamEventMessage | SessionResultMessage | SessionErrorMessage | SessionExitMessage | ContextUsageMessage | SessionIdUpdateMessage, + msg: + | StreamEventMessage + | SessionResultMessage + | SessionErrorMessage + | SessionExitMessage + | ContextUsageMessage + | SessionIdUpdateMessage, ) => void; /** @@ -38,13 +79,7 @@ function effortToOptions(effort: string | undefined): Pick; @@ -131,10 +168,8 @@ export class OrcdSession { private async scanJsonlTaskNotifications(): Promise { const path = await this.getJsonlPath(); const text = await readFile(path, 'utf8').catch((err: unknown) => { - const isMissingJsonl = err instanceof Error - && this.isRecord(err) - && typeof err.code === 'string' - && err.code === 'ENOENT'; + const isMissingJsonl = + err instanceof Error && this.isRecord(err) && typeof err.code === 'string' && err.code === 'ENOENT'; if (!isMissingJsonl) throw err; return ''; }); @@ -182,6 +217,8 @@ export class OrcdSession { bufferSize?: number; sessionId?: string; // For resume — use existing CC session UUID contextWindow?: number; + claudeCodePath?: string; + extraSettings?: string[]; summarizeThreshold?: number; onFork?: (oldId: string, newId: string) => void; jsonlPathForTesting?: string; @@ -192,6 +229,8 @@ export class OrcdSession { this.model = opts.model; this.provider = opts.provider; this.contextWindow = opts.contextWindow; + this.claudeCodePath = opts.claudeCodePath; + this.extraSettings = opts.extraSettings ?? []; this.summarizeThreshold = opts.summarizeThreshold ?? 0; this.buffer = new RingBuffer(opts.bufferSize ?? 1000); this.onFork = opts.onFork; @@ -226,12 +265,7 @@ export class OrcdSession { * Start or resume a session. * Consumes the Agent SDK async iterator and broadcasts events. */ - async run(opts: { - prompt: string; - resume?: boolean; - env?: Record; - effort?: string; - }): Promise { + async run(opts: { prompt: string; resume?: boolean; env?: Record; effort?: string }): Promise { const log = (msg: string) => console.log(`[orcd:${this.id.slice(0, 8)}] ${msg}`); const thinkingOpts = effortToOptions(opts.effort); @@ -241,6 +275,12 @@ export class OrcdSession { ? Math.max(Math.floor(this.contextWindow * AUTO_COMPACT_RATIO), 100_000) : undefined; + const extraMerged = loadAndMergeSettings(this.extraSettings); + const settings: SettingsObj = { + ...(extraMerged ?? {}), + ...(autoCompactWindow ? { autoCompactWindow } : {}), + }; + const q = sdkQuery({ prompt: opts.prompt, options: { @@ -252,12 +292,13 @@ export class OrcdSession { disallowedTools: [...SESSION_DISABLED_TOOLS], settingSources: ['user', 'project'], includePartialMessages: true, - pathToClaudeCodeExecutable: '/home/ryan/.local/bin/claude', + ...(this.claudeCodePath ? { pathToClaudeCodeExecutable: this.claudeCodePath } : {}), env: opts.env, ...thinkingOpts, settings: { skillOverrides: disabledSkillOverrides(DEFAULT_DISABLED_SKILLS), ...(autoCompactWindow ? { autoCompactWindow } : {}), + ...(Object.keys(settings).length > 0 ? { settings } : {}), }, }, }); @@ -304,9 +345,7 @@ export class OrcdSession { const u = this.isRecord(msg) ? (msg.usage as Record | undefined) : undefined; if (u) { lastInputTokens = - (u.input_tokens ?? 0) + - (u.cache_creation_input_tokens ?? 0) + - (u.cache_read_input_tokens ?? 0); + (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0); } } else if (this.isRecord(inner) && inner.type === 'message_delta' && lastInputTokens === 0) { const u = this.isRecord(inner.usage) ? (inner.usage as Record) : undefined; diff --git a/src/orcd/socket-server.ts b/src/orcd/socket-server.ts index e52f108..3bb2783 100644 --- a/src/orcd/socket-server.ts +++ b/src/orcd/socket-server.ts @@ -29,6 +29,8 @@ export class OrcdServer { private providers: Record, private defaults: { provider: string; model: string }, memoryConfig?: OrcdConfig['memoryUpsert'], + private claudeCodePath?: string, + private extraSettings?: string[], ) { this.memoryConfig = memoryConfig; } @@ -148,6 +150,8 @@ export class OrcdServer { sessionId: action.sessionId, contextWindow: action.contextWindow, summarizeThreshold: action.summarizeThreshold, + claudeCodePath: this.claudeCodePath, + extraSettings: this.extraSettings, onFork: (oldId, newId) => this.store.alias(oldId, newId), }); @@ -363,6 +367,7 @@ export class OrcdServer { projectName: session.cwd.split('/').pop() ?? 'unknown', model: session.model, env, + claudeCodePath: this.claudeCodePath, memoryBaseUrl: this.memoryConfig.baseUrl, memoryApiKey: this.memoryConfig.apiKey, }); @@ -394,6 +399,7 @@ export class OrcdServer { projectPath: session.cwd, model: session.model, env, + claudeCodePath: this.claudeCodePath, }); this.pendingSummaries.set(sid, prepared); diff --git a/src/server/controllers/card-sessions.ts b/src/server/controllers/card-sessions.ts index 9fb769a..c36c978 100644 --- a/src/server/controllers/card-sessions.ts +++ b/src/server/controllers/card-sessions.ts @@ -79,6 +79,25 @@ export function initOrcdRouter( } } } + + if (sdkEvent.type === 'tool_use_summary') { + const summary = sdkEvent as { tool_result?: string }; + if (summary.tool_result) { + const prMatch = summary.tool_result.match( + /https:\/\/(?:github\.com|gitlab\.com)\/[^\s]+\/pull\/\d+/, + ); + if (prMatch) { + const card = await repo().findOneBy({ id: cardId }); + if (card && !card.prUrl) { + card.prUrl = prMatch[0]; + card.updatedAt = new Date().toISOString(); + await repo().save(card); + console.log(`[oc:${cardId}] auto-detected prUrl: ${prMatch[0]}`); + bus.publish('board:changed', { card, oldColumn: card.column, newColumn: card.column }); + } + } + } + } } if (msg.type === 'result') { diff --git a/src/server/init.ts b/src/server/init.ts index c047192..4939a95 100644 --- a/src/server/init.ts +++ b/src/server/init.ts @@ -58,6 +58,46 @@ export async function initBackend(): Promise<{ res.json({ files: refs }); }); + // PR status check + const { execFile } = await import('child_process'); + const { promisify } = await import('util'); + const execFileAsync = promisify(execFile); + + router.post('/api/pr-check', async (req: Request, res: Response) => { + const prUrl = req.body?.prUrl as string | undefined; + if (!prUrl) { + console.warn('[rest:pr-check] missing prUrl in request body'); + res.status(400).json({ error: 'prUrl required' }); + return; + } + + try { + const { stdout } = await execFileAsync('gh', [ + 'pr', 'view', prUrl, + '--json', 'state,comments,reviews,statusCheckRollup', + ], { timeout: 15_000 }); + + const pr = JSON.parse(stdout) as { + state: string; + comments: unknown[]; + reviews: { body: string; state: string }[]; + statusCheckRollup: { name: string; status: string; conclusion: string; detailsUrl: string }[]; + }; + + const merged = pr.state === 'MERGED'; + const reviewComments = pr.reviews?.filter((r) => r.body || r.state === 'CHANGES_REQUESTED') ?? []; + const hasComments = (pr.comments?.length ?? 0) > 0 || reviewComments.length > 0; + const failedChecks = (pr.statusCheckRollup ?? []).filter( + (c) => c.conclusion === 'FAILURE' || c.conclusion === 'TIMED_OUT' || c.conclusion === 'CANCELLED', + ); + + res.json({ state: pr.state, merged, hasComments, failedChecks }); + } catch (err) { + console.error('[rest:pr-check]', err); + res.status(502).json({ error: 'Failed to check PR status' }); + } + }); + // OpenAPI spec + Swagger UI const { readFileSync } = await import('fs'); const { resolve } = await import('path'); diff --git a/src/shared/config.ts b/src/shared/config.ts index 2ceae8b..08b90fc 100644 --- a/src/shared/config.ts +++ b/src/shared/config.ts @@ -33,6 +33,8 @@ export interface OrchestrelConfig { defaultProvider: string; defaultModel: string; defaultCwd?: string; + claudeCodePath?: string; + extraSettings?: string[]; providers: Record; memoryUpsert?: MemoryUpsertConfig; } @@ -105,11 +107,17 @@ export function parseConfig( } : undefined; + const extraSettings = Array.isArray(raw.extraSettings) + ? (raw.extraSettings as unknown[]).map((s) => resolveEnvVars(String(s), env)) + : undefined; + return { socket: String(raw.socket ?? '~/.orc/orcd.sock'), defaultProvider: String(raw.defaultProvider ?? 'anthropic'), defaultModel: String(raw.defaultModel ?? 'claude-sonnet-4-6'), defaultCwd: raw.defaultCwd != null ? String(raw.defaultCwd) : undefined, + claudeCodePath: raw.claudeCodePath != null ? resolveEnvVars(String(raw.claudeCodePath), env) : undefined, + extraSettings, providers, memoryUpsert, }; diff --git a/src/shared/ws-protocol.ts b/src/shared/ws-protocol.ts index 1e659a9..5324981 100644 --- a/src/shared/ws-protocol.ts +++ b/src/shared/ws-protocol.ts @@ -74,6 +74,7 @@ export const cardCreateSchema = z.object({ summarizeThreshold: z.number().min(0).max(1).optional(), worktreeBranch: z.string().nullable().optional(), sourceBranch: z.enum(['main', 'dev']).nullable().optional(), + prUrl: z.string().nullable().optional(), archiveOthers: z.boolean().optional(), });