Merge remote-tracking branch 'origin/main' into nwparker/sta-3077-reattach-pane-cardinality

This commit is contained in:
Neil
2026-08-12 20:24:09 -07:00
277 changed files with 15276 additions and 4431 deletions
+1
View File
@@ -25,6 +25,7 @@ export {
clearWorkingIndicators,
createAgentStatusTracker,
detectAgentStatusFromTitle,
isQuarterCircleSpinnerOnlyAgentTitle,
normalizeTerminalTitle
} from './agent-title-status'
+5 -5
View File
@@ -801,7 +801,7 @@ describe('shared agent-hook-listener', () => {
expect(tool?.payload.interactivePrompt).toBeUndefined()
})
it('keeps OMP ask_user_question behavior on Pi-compatible events', () => {
it('maps OMP ask to blocked without publishing a native prompt', () => {
const tool = normalizeHookPayload(
state,
'omp',
@@ -812,8 +812,8 @@ describe('shared agent-hook-listener', () => {
env: 'production',
version: '1',
payload: {
hook_event_name: 'tool_call',
tool_name: 'ask_user_question',
hook_event_name: 'tool_execution_start',
tool_name: 'ask',
tool_input: {
questions: [
{
@@ -827,9 +827,9 @@ describe('shared agent-hook-listener', () => {
'production'
)
expect(tool?.payload).toMatchObject({
state: 'working',
state: 'blocked',
agentType: 'omp',
toolName: 'ask_user_question'
toolName: 'ask'
})
expect(tool?.payload.interactivePrompt).toBeUndefined()
})
+6 -5
View File
@@ -3847,13 +3847,14 @@ function normalizePiCompatibleEvent(
return null
}
// Why: gate on the event's own tool_name (not a merged snapshot) so a stale cached ask_user_question can't re-enter blocked.
const isPiAskUserQuestion =
agentType === 'pi' &&
isAskUserQuestionTool(readString(hookPayload, 'tool_name')) &&
// Why: gate on the event's own tool_name so a stale cached question can't re-enter blocked.
const toolName = readString(hookPayload, 'tool_name')
const isPiCompatibleAsk =
((agentType === 'pi' && isAskUserQuestionTool(toolName)) ||
(agentType === 'omp' && toolName === 'ask')) &&
(eventName === 'tool_call' || eventName === 'tool_execution_start')
const stateName = isPiAskUserQuestion
const stateName = isPiCompatibleAsk
? 'blocked'
: eventName === 'before_agent_start' ||
eventName === 'agent_start' ||
+8 -2
View File
@@ -44,7 +44,9 @@ export type AgentType = WellKnownAgentType | (string & {})
/** A snapshot of a previous agent state, used to render activity blocks.
* Why: intentionally narrower than AgentStatusEntry — tool/assistant context is
* per-turn, not meaningful on a historical snapshot, and would bloat memory. */
* per-turn, not meaningful on a historical snapshot, and would bloat memory.
* Coalesced-turn output lives in AgentStatusEntry.lastCompletedAssistantMessage,
* one copy per pane, so it can't multiply by AGENT_STATE_HISTORY_MAX. */
export type AgentStateHistoryEntry = {
state: AgentStatusState
prompt: string
@@ -124,6 +126,10 @@ export type AgentStatusEntry = {
interactivePrompt?: string
/** Most recent assistant message preview, when the hook carried one. */
lastAssistantMessage?: string
/** Output of the newest completed (non-boundary) turn, kept across the next `working`.
* Why: batched publications can fold a whole done→working turn into one notification,
* so `lastAssistantMessage` is already cleared by the time a subscriber observes it. */
lastCompletedAssistantMessage?: string
/** True when this `done` was reached via interrupt, not normal completion
* (agent-reported or Orca's guarded fallback). Undefined otherwise. */
interrupted?: boolean
@@ -226,7 +232,7 @@ export type AgentStatusIpcPayload = ParsedAgentStatusPayload & {
tabId?: string
worktreeId?: string
/** Identifies the SSH connection the event arrived on, or null for local.
* Only the remote-ingest path (`ingestRemote`) can stamp it; the HTTP path always sets null. See docs/design/agent-status-over-ssh.md §5. */
* Only the remote-ingest path (`ingestRemote`) can stamp it from mux identity; the HTTP path has no mux and always sets null. */
connectionId: string | null
/** Timestamp (ms) when the hook server received this latest status event. */
receivedAt: number
+13
View File
@@ -16,6 +16,7 @@ import {
containsAgentName,
containsAgentSpinnerGlyph,
containsAny,
containsQuarterCircleSpinner,
containsLegacyAgentName,
isClaudeManagementTitle,
isGeminiTerminalTitle,
@@ -223,3 +224,15 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
return 'idle'
}
/**
* True when a quarter-circle spinner frame is the only agent evidence a title carries.
* Any TUI animates those glyphs, so they prove activity, not identity — callers that
* authorize writes into the pane need independent evidence (STA-4028, regression #13925).
*/
export function isQuarterCircleSpinnerOnlyAgentTitle(title: string | null | undefined): boolean {
if (!title || !containsQuarterCircleSpinner(title)) {
return false
}
return detectAgentStatusFromTitle(title.replace(QUARTER_CIRCLE_SPINNER_RE, '').trim()) === null
}
+1 -1
View File
@@ -9,7 +9,7 @@ Rules:
- First line: imperative mood, <= 72 chars, no trailing period.
- Optional body: blank line, then wrapped at 72 chars explaining WHY.
- Output ONLY the commit message - no preamble, no code fences, no quotes.
- Do not include "Co-authored-by" trailers - Orca appends them after generation when configured.
- Do not include "Co-authored-by" or other git trailers.
Staged diff:
\`\`\`diff
-1
View File
@@ -182,7 +182,6 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
autoRenameBranchFromWorkDefaultedOn: true,
branchPrefix: 'git-username',
branchPrefixCustom: '',
enableGitHubAttribution: false,
theme: 'system',
leftSidebarAppearanceMode: 'default',
leftSidebarTintColor: DEFAULT_LEFT_SIDEBAR_TINT_COLOR,
+19
View File
@@ -0,0 +1,19 @@
import { z } from 'zod'
export const DiffCommentSchema = z.object({
id: z.string(),
worktreeId: z.string(),
filePath: z.string(),
source: z.enum(['diff', 'markdown']).optional(),
selectedText: z.string().optional(),
startLine: z.number().int().positive().optional(),
lineNumber: z.number().int().positive(),
body: z.string(),
createdAt: z.number().finite(),
updatedAt: z.number().finite().optional(),
sentAt: z.number().finite().optional(),
scope: z.enum(['unstaged', 'staged', 'branch']).optional(),
oldPath: z.string().optional(),
diffIdentity: z.string().optional(),
side: z.literal('modified')
})
+37 -1
View File
@@ -9,8 +9,10 @@ import type {
} from './types'
import {
getHiddenExternalWorktrees,
getHiddenImportableExternalWorktrees,
getNewExternalWorktreeInboxWorktrees,
getVisibleExternalWorktrees,
getVisibleNonOrcaWorktrees,
mergeExternalWorktreeInboxPaths,
shouldOfferNewExternalWorktreeInbox
} from './external-worktree-inbox'
@@ -74,7 +76,6 @@ function makeSettings(): GlobalSettings {
localBaseRefSuggestionDismissed: false,
branchPrefix: 'none',
branchPrefixCustom: '',
enableGitHubAttribution: false,
theme: 'system',
appFontFamily: 'Geist',
editorAutoSave: false,
@@ -172,6 +173,41 @@ describe('external worktree inbox', () => {
})
expect(getVisibleExternalWorktrees(detectedResult([visible, scratch]))).toEqual([visible])
expect(getVisibleNonOrcaWorktrees(detectedResult([visible, scratch]))).toEqual([
visible,
scratch
])
})
it('lists hidden agent scratch among importable worktrees so recovery stays reachable', () => {
// Why: the visibility toggle can never reveal scratch (#9388), so if no list
// offers it for per-path import, a hidden scratch worktree is lost for good (#10324).
const hiddenScratch = detectedWorktree({
id: 'hidden-scratch',
path: '/repo/.claude/worktrees/scratch-1',
ownership: 'agent-scratch'
})
const hiddenExternal = detectedWorktree({ id: 'hidden-external' })
const detected = detectedResult([
hiddenScratch,
hiddenExternal,
detectedWorktree({ id: 'visible-scratch', ownership: 'agent-scratch', visible: true }),
detectedWorktree({ id: 'orca-managed', ownership: 'orca-managed' }),
detectedWorktree({ id: 'checked-out', selectedCheckout: true })
])
expect(getHiddenImportableExternalWorktrees(detected)).toEqual([hiddenScratch, hiddenExternal])
// Why: scratch stays out of discovery counts even though the dialog controls it.
expect(getHiddenExternalWorktrees(detected)).toEqual([hiddenExternal])
})
it('offers no importable worktrees from a non-authoritative snapshot', () => {
const scratch = detectedWorktree({ id: 'scratch', ownership: 'agent-scratch' })
expect(
getHiddenImportableExternalWorktrees({ ...detectedResult([scratch]), authoritative: false })
).toEqual([])
expect(getHiddenImportableExternalWorktrees(undefined)).toEqual([])
})
it('offers metadata-free nested Orca workspace worktrees through the inbox', () => {
+29 -2
View File
@@ -55,8 +55,7 @@ export function getVisibleExternalWorktrees(
}
function isUserFacingExternalWorktree(worktree: DetectedWorktree): boolean {
// Why: an explicit scratch import may be visible, but agent plumbing must
// stay outside repo-wide discovery and visibility controls (#9388).
// Why: agent plumbing stays outside the discovery inbox even when its separate visibility policy shows it.
return (
!worktree.selectedCheckout &&
worktree.ownership !== 'orca-managed' &&
@@ -64,6 +63,34 @@ function isUserFacingExternalWorktree(worktree: DetectedWorktree): boolean {
)
}
// Why: per-path recovery remains available while either repo visibility policy is off.
function isImportableExternalWorktree(worktree: DetectedWorktree): boolean {
return !worktree.selectedCheckout && worktree.ownership !== 'orca-managed'
}
export function getHiddenImportableExternalWorktrees(
detected: DetectedWorktreeListResult | undefined
): DetectedWorktree[] {
if (detected?.authoritative !== true) {
return []
}
return detected.worktrees.filter(
(worktree) => !worktree.visible && isImportableExternalWorktree(worktree)
)
}
export function getVisibleNonOrcaWorktrees(
detected: DetectedWorktreeListResult | undefined
): DetectedWorktree[] {
if (detected?.authoritative !== true) {
return []
}
return detected.worktrees.filter(
(worktree) =>
worktree.visible && !worktree.selectedCheckout && worktree.ownership !== 'orca-managed'
)
}
export function isExternalWorktreeDiscoverySuppressed(
repo: Pick<Repo, 'externalWorktreeDiscoverySuppressedAt'>
): boolean {
@@ -67,3 +67,10 @@ export function effectiveExternalWorktreeVisibility(
}
return isLegacyRepoForVisibility ? 'show' : 'hide'
}
export function effectiveAgentWorktreeVisibility(
repo: Pick<Repo, 'agentWorktreeVisibility'>
): ExternalWorktreeVisibility {
// Why: scratch worktrees have always defaulted hidden, including for legacy repos.
return repo.agentWorktreeVisibility === 'show' ? 'show' : 'hide'
}
+1
View File
@@ -40,6 +40,7 @@ export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Wor
pendingFirstAgentMessageRename: folderWorkspace.pendingFirstAgentMessageRename,
firstAgentMessageRenameError: folderWorkspace.firstAgentMessageRenameError,
workspaceStatus: folderWorkspace.workspaceStatus,
diffComments: folderWorkspace.diffComments,
path: folderWorkspace.folderPath,
head: '',
branch: '',
+2 -1
View File
@@ -103,7 +103,8 @@ export function normalizeFolderWorkspaces(
createdAt:
typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : now,
updatedAt:
typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now
typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now,
...(Array.isArray(raw.diffComments) ? { diffComments: raw.diffComments } : {})
})
}
return workspaces.sort(
-6
View File
@@ -1,6 +0,0 @@
// Why: single source of truth for the commit trailer Orca appends when the
// "Orca Attribution" toggle (`enableGitHubAttribution`) is on. Used by both
// the terminal git/gh shim and the AI commit-message generator so the two
// code paths agree on the exact string.
export const ORCA_GIT_COMMIT_TRAILER = 'Co-authored-by: Orca <help@stably.ai>'
+2 -2
View File
@@ -13,8 +13,8 @@ type ProjectAccumulator = {
}
export type ProjectHostSetupProjection = {
projects: Project[]
setups: ProjectHostSetup[]
projects: readonly Project[]
setups: readonly ProjectHostSetup[]
}
function getProjectProviderIdentity(
+2 -1
View File
@@ -54,6 +54,7 @@ export const AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY = 'aiVault.session-title
// offscreen backend). Advertised only when that backend is actually available, so
// clients never fall back to a local desktop browser tab for a remote-owned page.
export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const
export const BROWSER_SCREENCAST_RUNTIME_CAPABILITY = 'browser.screencast.v1' as const
export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate-trust.v1' as const
// Why: hosts without this strip terminal.send's inputKind (zod object drops
// unknown keys), so a mobile xterm query reply would land as ordinary
@@ -97,7 +98,7 @@ export const RUNTIME_CAPABILITIES = [
ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_RUNTIME_CAPABILITY,
ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY,
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
'browser.screencast.v1',
BROWSER_SCREENCAST_RUNTIME_CAPABILITY,
'terminal.binary-stream.v1',
'terminal.multiplex.v1',
'workspace-ports.v1',
@@ -3,7 +3,11 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'
const execFileMock = vi.hoisted(() => vi.fn())
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { createPtySlaveEchoProbe, readPtySlavePath } from './pty-slave-line-discipline-echo'
import {
createPtySlaveEchoProbe,
createPtySlaveLineEditorProbe,
readPtySlavePath
} from './pty-slave-line-discipline-echo'
/** Replies to the next stty call with the given output, or an error when `output` is null. */
function answerStty(output: string | null): void {
@@ -14,6 +18,7 @@ function answerStty(output: string | null): void {
const COOKED = 'speed 38400 baud;\nlflags: icanon isig iexten echo echoe echok echoctl\n'
const RAW = 'speed 38400 baud;\nlflags: -icanon -isig -iexten -echo -echoe -echok -echoctl\n'
const LINE_EDITOR = `${RAW}cchars: lnext = <undef>; min = 1; time = 0;\n`
beforeEach(() => {
execFileMock.mockReset()
@@ -132,3 +137,21 @@ describe('createPtySlaveEchoProbe', () => {
expect(execFileMock.mock.calls[1]?.[1]).toEqual(['-a', '-F', '/dev/pts/3'])
})
})
describe('createPtySlaveLineEditorProbe', () => {
it('requires raw quiet mode with the line editor disabling literal-next', async () => {
const probe = createPtySlaveLineEditorProbe('/dev/ttys048', 'darwin')
answerStty(LINE_EDITOR)
await expect(probe?.()).resolves.toBe('line-editor')
answerStty(`${RAW}cchars: lnext = ^V; min = 1; time = 0;\n`)
await expect(probe?.()).resolves.toBe('other')
answerStty(COOKED)
await expect(probe?.()).resolves.toBe('other')
})
it('fails closed when the terminal state is incomplete', async () => {
const probe = createPtySlaveLineEditorProbe('/dev/ttys048', 'darwin')
answerStty('lflags: -echo\ncchars: lnext = <undef>;\n')
await expect(probe?.()).resolves.toBe('unknown')
})
})
+36 -4
View File
@@ -10,10 +10,16 @@ export type PtySlaveLineDisciplineEcho = 'echoing' | 'quiet' | 'unknown'
export type PtySlaveEchoProbe = () => Promise<PtySlaveLineDisciplineEcho>
export type PtySlaveLineEditorState = 'line-editor' | 'other' | 'unknown'
export type PtySlaveLineEditorProbe = () => Promise<PtySlaveLineEditorState>
const STTY_TIMEOUT_MS = 2_000
// `stty -a` prints the lflags as a space-separated list where a disabled flag is
// prefixed with `-`, so `echo` and `-echo` are the two tokens that matter.
const ECHO_FLAG = /(?:^|\s)(-?)echo(?:\s|$)/
const ICANON_FLAG = /(?:^|\s)(-?)icanon(?:\s|$)/
const LNEXT_UNDEFINED = /(?:^|[;\s])lnext\s*=\s*<undef>(?:;|\s|$)/
function sttyArgs(ptsName: string, platform: NodeJS.Platform): readonly string[] {
// BSD/macOS take `-f`; Linux (GNU coreutils) takes `-F`.
@@ -30,7 +36,18 @@ function parseEchoFlag(sttyOutput: string): PtySlaveLineDisciplineEcho {
return match[1] === '-' ? 'quiet' : 'echoing'
}
type SttyProbeResult = { state: PtySlaveLineDisciplineEcho; permanent: boolean }
function parseLineEditorState(sttyOutput: string): PtySlaveLineEditorState {
const echo = ECHO_FLAG.exec(sttyOutput)
const icanon = ICANON_FLAG.exec(sttyOutput)
if (!echo || !icanon) {
return 'unknown'
}
return echo[1] === '-' && icanon[1] === '-' && LNEXT_UNDEFINED.test(sttyOutput)
? 'line-editor'
: 'other'
}
type SttyProbeResult = { stdout: string | null; permanent: boolean }
/**
* A spawn that never ran (`stty` absent) or a device that answered non-zero (reaped,
@@ -54,8 +71,8 @@ function runStty(ptsName: string, platform: NodeJS.Platform): Promise<SttyProbeR
(error, stdout) => {
resolve(
error
? { state: 'unknown', permanent: isPermanentSttyFailure(error) }
: { state: parseEchoFlag(stdout), permanent: false }
? { stdout: null, permanent: isPermanentSttyFailure(error) }
: { stdout, permanent: false }
)
}
)
@@ -83,6 +100,21 @@ export function createPtySlaveEchoProbe(
ptsName: string | undefined,
platform: NodeJS.Platform = process.platform
): PtySlaveEchoProbe | undefined {
return createSttyProbe(ptsName, platform, parseEchoFlag)
}
export function createPtySlaveLineEditorProbe(
ptsName: string | undefined,
platform: NodeJS.Platform = process.platform
): PtySlaveLineEditorProbe | undefined {
return createSttyProbe(ptsName, platform, parseLineEditorState)
}
function createSttyProbe<T extends string>(
ptsName: string | undefined,
platform: NodeJS.Platform,
parse: (output: string) => T | 'unknown'
): (() => Promise<T | 'unknown'>) | undefined {
if (platform === 'win32' || !ptsName) {
return undefined
}
@@ -101,6 +133,6 @@ export function createPtySlaveEchoProbe(
})
const result = await inFlight
unavailable = result.permanent
return result.state
return result.stdout === null ? 'unknown' : parse(result.stdout)
}
}
@@ -0,0 +1,52 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
const openedPaths = vi.hoisted(() => [] as { path: string; flags: string | number }[])
vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof NodeFs>('node:fs')
return {
...actual,
openSync: (path: NodeFs.PathLike, flags: string | number, mode?: NodeFs.Mode) => {
openedPaths.push({ path: String(path), flags })
return actual.openSync(path, flags, mode)
}
}
})
import { bestEffortFsyncDirectorySync, fsyncFileSync } from './secure-file'
const createdPaths: string[] = []
afterEach(() => {
openedPaths.length = 0
for (const path of createdPaths.splice(0)) {
rmSync(path, { recursive: true, force: true })
}
})
describe('secure file fsync flags', () => {
it('opens files read/write before fsync', () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-file-fsync-'))
createdPaths.push(directory)
const path = join(directory, 'record.json')
writeFileSync(path, '{}')
fsyncFileSync(path)
expect(openedPaths).toEqual([{ path, flags: 'r+' }])
})
const posixIt = process.platform === 'win32' ? it.skip : it
posixIt('opens directories read-only before fsync', () => {
const directory = mkdtempSync(join(tmpdir(), 'orca-directory-fsync-'))
createdPaths.push(directory)
bestEffortFsyncDirectorySync(directory)
expect(openedPaths).toEqual([{ path: directory, flags: 'r' }])
})
})
+8 -3
View File
@@ -133,8 +133,8 @@ export function writeSecureFile(
}
}
export function fsyncFileSync(path: string): void {
const descriptor = openSync(path, 'r')
function fsyncPathSync(path: string, flags: 'r' | 'r+'): void {
const descriptor = openSync(path, flags)
try {
fsyncSync(descriptor)
} finally {
@@ -142,12 +142,17 @@ export function fsyncFileSync(path: string): void {
}
}
export function fsyncFileSync(path: string): void {
// FlushFileBuffers requires a write-capable handle on Windows.
fsyncPathSync(path, 'r+')
}
export function bestEffortFsyncDirectorySync(directory: string): void {
if (process.platform === 'win32') {
return
}
try {
fsyncFileSync(directory)
fsyncPathSync(directory, 'r')
} catch (error) {
if (
error instanceof Error &&
@@ -0,0 +1,73 @@
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { parseDarwinExecutablePath, resolveShellExecutablePath } from './shell-process-readiness'
describe('shell process readiness', () => {
it('extracts the primary text image from macOS lsof output', () => {
expect(parseDarwinExecutablePath('p42\nftxt\nn/bin/zsh\nftxt\nn/usr/lib/zsh/zle.so\n')).toBe(
'/bin/zsh'
)
})
it.skipIf(process.platform === 'win32')(
'resolves bare shell commands through the spawn PATH',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-shell-path-'))
const link = join(root, 'shell-name')
await symlink(process.execPath, link)
try {
await expect(resolveShellExecutablePath('shell-name', dirname(root), root)).resolves.toBe(
await resolveShellExecutablePath(process.execPath, dirname(root), root)
)
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'uses the POSIX exec default when PATH is unset',
async () => {
await expect(resolveShellExecutablePath('sh', process.cwd(), undefined)).resolves.toBe(
await resolveShellExecutablePath('/bin/sh', process.cwd(), '')
)
}
)
it.skipIf(process.platform === 'win32')(
'resolves relative shell paths against the PTY cwd',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-relative-shell-'))
const bin = join(root, 'bin')
await symlink(dirname(process.execPath), bin)
try {
await expect(
resolveShellExecutablePath(`./bin/${basename(process.execPath)}`, root, '')
).resolves.toBe(await resolveShellExecutablePath(process.execPath, root, ''))
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
it.skipIf(process.platform === 'win32')(
'skips searchable directories that shadow a later PATH executable',
async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-shadowed-shell-'))
const first = join(root, 'first')
const second = join(root, 'second')
await mkdir(join(first, 'shell-name'), { recursive: true })
await mkdir(second)
await symlink(process.execPath, join(second, 'shell-name'))
try {
await expect(
resolveShellExecutablePath('shell-name', root, `${first}:${second}`)
).resolves.toBe(await resolveShellExecutablePath(process.execPath, root, ''))
} finally {
await rm(root, { recursive: true, force: true })
}
}
)
})
+80
View File
@@ -0,0 +1,80 @@
import { execFile as execFileCallback } from 'node:child_process'
import { constants } from 'node:fs'
import { access, readlink, realpath, stat } from 'node:fs/promises'
import { delimiter, isAbsolute, resolve } from 'node:path'
import { promisify } from 'node:util'
const execFile = promisify(execFileCallback)
const PROCESS_READINESS_TIMEOUT_MS = 3000
const DEFAULT_POSIX_EXEC_PATH = '/usr/bin:/bin'
export type ShellProcessReadiness = {
executablePath: string
foreground: boolean
}
export function parseDarwinExecutablePath(stdout: string): string | null {
const lines = stdout.split(/\r?\n/)
const textIndex = lines.indexOf('ftxt')
const pathLine = textIndex === -1 ? undefined : lines[textIndex + 1]
return pathLine?.startsWith('n') ? pathLine.slice(1) : null
}
async function readExecutablePath(pid: number): Promise<string | null> {
if (process.platform === 'linux') {
return readlink(`/proc/${pid}/exe`)
}
if (process.platform !== 'darwin') {
return null
}
const { stdout } = await execFile(
'/usr/sbin/lsof',
['-a', '-p', String(pid), '-d', 'txt', '-Fn'],
{
encoding: 'utf8',
timeout: PROCESS_READINESS_TIMEOUT_MS
}
)
return parseDarwinExecutablePath(stdout)
}
export async function readShellProcessReadiness(
pid: number
): Promise<ShellProcessReadiness | null> {
const [executablePath, { stdout }] = await Promise.all([
readExecutablePath(pid),
execFile('ps', ['-p', String(pid), '-o', 'stat='], {
encoding: 'utf8',
timeout: PROCESS_READINESS_TIMEOUT_MS
})
])
const status = stdout.trim()
return status && executablePath
? { executablePath: await realpath(executablePath), foreground: status.includes('+') }
: null
}
export async function resolveShellExecutablePath(
shellPath: string,
cwd: string,
pathEnv: string | undefined
): Promise<string | null> {
const candidates = shellPath.includes('/')
? [isAbsolute(shellPath) ? shellPath : resolve(cwd, shellPath)]
: (
pathEnv ??
(process.platform === 'win32' ? (process.env.PATH ?? '') : DEFAULT_POSIX_EXEC_PATH)
)
.split(delimiter)
.map((entry) => resolve(isAbsolute(entry) ? entry : resolve(cwd, entry), shellPath))
for (const candidate of candidates) {
try {
await access(candidate, constants.X_OK)
const canonicalPath = await realpath(candidate)
if ((await stat(canonicalPath)).isFile()) {
return canonicalPath
}
} catch {}
}
return null
}
+3 -1
View File
@@ -289,6 +289,8 @@ export type Repo = {
externalWorktreeInboxBaselinePaths?: string[]
/** External worktree paths explicitly imported while global visibility stays hide. */
importedExternalWorktreePaths?: string[]
/** Opt-in repo policy for coding-agent scratch worktrees; absent means hide. */
agentWorktreeVisibility?: ExternalWorktreeVisibility
/** User permanently opted out of the new-external-worktree inbox for this repo. */
externalWorktreeDiscoverySuppressedAt?: number
/** Paths (relative to the primary checkout) that should be APFS clone-copied
@@ -357,6 +359,7 @@ export type FolderWorkspace = {
lastActivityAt: number
createdAt: number
updatedAt: number
diffComments?: DiffComment[]
}
export type WorkspaceLinkedItem = {
@@ -2796,7 +2799,6 @@ export type GlobalSettings = {
autoRenameBranchFromWorkDefaultedOn?: boolean
branchPrefix: BranchPrefixStrategy
branchPrefixCustom: string
enableGitHubAttribution: boolean
theme: 'system' | 'dark' | 'light'
/** Controls the left sidebar surface without changing terminal brightness. */
leftSidebarAppearanceMode: LeftSidebarAppearanceMode
+20 -2
View File
@@ -80,7 +80,6 @@ function makeSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings {
localBaseRefSuggestionDismissed: false,
branchPrefix: 'none',
branchPrefixCustom: '',
enableGitHubAttribution: false,
theme: 'system',
appFontFamily: 'Geist',
editorAutoSave: false,
@@ -477,7 +476,7 @@ describe('agent scratch worktrees', () => {
).toBe('orca-managed')
})
it('hides agent scratch even when the repo shows non-Orca worktrees', () => {
it('keeps agent scratch hidden by default for new and legacy repos', () => {
for (const repo of [
makeRepo({ externalWorktreeVisibility: 'show' }),
makeRepo({ addedAt: EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT - 1 })
@@ -494,6 +493,25 @@ describe('agent scratch worktrees', () => {
}
})
it('shows agent scratch when its repo policy is enabled', () => {
const repo = makeRepo({
externalWorktreeVisibility: 'hide',
agentWorktreeVisibility: 'show'
})
const settings = makeSettings()
for (const path of [scratchPath, '/repos/app/.gsd-workspaces/phase-1']) {
const detected = toDetectedWorktree({
repo,
settings,
worktree: makeWorktree({ path, isMainWorktree: false }),
knownOrcaLayouts: buildKnownOrcaWorkspaceLayouts(settings, repo)
})
expect(detected).toMatchObject({ ownership: 'agent-scratch', visible: true })
}
})
it('still shows agent scratch for the selected checkout or an explicit import', () => {
const repo = makeRepo({
externalWorktreeVisibility: 'hide',
+3 -3
View File
@@ -13,6 +13,7 @@ import {
} from './agent-scratch-worktrees'
import { isExplicitlyImportedExternalWorktreePath } from './external-worktree-inbox'
import {
effectiveAgentWorktreeVisibility,
effectiveExternalWorktreeVisibility,
isLegacyRepoForExternalWorktreeVisibility
} from './external-worktree-visibility'
@@ -27,6 +28,7 @@ import type {
} from './types'
export {
effectiveAgentWorktreeVisibility,
effectiveExternalWorktreeVisibility,
EXTERNAL_WORKTREE_VISIBILITY_ROLLOUT_AT,
isLegacyRepoForExternalWorktreeVisibility
@@ -221,10 +223,8 @@ export function shouldShowWorktree(args: {
) {
return true
}
// Why: agent scratch stays hidden even when the repo shows non-Orca
// worktrees; only an explicit import or selected checkout reveals it.
if (args.ownership === 'agent-scratch') {
return false
return effectiveAgentWorktreeVisibility(args.repo) === 'show'
}
if (args.ownership === 'unknown-legacy' && args.isLegacyRepoForVisibility) {
return true