feat(cli): add orca ports to list a workspace's detected ports (#33016)

* feat(cli): add orca ports to list a workspace's detected ports

Scripts and agents had no way to read the ports Orca already detects for
a workspace, so finding a dev server on a random port meant redoing the
lsof scan by hand. `orca ports` reuses the host-scoped
workspacePorts.scanHost RPC, keeps the rows owned by the current (or
--worktree) workspace, and prints port, pid, process, connect host,
protocol, how the owner was matched, and the advertised URL. --all shows
every listener on that host. An older runtime without scanHost gets an
"update Orca" error instead of a raw method_not_found.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): resolve folder workspaces in orca ports and match rows by path

Run inside a native folder, `orca ports` failed with selector_not_found
(or picked an enclosing git worktree) because it only searched the
worktree list. It now resolves the cwd like `agent launch`, native
folders first, and shows that host's listeners. Rows are matched on the
owner's path instead of its id, so every instance of a repo-backed
folder workspace keeps its ports even though attribution names only the
first instance as owner. `--all` adds the worktree id when two owners
share a display name, and the skill guide notes that only workspace
rows carry `owner` and `advertisedUrl`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): refuse orca ports for folder workspaces without --all; document the command

Co-authored-by: Manuel Meurer <manuel@meurer.io>

* fix(cli): recognise native folder path selectors in orca ports; note SSH scan limits

Co-authored-by: Manuel Meurer <manuel@meurer.io>

* fix(cli): refuse a folder path shared by several folder workspaces in orca ports

Co-authored-by: Manuel Meurer <manuel@meurer.io>

* fix(cli): refuse a path a worktree and a folder workspace share in orca ports

Co-authored-by: Manuel Meurer <manuel@meurer.io>

---------

