Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
eff3acc
feat: implement file copy and move functionality with unique naming
Aug 30, 2026
613493c
ignore files
Aug 28, 2026
2f33c30
feat: update package-lock.json and enhance tests with realpathSync fo…
Aug 29, 2026
4b301d0
chore: match package.json description to README
Aug 29, 2026
42e91a0
chore: release v0.2.2
Aug 29, 2026
d10a623
feat: Desktop and web continuity
Sep 1, 2026
f735529
feat: enhance GitHub integration and CrewCode Web continuity features
Sep 2, 2026
fd5dbd1
Fix: continuity-state.ts forgot transcriptsCatalogue
Sep 2, 2026
4fe3c89
feat(git): complete in-app pull request workspace
Sep 3, 2026
df47c17
add file to gitignore
Sep 3, 2026
f0a1562
Merge remote-tracking branch 'origin/main' into dev
Sep 3, 2026
e66130c
feat: enhance GitHub merge requirements handling and error reporting
Sep 3, 2026
eed546f
feat: enhance pull request creation with structured body and commit s…
Sep 4, 2026
be9959b
update: Git workspace, pull request workspace CrewCoder ACP
Sep 4, 2026
a26354a
feat: enhance pull request handling and sidebar UI with improved navi…
Sep 5, 2026
be9a9ce
I fixed the release script’s terminal/job-control exposure. Its npm s…
Sep 5, 2026
5374733
feat: add adaptive custom backgrounds for solo chats (OPTIONAL)
Sep 11, 2026
ff9ccf9
feat: enhance context usage handling and ALOT of Updates
Sep 30, 2026
a891956
fix: three test failures
Sep 30, 2026
007bdc4
fix: isolate provider discovery from release terminal
Sep 30, 2026
2298ebf
chore: synchronize main history into dev
Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,9 @@ Release npm verification/version subprocesses must remain non-interactive with s
disconnected and CI mode enabled so nested commands cannot suspend a release through
shell job control. Keep Git fetch/push on the separate interactive path for credential
helpers, and never infer release success until the version commit, tag, and push are
all observed. See `docs/releasing.md`.
all observed. Provider-discovery shell probes must have piped I/O and a separate
process session so shell profiles cannot take the caller's controlling terminal;
discovery tests must mock those probes. See `docs/releasing.md`.

Automatic provider-usage probes must treat an observed CLI crash differently from an
ordinary unavailable response. Cool down the crashing interactive fallback so window
Expand Down
6 changes: 6 additions & 0 deletions docs/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ configured credential helpers can still authenticate. A verification command tha
needs input must fail explicitly rather than leaving a stopped release job that could
later resume and mutate version/tag state.

Provider-discovery tests mock shell probes rather than loading the owner's shell
profiles. Runtime login/interactive discovery probes run in a separate process
session with piped I/O: disconnected stdin alone does not remove access to the
controlling terminal through `/dev/tty`. This prevents a shell profile from taking
terminal custody or suspending the release test process group with `SIGTTIN`.

CrewCode's desktop usage indicators probe installed provider CLIs independently of
the release command. If Claude's interactive usage process exits from a crash signal,
CrewCode pauses that fallback for ten minutes. This prevents focus-driven refreshes
Expand Down
70 changes: 69 additions & 1 deletion src/main/headless-agent-resolver.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { join } from 'node:path'
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'

function shellProbe(output = '', code = 0) {
const child = Object.assign(new EventEmitter(), { stdout: new PassThrough() })
queueMicrotask(() => {
child.stdout.end(output)
child.emit('close', code)
})
return child
}

const spawn = vi.fn((_shell: string, _args: string[], _options: { detached?: boolean; timeout?: number }) => shellProbe())
const spawnSync = vi.fn((_shell: string, _args: string[], _options: { detached?: boolean; timeout?: number }) => ({ stdout: '', status: 0 }))

beforeEach(() => {
spawn.mockReset()
spawn.mockImplementation(() => shellProbe())
spawnSync.mockClear()
// Discovery tests must not execute the owner's login/interactive shell profiles.
vi.doMock('child_process', async importOriginal => ({
...await importOriginal<typeof import('child_process')>(),
spawn,
spawnSync,
}))
})

afterEach(() => {
vi.doUnmock('child_process')
vi.doUnmock('./agents/model-detect')
vi.doUnmock('fs')
vi.doUnmock('os')
vi.resetModules()
Expand Down Expand Up @@ -32,9 +60,43 @@ describe('headless agent registry', () => {
available: true,
path: expectedPath,
})
expect(spawnSync).not.toHaveBeenCalled()
if (process.platform !== 'win32') {
expect(spawn).toHaveBeenCalled()
for (const [, , options] of spawn.mock.calls) {
expect(options).toMatchObject({ detached: true, timeout: 3_000 })
}
}
})

