mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
Merge remote-tracking branch 'origin/main' into nwparker/sta-3077-reattach-pane-cardinality
This commit is contained in:
@@ -25,6 +25,7 @@ export {
|
||||
clearWorkingIndicators,
|
||||
createAgentStatusTracker,
|
||||
detectAgentStatusFromTitle,
|
||||
isQuarterCircleSpinnerOnlyAgentTitle,
|
||||
normalizeTerminalTitle
|
||||
} from './agent-title-status'
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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' ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>'
|
||||
@@ -13,8 +13,8 @@ type ProjectAccumulator = {
|
||||
}
|
||||
|
||||
export type ProjectHostSetupProjection = {
|
||||
projects: Project[]
|
||||
setups: ProjectHostSetup[]
|
||||
projects: readonly Project[]
|
||||
setups: readonly ProjectHostSetup[]
|
||||
}
|
||||
|
||||
function getProjectProviderIdentity(
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }])
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user