Co-authored-by: Manuel Meurer <manuel@meurer.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-23 20:05:47 -07:00
committed by GitHub
co-authored by Claude Opus 5.5 Manuel Meurer
parent e12e8bafef
commit cedce2aa9f
14 changed files with 645 additions and 3 deletions
+10
View File
@@ -133,6 +133,16 @@ Omitting a flag leaves its link unchanged. On `set`, literal `null` clears only
Folder-based repositories can store numeric links through these commands, but a number does not supply a provider host or project. Without existing source context or a stored remote, pasted GitLab URLs are refused and provider details or clickable links may be unavailable. These flags use fields already supported by the runtime; an older runtime that predates a field may ignore it, so check `worktree show --json` after writing to an older host.
## Ports
```bash
orca ports --json
orca ports --worktree branch:feature/login --json
orca ports --all
```
`ports` lists the listening ports Orca tied to a workspace, from the host the workspace runs on. It defaults to the workspace that contains the current directory. Each row has `port`, `pid`, `processName`, `connectHost`, `protocol`, how Orca matched it (`owner.confidence`: `cwd` or `command`), and `advertisedUrl` when the dev server printed one in an Orca terminal. `--all` lists every listener on that host. Native folder workspaces need `--all`, because Orca does not tie ports to them. If `unavailableReason` is set, the scan could not run on that host; SSH workspaces opened directly from the desktop report this for now, while a workspace on a managed Orca server is scanned on that server.
## References
A workspace can link multiple reviews and issues, including sources outside its repository.
+2
View File
@@ -100,6 +100,8 @@ ORCA worktree set --worktree active --pr null --gitlab-mr null --json
ORCA worktree rm --worktree id:<repoId>::<worktreePath> --force --json
```
Ports: `ORCA ports --json` lists the listening ports Orca tied to the current workspace (`--worktree <selector>` for another one, `--all` for every listener on that workspace's host). Each row has `port`, `pid`, `processName`, `connectHost`, `protocol`, and `kind` (`workspace`, `container`, or `external`). Only `workspace` rows have `owner` (with `owner.confidence`: `cwd` or `command`) and, when the dev server printed one in an Orca terminal, `advertisedUrl`. Use this to find a dev server's port instead of running `lsof` yourself. If `unavailableReason` is set, the scan could not run on that host. Native folder workspaces need `--all`, because Orca does not tie ports to them. SSH workspaces opened directly from this app report `unavailableReason` for now; a workspace on a managed Orca server is scanned on that server.
Use `repo set --external-worktree-visibility show` to show a repo's non-Orca worktrees.
`hide` hides them; `inherit` clears the repo override and follows the global default.
Per-worktree visibility rules still apply.
+1
View File
@@ -159,6 +159,7 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
'linear',
'skills',
'search',
'ports',
'agent-context'
].includes(commandPath[0])
) {
File diff suppressed because one or more lines are too long
+3
View File
@@ -71,6 +71,9 @@ const COMMAND_SCOPED_FLAG_HELP: Record<string, Record<string, string>> = {
sort: '--sort <order> relevance (default) or newest',
debug: '--debug Include the planner route the host used',
'index-status': '--index-status Report the index instead of searching'
},
ports: {
all: '--all List every listener on the workspace host, not just this workspace'
}
}
+5
View File
@@ -286,5 +286,10 @@ export const HANDLER_GROUPS: readonly HandlerGroup[] = [
name: 'search',
keys: ['search'],
load: async () => (await import('./handlers/search.js')).SEARCH_HANDLERS
},
{
name: 'ports',
keys: ['ports'],
load: async () => (await import('./handlers/ports.js')).PORTS_HANDLERS
}
]
+164
View File
@@ -0,0 +1,164 @@
import { normalizeRuntimePathForComparison } from '../../shared/cross-platform-path'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { RuntimeWorktreeRecord } from '../../shared/runtime-types'
import type { WorkspacePortHostScanResult } from '../../shared/workspace-ports'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import { resolveCurrentAgentWorkspaceSelector } from '../agent-workspace-selector'
import type { CommandHandler, HandlerContext } from '../dispatch'
import { getOptionalStringFlag } from '../flags'
import { printResult } from '../format'
import { formatPortsScan } from '../ports-format'
import {
RuntimeClientError,
RuntimeRpcFailureError,
type RuntimeRpcSuccess
} from '../runtime-client'
import { normalizeWorktreeSelectorForCaller } from '../selectors'
type PortsTarget = { selector: string; worktreePath: string | null }
function isNativeFolderSelector(selector: string): boolean {
return parseWorkspaceKey(selector.replace(/^id:/, ''))?.type === 'folder'
}
async function listFoldersAtPath(
client: Pick<HandlerContext['client'], 'call'>,
folderPath: string
): Promise<FolderWorkspace[]> {
try {
const { result } = await client.call<{ folderWorkspaces: FolderWorkspace[] }>(
'folderWorkspace.list'
)
const target = normalizeRuntimePathForComparison(folderPath)
return result.folderWorkspaces.filter(
(row) => normalizeRuntimePathForComparison(row.folderPath) === target
)
} catch (error) {
if (error instanceof RuntimeRpcFailureError && error.code === 'method_not_found') {
return []
}
throw error
}
}
async function worktreeIdAtSelector(
client: Pick<HandlerContext['client'], 'call'>,
selector: string
): Promise<string | null> {
try {
const shown = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
worktree: selector
})
return shown.result.worktree.id
} catch (error) {
if (error instanceof RuntimeRpcFailureError && error.code === 'selector_not_found') {
return null
}
throw error
}
}
// Why: worktree path selectors never match a native folder, so a folder's path is named by its id.
async function resolveNativeFolderPathSelector(
selector: string,
client: Pick<HandlerContext['client'], 'call'>
): Promise<string> {
if (!selector.startsWith('path:')) {
return selector
}
const folders = await listFoldersAtPath(client, selector.slice('path:'.length))
if (folders.length === 0) {
return selector
}
const worktreeId = await worktreeIdAtSelector(client, selector)
const candidates = [
...(worktreeId ? [`id:${worktreeId}`] : []),
...folders.map((row) => `folder:${row.id}`)
]
// Why refuse: workspaces on different hosts can share a path, and guessing would scan the wrong host.
if (candidates.length > 1) {
throw new RuntimeClientError(
'selector_ambiguous',
`More than one workspace has this path. Pass one of: ${candidates.join(', ')}.`
)
}
return `id:${candidates[0]}`
}
async function resolvePortsTarget(
ctx: Pick<HandlerContext, 'flags' | 'cwd' | 'client'>,
options: { needsWorktreePath: boolean }
): Promise<PortsTarget> {
const { flags, cwd, client } = ctx
const explicit = getOptionalStringFlag(flags, 'worktree')
const selector =
!explicit || explicit === 'active' || explicit === 'current'
? await resolveCurrentAgentWorkspaceSelector(cwd, client)
: await resolveNativeFolderPathSelector(
await normalizeWorktreeSelectorForCaller(explicit, cwd, client),
client
)
if (!options.needsWorktreePath) {
return { selector, worktreePath: null }
}
// Why refuse: Orca ties listeners only to repo-backed workspaces, and host-wide rows would hand
// scripts another workspace's dev server.
if (isNativeFolderSelector(selector)) {
throw new RuntimeClientError(
'invalid_argument',
'Orca does not tie ports to native folder workspaces yet. Run `orca ports --all` to list every listener on the host.'
)
}
const shown = await client.call<{ worktree: RuntimeWorktreeRecord }>('worktree.show', {
worktree: selector
})
return { selector, worktreePath: shown.result.worktree.git.path }
}
// Why: an older runtime has no host-scoped scan; its raw method_not_found reads as a CLI bug.
async function scanPortsOnWorkspaceHost(
client: Pick<HandlerContext['client'], 'call'>,
selector: string
): Promise<RuntimeRpcSuccess<WorkspacePortHostScanResult>> {
try {
return await client.call<WorkspacePortHostScanResult>('workspacePorts.scanHost', {
worktree: selector
})
} catch (error) {
if (error instanceof RuntimeClientError && error.code === 'method_not_found') {
throw new RuntimeClientError(
'incompatible_runtime',
'This Orca runtime cannot list workspace ports yet. Update Orca and try again.'
)
}
throw error
}
}
// Why the path, not the id: folder workspace instances share one path, and attribution names only
// the first of them as owner.
function selectWorkspacePorts(
scan: WorkspacePortHostScanResult,
worktreePath: string | null
): WorkspacePortHostScanResult {
const ports = scan.ports
.filter(
(port) =>
worktreePath === null || (port.kind === 'workspace' && port.owner.path === worktreePath)
)
.sort((a, b) => a.port - b.port || (a.processName ?? '').localeCompare(b.processName ?? ''))
return { ...scan, ports }
}
export const PORTS_HANDLERS: Record<string, CommandHandler> = {
ports: async ({ flags, client, cwd, json }) => {
const all = flags.get('all') === true
const target = await resolvePortsTarget({ flags, cwd, client }, { needsWorktreePath: !all })
const response = await scanPortsOnWorkspaceHost(client, target.selector)
const worktreePath = all ? null : target.worktreePath
const result = selectWorkspacePorts(response.result, worktreePath)
printResult({ ...response, result }, json, (scan) =>
formatPortsScan(scan, { showOwner: worktreePath === null })
)
}
}
+369
View File
@@ -0,0 +1,369 @@
import { describe, expect, it, vi } from 'vitest'
import type { WorkspacePort } from '../shared/workspace-ports'
const {
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
} = vi.hoisted(() => ({
callMock: vi.fn(),
runtimeClientConstructorMock: vi.fn(),
serveOrcaAppMock: vi.fn(),
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
addEnvironmentFromPairingCodeMock: vi.fn(),
listEnvironmentsMock: vi.fn(),
spawnMock: vi.fn()
}))
vi.mock('./runtime-client', async () => {
const { createRuntimeClientModuleMock } = await import('./index-test-harness.js')
return createRuntimeClientModuleMock({
callMock,
runtimeClientConstructorMock,
serveOrcaAppMock,
getDefaultUserDataPathMock
})
})
vi.mock('./runtime/environments', () => ({
addEnvironmentFromPairingCode: addEnvironmentFromPairingCodeMock,
listEnvironments: listEnvironmentsMock,
removeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('child_process', async () => {
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
return createChildProcessModuleMock(spawnMock)
})
import { main } from './index'
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
import { useWorktreeAwarenessEnvironment } from './index-test-harness'
const FEATURE_ID = 'repo::/tmp/repo/feature'
function workspacePort(
port: number,
worktreeId: string,
extra: { advertisedUrl?: string; confidence?: 'cwd' | 'command' } = {}
): WorkspacePort {
return {
id: `127.0.0.1:${port}:${port + 1}`,
bindHost: '127.0.0.1',
connectHost: 'localhost',
port,
pid: port + 1,
processName: 'node',
protocol: 'http',
kind: 'workspace',
owner: {
worktreeId,
repoId: 'repo',
displayName: worktreeId.endsWith('feature') ? 'feature' : 'other',
path: worktreeId.split('::')[1],
confidence: extra.confidence ?? 'cwd'
},
...(extra.advertisedUrl ? { advertisedUrl: extra.advertisedUrl } : {})
}
}
const externalPort: WorkspacePort = {
id: '0.0.0.0:5432:77',
bindHost: '0.0.0.0',
connectHost: 'localhost',
port: 5432,
pid: 77,
processName: 'postgres',
protocol: 'unknown',
kind: 'external'
}
const hostScan = okFixture('req_scan', {
executionHostId: 'local',
platform: 'darwin',
scannedAt: 1,
ports: [
workspacePort(5173, FEATURE_ID, { advertisedUrl: 'http://dev.test:5173' }),
workspacePort(3000, FEATURE_ID, { confidence: 'command' }),
workspacePort(4000, 'repo::/tmp/repo/other'),
externalPort
]
})
const noFolders = okFixture('req_folders', { folderWorkspaces: [] })
function folderCatalog(folderPath: string): ReturnType<typeof okFixture>[] {
return [
okFixture('req_folders', {
folderWorkspaces: [{ id: 'f1', folderPath, projectGroupId: 'local' }]
}),
okFixture('req_groups', { groups: [{ id: 'local' }] }),
okFixture('req_repos', { repos: [] })
]
}
function selectorNotFound(): RuntimeRpcFailureError {
return new RuntimeRpcFailureError({
id: 'req_show',
ok: false,
error: { code: 'selector_not_found', message: 'selector_not_found' },
_meta: { runtimeId: 'test-runtime' }
})
}
function captureOutput(): () => string {
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
return () => [...logSpy.mock.calls, ...errSpy.mock.calls].flat().join('\n')
}
describe('orca ports', () => {
useWorktreeAwarenessEnvironment({
callMock,
serveOrcaAppMock,
getDefaultUserDataPathMock,
addEnvironmentFromPairingCodeMock,
listEnvironmentsMock,
spawnMock
})
it('scans the host of the worktree containing cwd and keeps only its ports', async () => {
queueFixtures(
callMock,
noFolders,
worktreeListFixture([
buildWorktree('/tmp/repo', 'main'),
buildWorktree('/tmp/repo/feature', 'feature/foo')
]),
okFixture('req_show', { worktree: buildWorktree('/tmp/repo/feature', 'feature/foo') }),
hostScan
)
const output = captureOutput()
await main(['ports', '--json'], '/tmp/repo/feature/src')
expect(callMock).toHaveBeenCalledTimes(4)
expect(callMock).toHaveBeenNthCalledWith(3, 'worktree.show', { worktree: `id:${FEATURE_ID}` })
expect(callMock).toHaveBeenNthCalledWith(4, 'workspacePorts.scanHost', {
worktree: `id:${FEATURE_ID}`
})
const printed = JSON.parse(output())
expect(printed.result.executionHostId).toBe('local')
expect(printed.result.ports.map((port: WorkspacePort) => port.port)).toEqual([3000, 5173])
expect(printed.result.ports[1].advertisedUrl).toBe('http://dev.test:5173')
})
it('prints one row per port with pid, process, host, protocol, match, and url', async () => {
queueFixtures(
callMock,
noFolders,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
okFixture('req_show', { worktree: buildWorktree('/tmp/repo/feature', 'feature/foo') }),
hostScan
)
const output = captureOutput()
await main(['ports'], '/tmp/repo/feature')
expect(output()).toBe(
[
'3000 pid=3001 node localhost http match=command',
'5173 pid=5174 node localhost http match=cwd url=http://dev.test:5173',
'',
'host=local'
].join('\n')
)
})
it('resolves an explicit selector to its worktree id before filtering', async () => {
queueFixtures(
callMock,
okFixture('req_show', { worktree: buildWorktree('/tmp/repo/other', 'other') }),
hostScan
)
const output = captureOutput()
await main(['ports', '--worktree', 'branch:other', '--json'], '/tmp')
expect(callMock).toHaveBeenNthCalledWith(1, 'worktree.show', { worktree: 'branch:other' })
expect(callMock).toHaveBeenNthCalledWith(2, 'workspacePorts.scanHost', {
worktree: 'branch:other'
})
expect(JSON.parse(output()).result.ports.map((port: WorkspacePort) => port.port)).toEqual([
4000
])
})
it('lists every listener on the host with its owner under --all', async () => {
queueFixtures(
callMock,
noFolders,
worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]),
hostScan
)
const output = captureOutput()
await main(['ports', '--all'], '/tmp/repo/feature')
expect(callMock).not.toHaveBeenCalledWith('worktree.show', expect.anything())
expect(output().split('\n').slice(0, 4)).toEqual([
'3000 workspace=feature pid=3001 node localhost http match=command',
'4000 workspace=other pid=4001 node localhost http match=cwd',
'5173 workspace=feature pid=5174 node localhost http match=cwd url=http://dev.test:5173',
'5432 workspace=(none) pid=77 postgres localhost unknown'
])
})
it.each([
['a standalone native folder', '/tmp/folder', '/tmp/folder/src'],
['a native folder nested in a git worktree', '/tmp/repo/feature/docs', '/tmp/repo/feature/docs']
])(
'refuses %s containing cwd unless --all asks for the host',
async (_label, folderPath, cwd) => {
queueFixtures(callMock, ...folderCatalog(folderPath))
const output = captureOutput()
const priorExitCode = process.exitCode
await main(['ports', '--json'], cwd)
expect(callMock).not.toHaveBeenCalledWith('workspacePorts.scanHost', expect.anything())
expect(output()).toContain('does not tie ports to native folder workspaces')
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
queueFixtures(callMock, ...folderCatalog(folderPath), hostScan)
await main(['ports', '--all'], cwd)
expect(callMock).toHaveBeenCalledWith('workspacePorts.scanHost', { worktree: 'id:folder:f1' })
expect(callMock).not.toHaveBeenCalledWith('worktree.list', expect.anything())
expect(output()).toContain('5432 workspace=(none) pid=77 postgres')
}
)
it('recognises a path selector naming a native folder', async () => {
queueFixtures(
callMock,
okFixture('req_folders', {
folderWorkspaces: [{ id: 'f1', folderPath: '/tmp/folder', projectGroupId: 'local' }]
})
)
callMock.mockRejectedValueOnce(selectorNotFound())
const output = captureOutput()
const priorExitCode = process.exitCode
await main(['ports', '--worktree', 'path:/tmp/folder'], '/tmp')
expect(callMock).not.toHaveBeenCalledWith('workspacePorts.scanHost', expect.anything())
expect(output()).toContain('does not tie ports to native folder workspaces')
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
queueFixtures(
callMock,
okFixture('req_folders', {
folderWorkspaces: [{ id: 'f1', folderPath: '/tmp/folder', projectGroupId: 'local' }]
})
)
callMock.mockRejectedValueOnce(selectorNotFound())
queueFixtures(callMock, hostScan)
await main(['ports', '--worktree', 'path:/tmp/folder', '--all'], '/tmp')
expect(callMock).toHaveBeenCalledWith('workspacePorts.scanHost', { worktree: 'id:folder:f1' })
})
it('refuses a path that a worktree and a folder workspace share', async () => {
queueFixtures(
callMock,
okFixture('req_folders', {
folderWorkspaces: [{ id: 'f1', folderPath: '/srv/app', projectGroupId: 'ssh' }]
}),
okFixture('req_show', { worktree: buildWorktree('/srv/app', 'main') })
)
const output = captureOutput()
const priorExitCode = process.exitCode
await main(['ports', '--worktree', 'path:/srv/app', '--all'], '/tmp')
expect(callMock).not.toHaveBeenCalledWith('workspacePorts.scanHost', expect.anything())
expect(output()).toContain('id:repo::/srv/app, folder:f1')
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
it('keeps the ports of a folder workspace instance that shares its path with the owner', async () => {
const instance = {
...buildWorktree('/tmp/repo/feature', 'feature/foo'),
id: `${FEATURE_ID}::workspace:second`
}
queueFixtures(callMock, okFixture('req_show', { worktree: instance }), hostScan)
const output = captureOutput()
await main(['ports', '--worktree', `id:${instance.id}`, '--json'], '/tmp')
expect(JSON.parse(output()).result.ports.map((port: WorkspacePort) => port.port)).toEqual([
3000, 5173
])
})
it('adds the worktree id to owner names shared by two workspaces under --all', async () => {
const twin = workspacePort(4000, 'repo2::/tmp/twin/feature')
queueFixtures(
callMock,
okFixture('req_scan', {
executionHostId: 'local',
platform: 'darwin',
scannedAt: 1,
ports: [workspacePort(3000, FEATURE_ID), twin, externalPort]
})
)
const output = captureOutput()
await main(['ports', '--worktree', 'branch:feature', '--all'], '/tmp')
expect(output().split('\n').slice(0, 3)).toEqual([
`3000 workspace=feature (${FEATURE_ID}) pid=3001 node localhost http match=cwd`,
'4000 workspace=feature (repo2::/tmp/twin/feature) pid=4001 node localhost http match=cwd',
'5432 workspace=(none) pid=77 postgres localhost unknown'
])
})
it('shows why the scan could not run', async () => {
queueFixtures(
callMock,
okFixture('req_scan', {
executionHostId: 'ssh:box',
platform: 'unknown',
scannedAt: 1,
ports: [],
unavailableReason: 'Could not scan the SSH host.'
})
)
const output = captureOutput()
await main(['ports', '--worktree', 'folder:f1', '--all'], '/tmp')
expect(callMock).toHaveBeenCalledTimes(1)
expect(output()).toBe('Port scan unavailable: Could not scan the SSH host.\nhost=ssh:box')
})
it('asks for an Orca update when the runtime predates the host-scoped scan', async () => {
callMock.mockRejectedValueOnce(
new RuntimeClientError('method_not_found', 'Unknown method: workspacePorts.scanHost')
)
const output = captureOutput()
const priorExitCode = process.exitCode
await main(['ports', '--worktree', 'id:repo::/tmp/repo/feature', '--all'], '/tmp')
expect(output()).toContain('cannot list workspace ports yet. Update Orca')
expect(process.exitCode).toBe(1)
process.exitCode = priorExitCode
})
})
+60
View File
@@ -0,0 +1,60 @@
import type {
WorkspacePort,
WorkspacePortHostScanResult,
WorkspacePortOwner
} from '../shared/workspace-ports'
function ownerName(owner: WorkspacePortOwner): string {
return owner.displayName || owner.worktreeId
}
// Why: two workspaces can share a display name; only their ids tell the rows apart.
function findAmbiguousOwnerNames(ports: readonly WorkspacePort[]): Set<string> {
const idsByName = new Map<string, Set<string>>()
for (const port of ports) {
if (port.kind === 'workspace') {
const name = ownerName(port.owner)
idsByName.set(name, (idsByName.get(name) ?? new Set()).add(port.owner.worktreeId))
}
}
return new Set([...idsByName].filter(([, ids]) => ids.size > 1).map(([name]) => name))
}
function formatPortOwner(port: WorkspacePort, ambiguousNames: ReadonlySet<string>): string {
if (port.kind === 'workspace') {
const name = ownerName(port.owner)
return ambiguousNames.has(name)
? `workspace=${name} (${port.owner.worktreeId})`
: `workspace=${name}`
}
return port.kind === 'container' ? 'workspace=(container)' : 'workspace=(none)'
}
function formatPortRow(port: WorkspacePort, ambiguousNames: ReadonlySet<string> | null): string {
return [
String(port.port),
...(ambiguousNames ? [formatPortOwner(port, ambiguousNames)] : []),
`pid=${port.pid ?? '?'}`,
port.processName ?? '(unknown process)',
port.connectHost,
port.protocol,
...(port.kind === 'workspace' ? [`match=${port.owner.confidence}`] : []),
...(port.kind === 'workspace' && port.advertisedUrl ? [`url=${port.advertisedUrl}`] : [])
].join(' ')
}
export function formatPortsScan(
scan: WorkspacePortHostScanResult,
options: { showOwner: boolean }
): string {
const host = `host=${scan.executionHostId}`
if (scan.unavailableReason) {
return `Port scan unavailable: ${scan.unavailableReason}\n${host}`
}
if (scan.ports.length === 0) {
return `No listening ports found.\n${host}`
}
const ambiguousNames = options.showOwner ? findAmbiguousOwnerNames(scan.ports) : null
const rows = scan.ports.map((port) => formatPortRow(port, ambiguousNames))
return `${rows.join('\n')}\n\n${host}`
}
+1
View File
@@ -83,6 +83,7 @@ export const ROOT_HELP_TEXT_PRIMARY = [
' worktree set Update Orca metadata for a worktree',
' worktree rm Remove a worktree from Orca and git',
' worktree ps Show a compact orchestration summary across worktrees',
' ports List the listening ports Orca detected for a workspace',
'',
'Agents:',
' agent launch Start an agent in a worktree, as a chat or terminal per your settings',
+1
View File
@@ -56,6 +56,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
' orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--pr <number|null>] [--linear-issue <identifier-or-url|null>] [--gitlab-issue <number-or-url|null>] [--gitlab-mr <number-or-url|null>] [--comment <text>] [--workspace-status <id>] [--unread|--read] [--parent-worktree <selector>|--no-parent] [--json]',
' orca worktree rm --worktree <selector> [--force] [--run-hooks] [--allow-failed-archive-hook] [--json]',
' orca worktree ps [--limit <n>] [--json]',
' orca ports [--worktree <selector>] [--all] [--json]',
' orca file open <path> [--line <n> [--column <n>]] [--worktree <selector>] [--focus] [--json]',
' orca file diff <path> [--staged] [--worktree <selector>] [--focus] [--json]',
' orca file open-changed [--mode edit|diff|both] [--worktree <selector>] [--focus] [--json]',
+3 -1
View File
@@ -26,6 +26,7 @@ import { ARTIFACT_COMMAND_SPECS } from './artifacts'
import { SEARCH_COMMAND_SPECS } from './search'
import { PROFILE_STATE_COMMAND_SPECS } from './profile-state'
import { REFERENCE_COMMAND_SPECS } from './reference'
import { PORTS_COMMAND_SPECS } from './ports'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
@@ -54,5 +55,6 @@ export const COMMAND_SPECS: CommandSpec[] = [
...EMULATOR_COMMAND_SPECS,
...SKILL_COMMAND_SPECS,
...SEARCH_COMMAND_SPECS,
...PROFILE_STATE_COMMAND_SPECS
...PROFILE_STATE_COMMAND_SPECS,
...PORTS_COMMAND_SPECS
]
+23
View File
@@ -0,0 +1,23 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const PORTS_COMMAND_SPECS: CommandSpec[] = [
{
path: ['ports'],
summary: 'List the listening ports Orca detected for a workspace',
usage: 'orca ports [--worktree <selector>] [--all] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'all'],
notes: [
'Defaults to the Orca workspace that contains the current directory.',
'Scans the host the workspace runs on and lists the ports Orca tied to it, by process working directory (match=cwd) or command line (match=command).',
'--all lists every listener found on that host, including other workspaces and processes Orca could not tie to one. A native folder workspace needs --all, because Orca does not tie ports to folders.',
'url is the address the dev server printed in an Orca terminal; it is missing when Orca did not see one.'
],
examples: [
'orca ports',
'orca ports --worktree branch:feature/login --json',
'orca ports --all',
"orca ports --json | jq -r '.result.ports[0].advertisedUrl'"
]
}
]
+1
View File
@@ -50,6 +50,7 @@ export const CLI_COMMAND_NAMES = [
'open-url',
'orchestration',
'pdf',
'ports',
'profile',
'project',
'reference',