it.skipIf(process.platform === 'win32')('retains interactive-shell discovery without caller terminal access', async () => {
const expectedPath = join('/shell', 'codex')
vi.doMock('fs', async importOriginal => {
const actual = await importOriginal<typeof import('fs')>()
return {
...actual,
promises: { ...actual.promises, access: vi.fn(async (candidate: string) => {
if (candidate !== expectedPath) throw new Error('missing')
}) },
}
})
spawn.mockImplementation((_shell, args) => shellProbe(
args[0] === '-ic' && args[1] === 'command -v codex' ? `${expectedPath}\n` : '',
))

const { headlessAgentRegistry } = await import('./headless-agent-resolver')
const registry = await headlessAgentRegistry()
expect(registry.find(agent => agent.id === 'codex')).toMatchObject({ available: true, path: expectedPath })
expect(spawn).toHaveBeenCalledWith(expect.any(String), ['-ic', 'command -v codex'], expect.objectContaining({
detached: true, stdio: ['ignore', 'pipe', 'ignore'], timeout: 3_000,
}))
})

it('lists models from the same provider CLIs as desktop', async () => {
vi.doMock('fs', async importOriginal => ({
...await importOriginal<typeof import('fs')>(),
existsSync: () => false,
}))
vi.doMock('./agents/model-detect', () => ({
listModels: vi.fn(async (provider: string) => (
provider === 'claude'
Expand All @@ -47,5 +109,11 @@ describe('headless agent registry', () => {
{ id: 'claude-sonnet-4-6', label: 'Sonnet', provider: 'anthropic' },
])
expect(await listHeadlessAgentModels('codex')).toEqual([])
if (process.platform !== 'win32') {
expect(spawnSync).toHaveBeenCalled()
for (const [, , options] of spawnSync.mock.calls) {
expect(options).toMatchObject({ detached: true, timeout: 3_000 })
}
}
})
})
23 changes: 18 additions & 5 deletions src/main/headless-agent-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { existsSync, constants as fsConstants, promises as fsp } from 'fs'
import { homedir } from 'os'
import { delimiter, join } from 'path'
import { execFile, spawnSync } from 'child_process'
import { spawn, spawnSync } from 'child_process'
import { listModels } from './agents/model-detect'

const PROVIDER_COMMANDS: Record<string, string> = {
Expand Down Expand Up @@ -79,8 +79,19 @@ function shellResolve(command: string, flag: '-lc' | '-ic'): Promise<string | nu
if (process.platform === 'win32') return Promise.resolve(null)
const shell = process.env.SHELL || '/bin/sh'
return new Promise(resolve => {
execFile(shell, [flag, `command -v ${command}`], { encoding: 'utf8', timeout: 3_000, windowsHide: true }, (_error, stdout) => {
const path = stdout?.trim().split('\n').pop()?.trim()
// Piped stdin alone leaves /dev/tty accessible to interactive shell profiles.
const child = spawn(shell, [flag, `command -v ${command}`], {
stdio: ['ignore', 'pipe', 'ignore'], detached: true,
timeout: 3_000, killSignal: 'SIGKILL', windowsHide: true,
})
let stdout = ''
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (chunk: string) => {
stdout = (stdout + chunk).slice(-16_384)
})
child.on('error', () => resolve(null))
child.on('close', code => {
const path = code === 0 ? stdout.trim().split('\n').pop()?.trim() : null
resolve(path || null)
})
})
Expand All @@ -106,8 +117,10 @@ export function resolveHeadlessAgentPath(provider: string): string | null {
for (const candidate of candidatePaths(command)) if (candidate && existsSync(candidate)) return candidate
if (process.platform !== 'win32') {
for (const flag of ['-lc', '-ic'] as const) {
const result = spawnSync(process.env.SHELL || '/bin/sh', [flag, `command -v ${command}`], { encoding: 'utf8', timeout: 3_000 })
const resolved = result.stdout?.trim().split('\n').pop()?.trim()
// Shell startup must not take terminal custody from the caller.
const options = { encoding: 'utf8' as const, timeout: 3_000, detached: true, killSignal: 'SIGKILL' as const }
const result = spawnSync(process.env.SHELL || '/bin/sh', [flag, `command -v ${command}`], options)
const resolved = result.status === 0 ? result.stdout?.trim().split('\n').pop()?.trim() : null
if (resolved && existsSync(resolved)) return resolved
}
}
Expand Down
Loading