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(),
});