mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-23 00:35:51 +00:00
feat: 添加工具类增强与状态管理改进
- 新增 workflowRuns、remoteTriggerAudit、pipeStatus 等工具 - 增强 permissionSetup: auto mode 和 bypass permissions 始终可用 - 新增多组测试覆盖 (modifiers, teamDiscovery, deepLink 等) - 修复 parseInt 缺少 radix 参数 - 移除多余 biome-ignore 注释 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -381,7 +381,7 @@ export function useMultiSelectState<T>({
|
|||||||
|
|
||||||
// Handle numeric keys (1-9) for direct selection
|
// Handle numeric keys (1-9) for direct selection
|
||||||
if (!hideIndexes && /^[0-9]+$/.test(normalizedInput)) {
|
if (!hideIndexes && /^[0-9]+$/.test(normalizedInput)) {
|
||||||
const index = parseInt(normalizedInput) - 1
|
const index = parseInt(normalizedInput, 10) - 1
|
||||||
if (index >= 0 && index < options.length) {
|
if (index >= 0 && index < options.length) {
|
||||||
const value = options[index]!.value
|
const value = options[index]!.value
|
||||||
const newValues = selectedValues.includes(value)
|
const newValues = selectedValues.includes(value)
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ export const useSelectInput = <T>({
|
|||||||
disableSelection !== 'numeric' &&
|
disableSelection !== 'numeric' &&
|
||||||
/^[0-9]+$/.test(normalizedInput)
|
/^[0-9]+$/.test(normalizedInput)
|
||||||
) {
|
) {
|
||||||
const index = parseInt(normalizedInput) - 1
|
const index = parseInt(normalizedInput, 10) - 1
|
||||||
if (index >= 0 && index < state.options.length) {
|
if (index >= 0 && index < state.options.length) {
|
||||||
const selectedOption = state.options[index]!
|
const selectedOption = state.options[index]!
|
||||||
if (selectedOption.disabled === true) {
|
if (selectedOption.disabled === true) {
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ export function isNavigableMessage(msg: NavigableMessage): boolean {
|
|||||||
return !stripSystemReminders(b.text!).startsWith('<')
|
return !stripSystemReminders(b.text!).startsWith('<')
|
||||||
}
|
}
|
||||||
case 'system':
|
case 'system':
|
||||||
// biome-ignore lint/nursery/useExhaustiveSwitchCases: blocklist — fallthrough return-true is the design
|
|
||||||
switch (msg.subtype) {
|
switch (msg.subtype) {
|
||||||
case 'api_metrics':
|
case 'api_metrics':
|
||||||
case 'stop_hook_summary':
|
case 'stop_hook_summary':
|
||||||
|
|||||||
@@ -288,7 +288,6 @@ export function useNotifications(): {
|
|||||||
// Imperative read (not useAppState) — a subscription in a mount-only
|
// Imperative read (not useAppState) — a subscription in a mount-only
|
||||||
// effect would be vestigial and make every caller re-render on queue changes.
|
// effect would be vestigial and make every caller re-render on queue changes.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect, store is a stable context ref
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (store.getState().notifications.queue.length > 0) {
|
if (store.getState().notifications.queue.length > 0) {
|
||||||
processQueue()
|
processQueue()
|
||||||
|
|||||||
96
src/utils/__tests__/modifiers.test.ts
Normal file
96
src/utils/__tests__/modifiers.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
|
let nativePrewarmCalls = 0
|
||||||
|
let nativeReturnValue = false
|
||||||
|
let nativeShouldThrow = false
|
||||||
|
|
||||||
|
const nativeIsModifierPressed = mock((modifier: string) => {
|
||||||
|
if (nativeShouldThrow) {
|
||||||
|
throw new Error('native modifier failure')
|
||||||
|
}
|
||||||
|
return nativeReturnValue
|
||||||
|
})
|
||||||
|
|
||||||
|
mock.module('modifiers-napi', () => ({
|
||||||
|
prewarm: async () => {
|
||||||
|
nativePrewarmCalls++
|
||||||
|
},
|
||||||
|
isModifierPressed: nativeIsModifierPressed,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const originalPlatform = process.platform
|
||||||
|
|
||||||
|
async function loadModule() {
|
||||||
|
return import(`../modifiers.ts?case=${Math.random()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
nativePrewarmCalls = 0
|
||||||
|
nativeReturnValue = false
|
||||||
|
nativeShouldThrow = false
|
||||||
|
nativeIsModifierPressed.mockClear()
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: originalPlatform,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: originalPlatform,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('src/utils/modifiers', () => {
|
||||||
|
test('does not touch the native module on non-darwin', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: 'win32',
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
const mod = await loadModule()
|
||||||
|
|
||||||
|
mod.prewarmModifiers()
|
||||||
|
expect(nativePrewarmCalls).toBe(0)
|
||||||
|
expect(mod.isModifierPressed('shift')).toBe(false)
|
||||||
|
expect(nativeIsModifierPressed).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('caches native prewarm after the first darwin call', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: 'darwin',
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
const mod = await loadModule()
|
||||||
|
|
||||||
|
mod.prewarmModifiers()
|
||||||
|
mod.prewarmModifiers()
|
||||||
|
|
||||||
|
// prewarm is fire-and-forget async — flush microtasks
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
expect(nativePrewarmCalls).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('forwards modifier checks to the native module on darwin', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: 'darwin',
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
nativeReturnValue = true
|
||||||
|
const mod = await loadModule()
|
||||||
|
|
||||||
|
expect(mod.isModifierPressed('shift')).toBe(true)
|
||||||
|
expect(nativeIsModifierPressed).toHaveBeenCalledWith('shift')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns false when native modifier checks throw on darwin', async () => {
|
||||||
|
Object.defineProperty(process, 'platform', {
|
||||||
|
value: 'darwin',
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
nativeShouldThrow = true
|
||||||
|
const mod = await loadModule()
|
||||||
|
|
||||||
|
expect(mod.isModifierPressed('shift')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
69
src/utils/__tests__/pipeStatus.test.ts
Normal file
69
src/utils/__tests__/pipeStatus.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { writeRegistry } from '../pipeRegistry'
|
||||||
|
import { formatPipeRegistryStatus } from '../pipeStatus'
|
||||||
|
|
||||||
|
let tempDir: string
|
||||||
|
let previousConfigDir: string | undefined
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||||
|
tempDir = join(
|
||||||
|
tmpdir(),
|
||||||
|
`pipe-status-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
)
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = tempDir
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (previousConfigDir === undefined) {
|
||||||
|
delete process.env.CLAUDE_CONFIG_DIR
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||||
|
}
|
||||||
|
await rm(tempDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pipe status', () => {
|
||||||
|
test('formats registry main and sub pipe communication state', async () => {
|
||||||
|
await writeRegistry({
|
||||||
|
version: 1,
|
||||||
|
mainMachineId: 'machine-main-123456',
|
||||||
|
main: {
|
||||||
|
id: 'main-id',
|
||||||
|
pid: 123,
|
||||||
|
machineId: 'machine-main-123456',
|
||||||
|
startedAt: 1,
|
||||||
|
ip: '127.0.0.1',
|
||||||
|
mac: '00:11:22:33:44:55',
|
||||||
|
hostname: 'main-host',
|
||||||
|
pipeName: 'main-pipe',
|
||||||
|
tcpPort: 43123,
|
||||||
|
},
|
||||||
|
subs: [
|
||||||
|
{
|
||||||
|
id: 'sub-id',
|
||||||
|
pid: 456,
|
||||||
|
machineId: 'machine-sub-123456',
|
||||||
|
startedAt: 2,
|
||||||
|
ip: '127.0.0.2',
|
||||||
|
mac: '66:77:88:99:aa:bb',
|
||||||
|
hostname: 'sub-host',
|
||||||
|
pipeName: 'sub-pipe',
|
||||||
|
tcpPort: 43124,
|
||||||
|
subIndex: 1,
|
||||||
|
boundToMain: 'main-pipe',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatted = await formatPipeRegistryStatus()
|
||||||
|
|
||||||
|
expect(formatted).toContain('Pipe registry: 1 main, 1 sub(s)')
|
||||||
|
expect(formatted).toContain('[main] main-pipe')
|
||||||
|
expect(formatted).toContain('[sub-1] sub-pipe')
|
||||||
|
expect(formatted).toContain('bound=main-pipe')
|
||||||
|
})
|
||||||
|
})
|
||||||
37
src/utils/__tests__/remoteControlStatus.test.ts
Normal file
37
src/utils/__tests__/remoteControlStatus.test.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { formatRemoteControlLocalStatus } from '../remoteControlStatus'
|
||||||
|
|
||||||
|
let previousBaseUrl: string | undefined
|
||||||
|
let previousToken: string | undefined
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
previousBaseUrl = process.env.CLAUDE_BRIDGE_BASE_URL
|
||||||
|
previousToken = process.env.CLAUDE_BRIDGE_OAUTH_TOKEN
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (previousBaseUrl === undefined) {
|
||||||
|
delete process.env.CLAUDE_BRIDGE_BASE_URL
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_BRIDGE_BASE_URL = previousBaseUrl
|
||||||
|
}
|
||||||
|
if (previousToken === undefined) {
|
||||||
|
delete process.env.CLAUDE_BRIDGE_OAUTH_TOKEN
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN = previousToken
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('remote control status', () => {
|
||||||
|
test('formats self-hosted bridge local config without remote calls', () => {
|
||||||
|
process.env.CLAUDE_BRIDGE_BASE_URL = 'http://127.0.0.1:8787'
|
||||||
|
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN = 'token'
|
||||||
|
|
||||||
|
const status = formatRemoteControlLocalStatus()
|
||||||
|
|
||||||
|
expect(status).toContain('Remote Control: self-hosted')
|
||||||
|
expect(status).toContain('base_url=http://127.0.0.1:8787')
|
||||||
|
expect(status).toContain('token=present')
|
||||||
|
expect(status).toContain('entitlement=checked at remote-control startup')
|
||||||
|
})
|
||||||
|
})
|
||||||
43
src/utils/__tests__/remoteTriggerAudit.test.ts
Normal file
43
src/utils/__tests__/remoteTriggerAudit.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { rm } from 'fs/promises'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { join } from 'path'
|
||||||
|
import {
|
||||||
|
appendRemoteTriggerAuditRecord,
|
||||||
|
formatRemoteTriggerAuditStatus,
|
||||||
|
listRemoteTriggerAuditRecords,
|
||||||
|
} from '../remoteTriggerAudit'
|
||||||
|
|
||||||
|
let tempDir = ''
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempDir = join(
|
||||||
|
tmpdir(),
|
||||||
|
`remote-trigger-audit-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tempDir, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('remote trigger audit', () => {
|
||||||
|
test('records and formats local remote trigger audit events', async () => {
|
||||||
|
await appendRemoteTriggerAuditRecord(
|
||||||
|
{ action: 'run', triggerId: 'abc', ok: true, status: 200, createdAt: 1 },
|
||||||
|
tempDir,
|
||||||
|
)
|
||||||
|
await appendRemoteTriggerAuditRecord(
|
||||||
|
{ action: 'create', ok: false, error: 'bad request', createdAt: 2 },
|
||||||
|
tempDir,
|
||||||
|
)
|
||||||
|
|
||||||
|
const records = await listRemoteTriggerAuditRecords(tempDir)
|
||||||
|
expect(records).toHaveLength(2)
|
||||||
|
expect(records[0].action).toBe('create')
|
||||||
|
expect(formatRemoteTriggerAuditStatus(records)).toContain(
|
||||||
|
'RemoteTrigger audit records: 2',
|
||||||
|
)
|
||||||
|
expect(formatRemoteTriggerAuditStatus(records)).toContain('Failures: 1')
|
||||||
|
})
|
||||||
|
})
|
||||||
68
src/utils/__tests__/teamDiscovery.test.ts
Normal file
68
src/utils/__tests__/teamDiscovery.test.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { getTeammateStatuses } from '../teamDiscovery'
|
||||||
|
|
||||||
|
let tempHome: string
|
||||||
|
let previousConfigDir: string | undefined
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||||
|
tempHome = join(
|
||||||
|
tmpdir(),
|
||||||
|
`team-discovery-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
||||||
|
)
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = tempHome
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (previousConfigDir === undefined) {
|
||||||
|
delete process.env.CLAUDE_CONFIG_DIR
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||||
|
}
|
||||||
|
rmSync(tempHome, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function writeTeamConfig(teamName: string, config: unknown): void {
|
||||||
|
const teamDir = join(tempHome, 'teams', teamName)
|
||||||
|
mkdirSync(teamDir, { recursive: true })
|
||||||
|
writeFileSync(join(teamDir, 'config.json'), JSON.stringify(config, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getTeammateStatuses', () => {
|
||||||
|
test('preserves in-process backend type for lifecycle actions', () => {
|
||||||
|
writeTeamConfig('alpha', {
|
||||||
|
name: 'alpha',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
leadAgentId: 'team-lead@alpha',
|
||||||
|
members: [
|
||||||
|
{
|
||||||
|
agentId: 'team-lead@alpha',
|
||||||
|
name: 'team-lead',
|
||||||
|
joinedAt: Date.now(),
|
||||||
|
tmuxPaneId: '',
|
||||||
|
cwd: tempHome,
|
||||||
|
subscriptions: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
agentId: 'worker@alpha',
|
||||||
|
name: 'worker',
|
||||||
|
joinedAt: Date.now(),
|
||||||
|
tmuxPaneId: 'in-process',
|
||||||
|
cwd: tempHome,
|
||||||
|
subscriptions: [],
|
||||||
|
backendType: 'in-process',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getTeammateStatuses('alpha')).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
agentId: 'worker@alpha',
|
||||||
|
backendType: 'in-process',
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -30,6 +30,18 @@ mock.module("src/services/tokenEstimation.ts", () => ({
|
|||||||
countTokensViaHaikuFallback: async () => 0,
|
countTokensViaHaikuFallback: async () => 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock slowOperations to avoid bun:bundle import
|
||||||
|
mock.module('src/utils/slowOperations.ts', () => ({
|
||||||
|
jsonStringify: JSON.stringify,
|
||||||
|
jsonParse: JSON.parse,
|
||||||
|
slowLogging: { enabled: false },
|
||||||
|
clone: (v: any) => structuredClone(v),
|
||||||
|
cloneDeep: (v: any) => structuredClone(v),
|
||||||
|
callerFrame: () => '',
|
||||||
|
SLOW_OPERATION_THRESHOLD_MS: 100,
|
||||||
|
writeFileSync_DEPRECATED: () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getTokenCountFromUsage,
|
getTokenCountFromUsage,
|
||||||
getTokenUsage,
|
getTokenUsage,
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export function getExperimentAdvisorModels():
|
|||||||
export function modelSupportsAdvisor(model: string): boolean {
|
export function modelSupportsAdvisor(model: string): boolean {
|
||||||
const m = model.toLowerCase()
|
const m = model.toLowerCase()
|
||||||
return (
|
return (
|
||||||
|
m.includes('opus-4-7') ||
|
||||||
m.includes('opus-4-6') ||
|
m.includes('opus-4-6') ||
|
||||||
m.includes('sonnet-4-6') ||
|
m.includes('sonnet-4-6') ||
|
||||||
process.env.USER_TYPE === 'ant'
|
process.env.USER_TYPE === 'ant'
|
||||||
@@ -99,6 +100,7 @@ export function modelSupportsAdvisor(model: string): boolean {
|
|||||||
export function isValidAdvisorModel(model: string): boolean {
|
export function isValidAdvisorModel(model: string): boolean {
|
||||||
const m = model.toLowerCase()
|
const m = model.toLowerCase()
|
||||||
return (
|
return (
|
||||||
|
m.includes('opus-4-7') ||
|
||||||
m.includes('opus-4-6') ||
|
m.includes('opus-4-6') ||
|
||||||
m.includes('sonnet-4-6') ||
|
m.includes('sonnet-4-6') ||
|
||||||
process.env.USER_TYPE === 'ant'
|
process.env.USER_TYPE === 'ant'
|
||||||
|
|||||||
@@ -536,9 +536,25 @@ export type Attachment =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'skill_discovery'
|
type: 'skill_discovery'
|
||||||
skills: { name: string; description: string; shortId?: string }[]
|
skills: {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
shortId?: string
|
||||||
|
score?: number
|
||||||
|
autoLoaded?: boolean
|
||||||
|
content?: string
|
||||||
|
path?: string
|
||||||
|
}[]
|
||||||
signal: DiscoverySignal
|
signal: DiscoverySignal
|
||||||
source: 'native' | 'aki' | 'both'
|
source: 'native' | 'aki' | 'both'
|
||||||
|
gap?: {
|
||||||
|
key: string
|
||||||
|
status: 'pending' | 'draft' | 'active'
|
||||||
|
draftName?: string
|
||||||
|
draftPath?: string
|
||||||
|
activeName?: string
|
||||||
|
activePath?: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'queued_command'
|
type: 'queued_command'
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ export function getAttributionTexts(): AttributionTexts {
|
|||||||
const modelName =
|
const modelName =
|
||||||
isInternalModelRepoCached() || isKnownPublicModel
|
isInternalModelRepoCached() || isKnownPublicModel
|
||||||
? getPublicModelName(model)
|
? getPublicModelName(model)
|
||||||
: 'Claude Opus 4.6'
|
: 'Claude Opus 4.7'
|
||||||
const defaultAttribution = `🤖 Generated with [Claude Code](${PRODUCT_URL})`
|
const defaultAttribution = `🤖 Generated with [Claude Code](${PRODUCT_URL})`
|
||||||
const defaultCommit = `Co-Authored-By: ${modelName} <noreply@anthropic.com>`
|
const defaultCommit = `Co-Authored-By: ${modelName} <noreply@anthropic.com>`
|
||||||
|
|
||||||
|
|||||||
@@ -514,7 +514,6 @@ async function _runAndCache(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (epoch !== _apiKeyHelperEpoch) return ' '
|
if (epoch !== _apiKeyHelperEpoch) return ' '
|
||||||
const detail = e instanceof Error ? e.message : String(e)
|
const detail = e instanceof Error ? e.message : String(e)
|
||||||
// biome-ignore lint/suspicious/noConsole: user-configured script failed; must be visible without --debug
|
|
||||||
console.error(chalk.red(`apiKeyHelper failed: ${detail}`))
|
console.error(chalk.red(`apiKeyHelper failed: ${detail}`))
|
||||||
logForDebugging(`Error getting API key from apiKeyHelper: ${detail}`, {
|
logForDebugging(`Error getting API key from apiKeyHelper: ${detail}`, {
|
||||||
level: 'error',
|
level: 'error',
|
||||||
@@ -690,7 +689,6 @@ export function refreshAwsAuth(awsAuthRefresh: string): Promise<boolean> {
|
|||||||
: chalk.red(
|
: chalk.red(
|
||||||
'Error running awsAuthRefresh (in settings or ~/.claude.json):',
|
'Error running awsAuthRefresh (in settings or ~/.claude.json):',
|
||||||
)
|
)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.error(message)
|
console.error(message)
|
||||||
authStatusManager.endAuthentication(false)
|
authStatusManager.endAuthentication(false)
|
||||||
void resolve(false)
|
void resolve(false)
|
||||||
@@ -769,10 +767,8 @@ async function getAwsCredsFromCredentialExport(): Promise<{
|
|||||||
'Error getting AWS credentials from awsCredentialExport (in settings or ~/.claude.json):',
|
'Error getting AWS credentials from awsCredentialExport (in settings or ~/.claude.json):',
|
||||||
)
|
)
|
||||||
if (e instanceof Error) {
|
if (e instanceof Error) {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.error(message, e.message)
|
console.error(message, e.message)
|
||||||
} else {
|
} else {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.error(message, e)
|
console.error(message, e)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@@ -958,7 +954,6 @@ export function refreshGcpAuth(gcpAuthRefresh: string): Promise<boolean> {
|
|||||||
: chalk.red(
|
: chalk.red(
|
||||||
'Error running gcpAuthRefresh (in settings or ~/.claude.json):',
|
'Error running gcpAuthRefresh (in settings or ~/.claude.json):',
|
||||||
)
|
)
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.error(message)
|
console.error(message)
|
||||||
authStatusManager.endAuthentication(false)
|
authStatusManager.endAuthentication(false)
|
||||||
void resolve(false)
|
void resolve(false)
|
||||||
@@ -1779,6 +1774,7 @@ export function getOtelHeadersFromHelper(): Record<string, string> {
|
|||||||
const debounceMs = parseInt(
|
const debounceMs = parseInt(
|
||||||
process.env.CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS ||
|
process.env.CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS ||
|
||||||
DEFAULT_OTEL_HEADERS_DEBOUNCE_MS.toString(),
|
DEFAULT_OTEL_HEADERS_DEBOUNCE_MS.toString(),
|
||||||
|
10,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
cachedOtelHeaders &&
|
cachedOtelHeaders &&
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ export async function assertMinVersion(): Promise<void> {
|
|||||||
versionConfig.minVersion &&
|
versionConfig.minVersion &&
|
||||||
lt(MACRO.VERSION, versionConfig.minVersion)
|
lt(MACRO.VERSION, versionConfig.minVersion)
|
||||||
) {
|
) {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.error(`
|
console.error(`
|
||||||
It looks like your version of Claude Code (${MACRO.VERSION}) needs an update.
|
It looks like your version of Claude Code (${MACRO.VERSION}) needs an update.
|
||||||
A newer version (${versionConfig.minVersion} or higher) is required to continue.
|
A newer version (${versionConfig.minVersion} or higher) is required to continue.
|
||||||
@@ -478,7 +477,6 @@ export async function installGlobalPackage(
|
|||||||
currentVersion:
|
currentVersion:
|
||||||
MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||||
})
|
})
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.error(`
|
console.error(`
|
||||||
Error: Windows NPM detected in WSL
|
Error: Windows NPM detected in WSL
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ export const createAndSaveSnapshot = async (
|
|||||||
|
|
||||||
logForDebugging(`Creating shell snapshot for ${shellType} (${binShell})`)
|
logForDebugging(`Creating shell snapshot for ${shellType} (${binShell})`)
|
||||||
|
|
||||||
|
// biome-ignore lint/suspicious/noAsyncPromiseExecutor: async needed for sequential awaits inside executor
|
||||||
return new Promise(async resolve => {
|
return new Promise(async resolve => {
|
||||||
try {
|
try {
|
||||||
const configFile = getConfigFile(binShell)
|
const configFile = getConfigFile(binShell)
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ const BRACE_EXPANSION_RE = /\{[^{}\s]*(,|\.\.)[^{}\s]*\}/
|
|||||||
* word boundaries.
|
* word boundaries.
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control character detection regex
|
||||||
const CONTROL_CHAR_RE = /[\x00-\x08\x0B-\x1F\x7F]/
|
const CONTROL_CHAR_RE = /[\x00-\x08\x0B-\x1F\x7F]/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1899,6 +1900,7 @@ function walkVariableAssignment(
|
|||||||
return {
|
return {
|
||||||
kind: 'too-complex',
|
kind: 'too-complex',
|
||||||
reason:
|
reason:
|
||||||
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: ${VAR} is bash syntax documentation, not a JS template literal
|
||||||
'PS4 value outside safe charset — only ${VAR} refs and [A-Za-z0-9 _+:.=/[]-] allowed',
|
'PS4 value outside safe charset — only ${VAR} refs and [A-Za-z0-9 _+:.=/[]-] allowed',
|
||||||
nodeType: 'variable_assignment',
|
nodeType: 'variable_assignment',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export function sanitizeSurfaceKey(surfaceKey: string): string {
|
|||||||
*/
|
*/
|
||||||
export function sanitizeModelName(shortName: string): string {
|
export function sanitizeModelName(shortName: string): string {
|
||||||
// Map internal variants to public equivalents based on model family
|
// Map internal variants to public equivalents based on model family
|
||||||
|
if (shortName.includes('opus-4-7')) return 'claude-opus-4-7'
|
||||||
if (shortName.includes('opus-4-6')) return 'claude-opus-4-6'
|
if (shortName.includes('opus-4-6')) return 'claude-opus-4-6'
|
||||||
if (shortName.includes('opus-4-5')) return 'claude-opus-4-5'
|
if (shortName.includes('opus-4-5')) return 'claude-opus-4-5'
|
||||||
if (shortName.includes('opus-4-1')) return 'claude-opus-4-1'
|
if (shortName.includes('opus-4-1')) return 'claude-opus-4-1'
|
||||||
|
|||||||
@@ -525,8 +525,8 @@ export type GlobalConfig = {
|
|||||||
// Permission explainer configuration
|
// Permission explainer configuration
|
||||||
permissionExplainerEnabled?: boolean // Enable Haiku-generated explanations for permission requests (default: true)
|
permissionExplainerEnabled?: boolean // Enable Haiku-generated explanations for permission requests (default: true)
|
||||||
|
|
||||||
// Teammate spawn mode: 'auto' | 'tmux' | 'in-process'
|
// Teammate spawn mode: 'auto' | 'tmux' | 'windows-terminal' | 'in-process'
|
||||||
teammateMode?: 'auto' | 'tmux' | 'in-process' // How to spawn teammates (default: 'auto')
|
teammateMode?: 'auto' | 'tmux' | 'windows-terminal' | 'in-process' // How to spawn teammates (default: 'auto')
|
||||||
// Model for new teammates when the tool call doesn't pass one.
|
// Model for new teammates when the tool call doesn't pass one.
|
||||||
// undefined = hardcoded Opus (backward-compat); null = leader's model; string = model alias/ID.
|
// undefined = hardcoded Opus (backward-compat); null = leader's model; string = model alias/ID.
|
||||||
teammateDefaultModel?: string | null
|
teammateDefaultModel?: string | null
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ export function modelSupports1M(model: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const canonical = getCanonicalName(model)
|
const canonical = getCanonicalName(model)
|
||||||
return canonical.includes('claude-sonnet-4') || canonical.includes('opus-4-6')
|
return (
|
||||||
|
canonical.includes('claude-sonnet-4') ||
|
||||||
|
canonical.includes('opus-4-6') ||
|
||||||
|
canonical.includes('opus-4-7')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getContextWindowForModel(
|
export function getContextWindowForModel(
|
||||||
@@ -171,7 +175,10 @@ export function getModelMaxOutputTokens(model: string): {
|
|||||||
|
|
||||||
const m = getCanonicalName(model)
|
const m = getCanonicalName(model)
|
||||||
|
|
||||||
if (m.includes('opus-4-6')) {
|
if (m.includes('opus-4-7')) {
|
||||||
|
defaultTokens = 64_000
|
||||||
|
upperLimit = 128_000
|
||||||
|
} else if (m.includes('opus-4-6')) {
|
||||||
defaultTokens = 64_000
|
defaultTokens = 64_000
|
||||||
upperLimit = 128_000
|
upperLimit = 128_000
|
||||||
} else if (m.includes('sonnet-4-6')) {
|
} else if (m.includes('sonnet-4-6')) {
|
||||||
|
|||||||
93
src/utils/deepLink/__tests__/protocolHandler.test.ts
Normal file
93
src/utils/deepLink/__tests__/protocolHandler.test.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||||
|
|
||||||
|
const mockParseDeepLink = mock((uri: string) => {
|
||||||
|
if (uri === null || uri === undefined || uri === 'bad-uri') {
|
||||||
|
throw new Error('invalid deep link')
|
||||||
|
}
|
||||||
|
return { query: 'hello', cwd: 'E:/Source_code/Claude-code-bast-test' }
|
||||||
|
})
|
||||||
|
const mockLaunchInTerminal = mock(async () => true)
|
||||||
|
|
||||||
|
mock.module('../parseDeepLink.js', () => ({
|
||||||
|
parseDeepLink: mockParseDeepLink,
|
||||||
|
}))
|
||||||
|
mock.module('../registerProtocol.js', () => ({
|
||||||
|
MACOS_BUNDLE_ID: 'com.anthropic.claude-code-url-handler',
|
||||||
|
}))
|
||||||
|
mock.module('../terminalLauncher.js', () => ({
|
||||||
|
launchInTerminal: mockLaunchInTerminal,
|
||||||
|
}))
|
||||||
|
mock.module('../banner.js', () => ({
|
||||||
|
readLastFetchTime: async () => undefined,
|
||||||
|
buildDeepLinkBanner: () => '',
|
||||||
|
}))
|
||||||
|
mock.module('../../githubRepoPathMapping.js', () => ({
|
||||||
|
updateGithubRepoPathMapping: async () => {},
|
||||||
|
getKnownPathsForRepo: () => [],
|
||||||
|
filterExistingPaths: async () => [],
|
||||||
|
validateRepoAtPath: async () => false,
|
||||||
|
removePathFromRepo: () => {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { handleDeepLinkUri, handleUrlSchemeLaunch } = await import(
|
||||||
|
'../protocolHandler.js'
|
||||||
|
)
|
||||||
|
|
||||||
|
const originalBundleId = process.env.__CFBundleIdentifier
|
||||||
|
const originalUrlEvent = process.env.CLAUDE_CODE_URL_EVENT
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockParseDeepLink.mockClear()
|
||||||
|
mockLaunchInTerminal.mockClear()
|
||||||
|
process.env.__CFBundleIdentifier = undefined
|
||||||
|
delete process.env.CLAUDE_CODE_URL_EVENT
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.__CFBundleIdentifier = originalBundleId
|
||||||
|
if (originalUrlEvent === undefined) {
|
||||||
|
delete process.env.CLAUDE_CODE_URL_EVENT
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_CODE_URL_EVENT = originalUrlEvent
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleUrlSchemeLaunch', () => {
|
||||||
|
test('returns null without calling url-handler-napi when bundle id does not match', async () => {
|
||||||
|
process.env.__CFBundleIdentifier = 'other.bundle'
|
||||||
|
|
||||||
|
await expect(handleUrlSchemeLaunch()).resolves.toBeNull()
|
||||||
|
expect(mockParseDeepLink).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns null for a matching bundle id when no URL event arrives', async () => {
|
||||||
|
process.env.__CFBundleIdentifier = 'com.anthropic.claude-code-url-handler'
|
||||||
|
|
||||||
|
await expect(handleUrlSchemeLaunch()).resolves.toBeNull()
|
||||||
|
expect(mockParseDeepLink).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('handles a URL event after waiting for url-handler-napi', async () => {
|
||||||
|
process.env.__CFBundleIdentifier = 'com.anthropic.claude-code-url-handler'
|
||||||
|
process.env.CLAUDE_CODE_URL_EVENT = 'claude-cli://prompt?q=hello'
|
||||||
|
|
||||||
|
await expect(handleUrlSchemeLaunch()).resolves.toBe(0)
|
||||||
|
expect(mockParseDeepLink).toHaveBeenCalledWith(
|
||||||
|
'claude-cli://prompt?q=hello',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('handleDeepLinkUri', () => {
|
||||||
|
test('returns 1 when parsing fails', async () => {
|
||||||
|
await expect(handleDeepLinkUri('bad-uri')).resolves.toBe(1)
|
||||||
|
expect(mockLaunchInTerminal).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns 0 when parsing succeeds and terminal launch succeeds', async () => {
|
||||||
|
await expect(
|
||||||
|
handleDeepLinkUri('claude-cli://prompt?q=hello'),
|
||||||
|
).resolves.toBe(0)
|
||||||
|
expect(mockLaunchInTerminal).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -94,11 +94,13 @@ export async function handleUrlSchemeLaunch(): Promise<number | null> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { waitForUrlEvent } = await import('url-handler-napi')
|
const { waitForUrlEvent } = await import('url-handler-napi')
|
||||||
const url = (waitForUrlEvent as any)(5000)
|
const url = await (
|
||||||
|
waitForUrlEvent as (timeoutMs?: number) => Promise<string | null>
|
||||||
|
)(5000)
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return await handleDeepLinkUri(await url as string)
|
return await handleDeepLinkUri(url)
|
||||||
} catch {
|
} catch {
|
||||||
// NAPI module not available, or handleDeepLinkUri rejected — not a URL launch
|
// NAPI module not available, or handleDeepLinkUri rejected — not a URL launch
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export function isBilledAsExtraUsage(
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/\[1m\]$/, '')
|
.replace(/\[1m\]$/, '')
|
||||||
.trim()
|
.trim()
|
||||||
const isOpus46 = m === 'opus' || m.includes('opus-4-6')
|
const isOpus46 =
|
||||||
|
m === 'opus' || m.includes('opus-4-6') || m.includes('opus-4-7')
|
||||||
const isSonnet46 = m === 'sonnet' || m.includes('sonnet-4-6')
|
const isSonnet46 = m === 'sonnet' || m.includes('sonnet-4-6')
|
||||||
|
|
||||||
if (isOpus46 && isOpus1mMerged) return false
|
if (isOpus46 && isOpus1mMerged) return false
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ export function getFastModeUnavailableReason(): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @[MODEL LAUNCH]: Update supported Fast Mode models.
|
// @[MODEL LAUNCH]: Update supported Fast Mode models.
|
||||||
export const FAST_MODE_MODEL_DISPLAY = 'Opus 4.6'
|
export const FAST_MODE_MODEL_DISPLAY = 'Opus 4.7'
|
||||||
|
|
||||||
export function getFastModeModel(): string {
|
export function getFastModeModel(): string {
|
||||||
return 'opus' + (isOpus1mMergeEnabled() ? '[1m]' : '')
|
return 'opus' + (isOpus1mMergeEnabled() ? '[1m]' : '')
|
||||||
@@ -172,7 +172,10 @@ export function isFastModeSupportedByModel(
|
|||||||
}
|
}
|
||||||
const model = modelSetting ?? getDefaultMainLoopModelSetting()
|
const model = modelSetting ?? getDefaultMainLoopModelSetting()
|
||||||
const parsedModel = parseUserSpecifiedModel(model)
|
const parsedModel = parseUserSpecifiedModel(model)
|
||||||
return parsedModel.toLowerCase().includes('opus-4-6')
|
return (
|
||||||
|
parsedModel.toLowerCase().includes('opus-4-7') ||
|
||||||
|
parsedModel.toLowerCase().includes('opus-4-6')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Fast mode runtime state ---
|
// --- Fast mode runtime state ---
|
||||||
|
|||||||
@@ -1109,7 +1109,6 @@ async function readFileAsyncOrNull(path: string): Promise<string | null> {
|
|||||||
const ENABLE_DUMP_STATE = false
|
const ENABLE_DUMP_STATE = false
|
||||||
function maybeDumpStateForDebug(state: FileHistoryState): void {
|
function maybeDumpStateForDebug(state: FileHistoryState): void {
|
||||||
if (ENABLE_DUMP_STATE) {
|
if (ENABLE_DUMP_STATE) {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.error(inspect(state, false, 5))
|
console.error(inspect(state, false, 5))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export type FrontmatterData = {
|
|||||||
// Values are arrays of matcher configurations with hooks
|
// Values are arrays of matcher configurations with hooks
|
||||||
// Validated by HooksSchema in loadSkillsDir.ts
|
// Validated by HooksSchema in loadSkillsDir.ts
|
||||||
hooks?: HooksSettings | null
|
hooks?: HooksSettings | null
|
||||||
// Effort level for agents (e.g., 'low', 'medium', 'high', 'max', or an integer)
|
// Effort level for agents (e.g., 'low', 'medium', 'high', 'xhigh', 'max', or an integer)
|
||||||
// Controls the thinking effort used by the agent's model
|
// Controls the thinking effort used by the agent's model
|
||||||
effort?: string | null
|
effort?: string | null
|
||||||
// Execution context for skills: 'inline' (default) or 'fork' (run as sub-agent)
|
// Execution context for skills: 'inline' (default) or 'fork' (run as sub-agent)
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ export async function returnValue<A>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type QueuedGenerator<A> = {
|
type QueuedGenerator<A> = {
|
||||||
|
// biome-ignore lint/suspicious/noConfusingVoidType: void matches AsyncGenerator<A, void> return type
|
||||||
done: boolean | void
|
done: boolean | void
|
||||||
|
// biome-ignore lint/suspicious/noConfusingVoidType: void matches AsyncGenerator<A, void> yield type
|
||||||
value: A | void
|
value: A | void
|
||||||
generator: AsyncGenerator<A, void>
|
generator: AsyncGenerator<A, void>
|
||||||
promise: Promise<QueuedGenerator<A>>
|
promise: Promise<QueuedGenerator<A>>
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ function urlMatchesPattern(url: string, pattern: string): boolean {
|
|||||||
*/
|
*/
|
||||||
function sanitizeHeaderValue(value: string): string {
|
function sanitizeHeaderValue(value: string): string {
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional control character sanitization
|
||||||
return value.replace(/[\r\n\x00]/g, '')
|
return value.replace(/[\r\n\x00]/g, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ async function readIdeLockfile(path: string): Promise<IdeLockfileInfo | null> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
workspaceFolders,
|
workspaceFolders,
|
||||||
port: parseInt(port),
|
port: parseInt(port, 10),
|
||||||
pid,
|
pid,
|
||||||
ideName,
|
ideName,
|
||||||
useWebSocket,
|
useWebSocket,
|
||||||
@@ -669,7 +669,7 @@ export async function detectIDEs(
|
|||||||
try {
|
try {
|
||||||
// Get the CLAUDE_CODE_SSE_PORT if set
|
// Get the CLAUDE_CODE_SSE_PORT if set
|
||||||
const ssePort = process.env.CLAUDE_CODE_SSE_PORT
|
const ssePort = process.env.CLAUDE_CODE_SSE_PORT
|
||||||
const envPort = ssePort ? parseInt(ssePort) : null
|
const envPort = ssePort ? parseInt(ssePort, 10) : null
|
||||||
|
|
||||||
// Get the current working directory, normalized to NFC for consistent
|
// Get the current working directory, normalized to NFC for consistent
|
||||||
// comparison. macOS returns NFD paths (decomposed Unicode), while IDEs
|
// comparison. macOS returns NFD paths (decomposed Unicode), while IDEs
|
||||||
@@ -1006,7 +1006,7 @@ function getVSCodeIDECommandByParentProcess(): string | null {
|
|||||||
if (!ppidStr) {
|
if (!ppidStr) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
pid = parseInt(ppidStr.trim())
|
pid = parseInt(ppidStr.trim(), 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -158,7 +158,6 @@ const isHardFailMode = memoize((): boolean => {
|
|||||||
export function logError(error: unknown): void {
|
export function logError(error: unknown): void {
|
||||||
const err = toError(error)
|
const err = toError(error)
|
||||||
if (feature('HARD_FAIL') && isHardFailMode()) {
|
if (feature('HARD_FAIL') && isHardFailMode()) {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional crash output
|
|
||||||
console.error('[HARD FAIL] logError called with:', err.stack || err.message)
|
console.error('[HARD FAIL] logError called with:', err.stack || err.message)
|
||||||
// eslint-disable-next-line custom-rules/no-process-exit
|
// eslint-disable-next-line custom-rules/no-process-exit
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
@@ -3555,14 +3555,40 @@ Read the team config to discover your teammates' names. Check the task list peri
|
|||||||
// be gated, but this pattern can — same approach as teammate_mailbox above.
|
// be gated, but this pattern can — same approach as teammate_mailbox above.
|
||||||
if (feature('EXPERIMENTAL_SKILL_SEARCH')) {
|
if (feature('EXPERIMENTAL_SKILL_SEARCH')) {
|
||||||
if (attachment.type === 'skill_discovery') {
|
if (attachment.type === 'skill_discovery') {
|
||||||
if (attachment.skills.length === 0) return []
|
if (attachment.skills.length === 0 && !attachment.gap) return []
|
||||||
const lines = attachment.skills.map(s => `- ${s.name}: ${s.description}`)
|
const loaded = attachment.skills.filter(s => s.autoLoaded && s.content)
|
||||||
|
const recommended = attachment.skills.filter(s => !s.autoLoaded)
|
||||||
|
const loadedSections = loaded.map(
|
||||||
|
s =>
|
||||||
|
`<${COMMAND_NAME_TAG}>${s.name}</${COMMAND_NAME_TAG}>\n` +
|
||||||
|
`<loaded-skill name="${s.name}" path="${s.path ?? ''}">\n${s.content}\n</loaded-skill>`,
|
||||||
|
)
|
||||||
|
const recommendationLines = recommended.map(
|
||||||
|
s => `- ${s.name}: ${s.description}`,
|
||||||
|
)
|
||||||
|
const gapText = attachment.gap
|
||||||
|
? [
|
||||||
|
'No high-confidence active skill was auto-loaded for this request.',
|
||||||
|
attachment.gap.activePath
|
||||||
|
? `A learned skill was promoted for future turns: ${attachment.gap.activeName} (${attachment.gap.activePath}).`
|
||||||
|
: attachment.gap.draftPath
|
||||||
|
? `A draft learned skill candidate was created: ${attachment.gap.draftName} (${attachment.gap.draftPath}).`
|
||||||
|
: `The skill gap was recorded for future learning: ${attachment.gap.key}.`,
|
||||||
|
].join('\n')
|
||||||
|
: ''
|
||||||
return wrapMessagesInSystemReminder([
|
return wrapMessagesInSystemReminder([
|
||||||
createUserMessage({
|
createUserMessage({
|
||||||
content:
|
content: [
|
||||||
`Skills relevant to your task:\n\n${lines.join('\n')}\n\n` +
|
loadedSections.length > 0
|
||||||
`These skills encode project-specific conventions. ` +
|
? `The following skills are auto-loaded for this task. Apply their instructions now; do not call Skill("<name>") again for these loaded skills.\n\n${loadedSections.join('\n\n')}`
|
||||||
`Invoke via Skill("<name>") for complete instructions.`,
|
: '',
|
||||||
|
recommendationLines.length > 0
|
||||||
|
? `Additional relevant skills were found but not auto-loaded:\n\n${recommendationLines.join('\n')}\n\nInvoke via Skill("<name>") only if you need their complete instructions.`
|
||||||
|
: '',
|
||||||
|
gapText,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n\n'),
|
||||||
isMeta: true,
|
isMeta: true,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
@@ -3570,7 +3596,6 @@ Read the team config to discover your teammates' names. Check the task list peri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- teammate_mailbox/team_context/skill_discovery/bagel_console handled above
|
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- teammate_mailbox/team_context/skill_discovery/bagel_console handled above
|
||||||
// biome-ignore lint/nursery/useExhaustiveSwitchCases: teammate_mailbox/team_context/max_turns_reached/skill_discovery/bagel_console handled above, can't add case for dead code elimination
|
|
||||||
switch (attachment.type) {
|
switch (attachment.type) {
|
||||||
case 'directory': {
|
case 'directory': {
|
||||||
return wrapMessagesInSystemReminder([
|
return wrapMessagesInSystemReminder([
|
||||||
|
|||||||
@@ -11,14 +11,7 @@ export function prewarmModifiers(): void {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
prewarmed = true
|
prewarmed = true
|
||||||
// Load module in background
|
void import('modifiers-napi').then(({ prewarm }) => prewarm()).catch(() => {})
|
||||||
try {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const { prewarm } = require('modifiers-napi') as { prewarm: () => void }
|
|
||||||
prewarm()
|
|
||||||
} catch {
|
|
||||||
// Ignore errors during prewarm
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,9 +21,12 @@ export function isModifierPressed(modifier: ModifierKey): boolean {
|
|||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Dynamic import to avoid loading native module at top level
|
try {
|
||||||
const { isModifierPressed: nativeIsModifierPressed } =
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
const { isModifierPressed: nativeIsModifierPressed } =
|
||||||
require('modifiers-napi') as { isModifierPressed: (m: string) => boolean }
|
require('modifiers-napi') as { isModifierPressed: (m: string) => boolean }
|
||||||
return nativeIsModifierPressed(modifier)
|
return nativeIsModifierPressed(modifier)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -799,6 +799,10 @@ export function initialPermissionModeFromCLI({
|
|||||||
result = { mode: 'default', notification }
|
result = { mode: 'default', notification }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
result = { mode: 'default', notification }
|
||||||
|
}
|
||||||
|
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') {
|
if (feature('TRANSCRIPT_CLASSIFIER') && result.mode === 'auto') {
|
||||||
autoModeStateModule?.setAutoModeActive(true)
|
autoModeStateModule?.setAutoModeActive(true)
|
||||||
}
|
}
|
||||||
@@ -923,7 +927,6 @@ export async function initializeToolPermissionContext({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bypass permissions mode is available to all users
|
|
||||||
const isBypassPermissionsModeAvailable = true
|
const isBypassPermissionsModeAvailable = true
|
||||||
const settings = getSettings_DEPRECATED() || {}
|
const settings = getSettings_DEPRECATED() || {}
|
||||||
|
|
||||||
@@ -1061,54 +1064,131 @@ export function getAutoModeUnavailableNotification(
|
|||||||
* kicking the user out of a mode they've already left during the await.
|
* kicking the user out of a mode they've already left during the await.
|
||||||
*/
|
*/
|
||||||
export async function verifyAutoModeGateAccess(
|
export async function verifyAutoModeGateAccess(
|
||||||
_currentContext: ToolPermissionContext,
|
currentContext: ToolPermissionContext,
|
||||||
// Runtime AppState.fastMode — passed from callers with AppState access so
|
// Runtime AppState.fastMode — passed from callers with AppState access so
|
||||||
// the disableFastMode circuit breaker reads current state, not stale
|
// the disableFastMode circuit breaker reads current state, not stale
|
||||||
// settings.fastMode (which is intentionally sticky across /model auto-
|
// settings.fastMode (which is intentionally sticky across /model auto-
|
||||||
// downgrades). Optional for callers without AppState (e.g. SDK init paths).
|
// downgrades). Optional for callers without AppState (e.g. SDK init paths).
|
||||||
fastMode?: boolean,
|
fastMode?: boolean,
|
||||||
): Promise<AutoModeGateCheckResult> {
|
): Promise<AutoModeGateCheckResult> {
|
||||||
// Only fast-mode circuit breaker remains. All other gates (GrowthBook,
|
// Auto-mode config — runs in ALL builds (circuit breaker, carousel, kick-out)
|
||||||
// settings, model support, opt-in) have been removed.
|
// Fresh read of tengu_auto_mode_config.enabled — this async check runs once
|
||||||
|
// after GrowthBook initialization and is the authoritative source for
|
||||||
|
// isAutoModeAvailable. The sync startup path uses stale cache; this
|
||||||
|
// corrects it. Circuit breaker (enabled==='disabled') takes effect here.
|
||||||
const autoModeConfig = await getDynamicConfig_BLOCKS_ON_INIT<{
|
const autoModeConfig = await getDynamicConfig_BLOCKS_ON_INIT<{
|
||||||
enabled?: AutoModeEnabledState
|
enabled?: AutoModeEnabledState
|
||||||
disableFastMode?: boolean
|
disableFastMode?: boolean
|
||||||
}>('tengu_auto_mode_config', {})
|
}>('tengu_auto_mode_config', {})
|
||||||
|
const enabledState = parseAutoModeEnabledState(autoModeConfig?.enabled)
|
||||||
|
const disabledBySettings = isAutoModeDisabledBySettings()
|
||||||
|
// Treat settings-disable the same as GrowthBook 'disabled' for circuit-breaker
|
||||||
|
// semantics — blocks SDK/explicit re-entry via isAutoModeGateEnabled().
|
||||||
|
autoModeStateModule?.setAutoModeCircuitBroken(
|
||||||
|
enabledState === 'disabled' || disabledBySettings,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Carousel availability: not circuit-broken, not disabled-by-settings,
|
||||||
|
// model supports it, disableFastMode breaker not firing, and (enabled or opted-in)
|
||||||
const mainModel = getMainLoopModel()
|
const mainModel = getMainLoopModel()
|
||||||
|
// Temp circuit breaker: tengu_auto_mode_config.disableFastMode blocks auto
|
||||||
|
// mode when fast mode is on. Checks runtime AppState.fastMode (if provided)
|
||||||
|
// and, for ants, model name '-fast' substring (ant-internal fast models
|
||||||
|
// like capybara-v2-fast[1m] encode speed in the model ID itself).
|
||||||
|
// Remove once auto+fast mode interaction is validated.
|
||||||
const disableFastModeBreakerFires =
|
const disableFastModeBreakerFires =
|
||||||
!!autoModeConfig?.disableFastMode &&
|
!!autoModeConfig?.disableFastMode &&
|
||||||
(!!fastMode ||
|
(!!fastMode ||
|
||||||
(process.env.USER_TYPE === 'ant' &&
|
(process.env.USER_TYPE === 'ant' &&
|
||||||
mainModel.toLowerCase().includes('-fast')))
|
mainModel.toLowerCase().includes('-fast')))
|
||||||
|
const modelSupported =
|
||||||
// If fast-mode breaker fires, circuit-break auto mode
|
modelSupportsAutoMode(mainModel) && !disableFastModeBreakerFires
|
||||||
autoModeStateModule?.setAutoModeCircuitBroken(disableFastModeBreakerFires)
|
let carouselAvailable = false
|
||||||
|
if (enabledState !== 'disabled' && !disabledBySettings && modelSupported) {
|
||||||
|
carouselAvailable =
|
||||||
|
enabledState === 'enabled' || hasAutoModeOptInAnySource()
|
||||||
|
}
|
||||||
|
// canEnterAuto gates explicit entry (--permission-mode auto, defaultMode: auto)
|
||||||
|
// — explicit entry IS an opt-in, so we only block on circuit breaker + settings + model
|
||||||
|
const canEnterAuto =
|
||||||
|
enabledState !== 'disabled' && !disabledBySettings && modelSupported
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[auto-mode] verifyAutoModeGateAccess: disableFastModeBreakerFires=${disableFastModeBreakerFires}`,
|
`[auto-mode] verifyAutoModeGateAccess: enabledState=${enabledState} disabledBySettings=${disabledBySettings} model=${mainModel} modelSupported=${modelSupported} disableFastModeBreakerFires=${disableFastModeBreakerFires} carouselAvailable=${carouselAvailable} canEnterAuto=${canEnterAuto}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!disableFastModeBreakerFires) {
|
// Capture CLI-flag intent now (doesn't depend on context).
|
||||||
// Auto mode available — no kick-out needed
|
const autoModeFlagCli = autoModeStateModule?.getAutoModeFlagCli() ?? false
|
||||||
return { updateContext: ctx => ctx }
|
|
||||||
|
// Return a transform function that re-evaluates context-dependent conditions
|
||||||
|
// against the CURRENT context at setAppState time. The async GrowthBook
|
||||||
|
// results above (canEnterAuto, carouselAvailable, enabledState, reason) are
|
||||||
|
// closure-captured — those don't depend on context. But mode, prePlanMode,
|
||||||
|
// and isAutoModeAvailable checks MUST use the fresh ctx or a mid-await
|
||||||
|
// shift-tab gets reverted (or worse, the user stays in auto despite the
|
||||||
|
// circuit breaker if they entered auto DURING the await — which is possible
|
||||||
|
// because setAutoModeCircuitBroken above runs AFTER the await).
|
||||||
|
const setAvailable = (
|
||||||
|
ctx: ToolPermissionContext,
|
||||||
|
available: boolean,
|
||||||
|
): ToolPermissionContext => {
|
||||||
|
if (ctx.isAutoModeAvailable !== available) {
|
||||||
|
logForDebugging(
|
||||||
|
`[auto-mode] verifyAutoModeGateAccess setAvailable: ${ctx.isAutoModeAvailable} -> ${available}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ctx.isAutoModeAvailable === available
|
||||||
|
? ctx
|
||||||
|
: { ...ctx, isAutoModeAvailable: available }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast-mode breaker fired — kick out of auto if currently in it
|
if (canEnterAuto) {
|
||||||
const notification = getAutoModeUnavailableNotification('circuit-breaker')
|
return { updateContext: ctx => setAvailable(ctx, carouselAvailable) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate is off or circuit-broken — determine reason (context-independent).
|
||||||
|
let reason: AutoModeUnavailableReason
|
||||||
|
if (disabledBySettings) {
|
||||||
|
reason = 'settings'
|
||||||
|
logForDebugging('auto mode disabled: disableAutoMode in settings', {
|
||||||
|
level: 'warn',
|
||||||
|
})
|
||||||
|
} else if (enabledState === 'disabled') {
|
||||||
|
reason = 'circuit-breaker'
|
||||||
|
logForDebugging(
|
||||||
|
'auto mode disabled: tengu_auto_mode_config.enabled === "disabled" (circuit breaker)',
|
||||||
|
{ level: 'warn' },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
reason = 'model'
|
||||||
|
logForDebugging(
|
||||||
|
`auto mode disabled: model ${getMainLoopModel()} does not support auto mode`,
|
||||||
|
{ level: 'warn' },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const notification = getAutoModeUnavailableNotification(reason)
|
||||||
|
|
||||||
|
// Unified kick-out transform. Re-checks the FRESH ctx and only fires
|
||||||
|
// side effects (setAutoModeActive(false), setNeedsAutoModeExitAttachment)
|
||||||
|
// when the kick-out actually applies. This keeps autoModeActive in sync
|
||||||
|
// with toolPermissionContext.mode even if the user changed modes during
|
||||||
|
// the await: if they already left auto on their own, handleCycleMode
|
||||||
|
// already deactivated the classifier and we don't fire again; if they
|
||||||
|
// ENTERED auto during the await (possible before setAutoModeCircuitBroken
|
||||||
|
// landed), we kick them out here.
|
||||||
const kickOutOfAutoIfNeeded = (
|
const kickOutOfAutoIfNeeded = (
|
||||||
ctx: ToolPermissionContext,
|
ctx: ToolPermissionContext,
|
||||||
): ToolPermissionContext => {
|
): ToolPermissionContext => {
|
||||||
const inAuto = ctx.mode === 'auto'
|
const inAuto = ctx.mode === 'auto'
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[auto-mode] kickOutOfAutoIfNeeded (fast-mode): ctx.mode=${ctx.mode}`,
|
`[auto-mode] kickOutOfAutoIfNeeded applying: ctx.mode=${ctx.mode} ctx.prePlanMode=${ctx.prePlanMode} reason=${reason}`,
|
||||||
)
|
)
|
||||||
|
// Plan mode with auto active: either from prePlanMode='auto' (entered
|
||||||
|
// from auto) or from opt-in (strippedDangerousRules present).
|
||||||
const inPlanWithAutoActive =
|
const inPlanWithAutoActive =
|
||||||
ctx.mode === 'plan' &&
|
ctx.mode === 'plan' &&
|
||||||
(ctx.prePlanMode === 'auto' || !!ctx.strippedDangerousRules)
|
(ctx.prePlanMode === 'auto' || !!ctx.strippedDangerousRules)
|
||||||
if (!inAuto && !inPlanWithAutoActive) {
|
if (!inAuto && !inPlanWithAutoActive) {
|
||||||
return { ...ctx, isAutoModeAvailable: false }
|
return setAvailable(ctx, false)
|
||||||
}
|
}
|
||||||
if (inAuto) {
|
if (inAuto) {
|
||||||
autoModeStateModule?.setAutoModeActive(false)
|
autoModeStateModule?.setAutoModeActive(false)
|
||||||
@@ -1122,6 +1202,8 @@ export async function verifyAutoModeGateAccess(
|
|||||||
isAutoModeAvailable: false,
|
isAutoModeAvailable: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Plan with auto active: deactivate auto, restore permissions, defuse
|
||||||
|
// prePlanMode so ExitPlanMode goes to default.
|
||||||
autoModeStateModule?.setAutoModeActive(false)
|
autoModeStateModule?.setAutoModeActive(false)
|
||||||
setNeedsAutoModeExitAttachment(true)
|
setNeedsAutoModeExitAttachment(true)
|
||||||
return {
|
return {
|
||||||
@@ -1131,23 +1213,62 @@ export async function verifyAutoModeGateAccess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notification decisions use the stale context — that's OK: we're deciding
|
||||||
|
// WHETHER to notify based on what the user WAS doing when this check started.
|
||||||
|
// (Side effects and mode mutation are decided inside the transform above,
|
||||||
|
// against the fresh ctx.)
|
||||||
|
const wasInAuto = currentContext.mode === 'auto'
|
||||||
|
// Auto was used during plan: entered from auto or opt-in auto active
|
||||||
|
const autoActiveDuringPlan =
|
||||||
|
currentContext.mode === 'plan' &&
|
||||||
|
(currentContext.prePlanMode === 'auto' ||
|
||||||
|
!!currentContext.strippedDangerousRules)
|
||||||
|
const wantedAuto = wasInAuto || autoActiveDuringPlan || autoModeFlagCli
|
||||||
|
|
||||||
|
if (!wantedAuto) {
|
||||||
|
// User didn't want auto at call time — no notification. But still apply
|
||||||
|
// the full kick-out transform: if they shift-tabbed INTO auto during the
|
||||||
|
// await (before setAutoModeCircuitBroken landed), we need to evict them.
|
||||||
|
return { updateContext: kickOutOfAutoIfNeeded }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasInAuto || autoActiveDuringPlan) {
|
||||||
|
// User was in auto or had auto active during plan — kick out + notify.
|
||||||
return { updateContext: kickOutOfAutoIfNeeded, notification }
|
return { updateContext: kickOutOfAutoIfNeeded, notification }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// autoModeFlagCli only: defaultMode was auto but sync check rejected it.
|
||||||
* Bypass permissions is always available — no remote gate check needed.
|
// Suppress notification if isAutoModeAvailable is already false (already
|
||||||
*/
|
// notified on a prior check; prevents repeat notifications on successive
|
||||||
export function shouldDisableBypassPermissions(): Promise<boolean> {
|
// unsupported-model switches).
|
||||||
return Promise.resolve(false)
|
return {
|
||||||
|
updateContext: kickOutOfAutoIfNeeded,
|
||||||
|
notification: currentContext.isAutoModeAvailable ? notification : undefined,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if auto mode can be entered: only fast-mode circuit breaker remains.
|
* Core logic to check if bypassPermissions should be disabled based on Statsig gate
|
||||||
* Synchronous.
|
*/
|
||||||
|
export function shouldDisableBypassPermissions(): Promise<boolean> {
|
||||||
|
return checkSecurityRestrictionGate('tengu_disable_bypass_permissions_mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAutoModeDisabledBySettings(): boolean {
|
||||||
|
const settings = getSettings_DEPRECATED() || {}
|
||||||
|
return (
|
||||||
|
(settings as { disableAutoMode?: 'disable' }).disableAutoMode ===
|
||||||
|
'disable' ||
|
||||||
|
(settings.permissions as { disableAutoMode?: 'disable' } | undefined)
|
||||||
|
?.disableAutoMode === 'disable'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if auto mode can be entered: circuit breaker is not active and settings
|
||||||
|
* have not disabled it. Synchronous.
|
||||||
*/
|
*/
|
||||||
export function isAutoModeGateEnabled(): boolean {
|
export function isAutoModeGateEnabled(): boolean {
|
||||||
// Auto mode is available to all users — only fast-mode circuit breaker remains
|
|
||||||
if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) return false
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1156,9 +1277,11 @@ export function isAutoModeGateEnabled(): boolean {
|
|||||||
* Synchronous — uses state populated by verifyAutoModeGateAccess.
|
* Synchronous — uses state populated by verifyAutoModeGateAccess.
|
||||||
*/
|
*/
|
||||||
export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null {
|
export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null {
|
||||||
|
if (isAutoModeDisabledBySettings()) return 'settings'
|
||||||
if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) {
|
if (autoModeStateModule?.isAutoModeCircuitBroken() ?? false) {
|
||||||
return 'circuit-breaker'
|
return 'circuit-breaker'
|
||||||
}
|
}
|
||||||
|
if (!modelSupportsAutoMode(getMainLoopModel())) return 'model'
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1172,7 +1295,11 @@ export function getAutoModeUnavailableReason(): AutoModeUnavailableReason | null
|
|||||||
*/
|
*/
|
||||||
export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in'
|
export type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in'
|
||||||
|
|
||||||
const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = 'enabled'
|
const AUTO_MODE_ENABLED_DEFAULT: AutoModeEnabledState = feature(
|
||||||
|
'TRANSCRIPT_CLASSIFIER',
|
||||||
|
)
|
||||||
|
? 'enabled'
|
||||||
|
: 'disabled'
|
||||||
|
|
||||||
function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState {
|
function parseAutoModeEnabledState(value: unknown): AutoModeEnabledState {
|
||||||
if (value === 'enabled' || value === 'disabled' || value === 'opt-in') {
|
if (value === 'enabled' || value === 'disabled' || value === 'opt-in') {
|
||||||
@@ -1222,15 +1349,27 @@ export function getAutoModeEnabledStateIfCached():
|
|||||||
* dialog or by IDE/Desktop settings toggle)
|
* dialog or by IDE/Desktop settings toggle)
|
||||||
*/
|
*/
|
||||||
export function hasAutoModeOptInAnySource(): boolean {
|
export function hasAutoModeOptInAnySource(): boolean {
|
||||||
return true
|
if (autoModeStateModule?.getAutoModeFlagCli() ?? false) return true
|
||||||
|
return hasAutoModeOptIn()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if bypassPermissions mode is currently disabled by Statsig gate or settings.
|
* Checks if bypassPermissions mode is currently disabled by Statsig gate or settings.
|
||||||
* Always returns false — bypass is available to all users.
|
* This is a synchronous version that uses cached Statsig values.
|
||||||
*/
|
*/
|
||||||
export function isBypassPermissionsModeDisabled(): boolean {
|
export function isBypassPermissionsModeDisabled(): boolean {
|
||||||
return false
|
const growthBookDisableBypassPermissionsMode =
|
||||||
|
checkStatsigFeatureGate_CACHED_MAY_BE_STALE(
|
||||||
|
'tengu_disable_bypass_permissions_mode',
|
||||||
|
)
|
||||||
|
const settings = getSettings_DEPRECATED() || {}
|
||||||
|
const settingsDisableBypassPermissionsMode =
|
||||||
|
settings.permissions?.disableBypassPermissionsMode === 'disable'
|
||||||
|
|
||||||
|
return (
|
||||||
|
growthBookDisableBypassPermissionsMode ||
|
||||||
|
settingsDisableBypassPermissionsMode
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1255,12 +1394,29 @@ export function createDisabledBypassPermissionsContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No-op — bypass permissions is always available, no remote gate check needed.
|
* Asynchronously checks if the bypassPermissions mode should be disabled based on Statsig gate
|
||||||
|
* and returns an updated toolPermissionContext if needed
|
||||||
*/
|
*/
|
||||||
export async function checkAndDisableBypassPermissions(
|
export async function checkAndDisableBypassPermissions(
|
||||||
_currentContext: ToolPermissionContext,
|
currentContext: ToolPermissionContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Bypass permissions is always available — no gate check needed
|
// Only proceed if bypassPermissions mode is available
|
||||||
|
if (!currentContext.isBypassPermissionsModeAvailable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldDisable = await shouldDisableBypassPermissions()
|
||||||
|
if (!shouldDisable) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate is enabled, need to disable bypassPermissions mode
|
||||||
|
logForDebugging(
|
||||||
|
'bypassPermissions mode is being disabled by Statsig gate (async check)',
|
||||||
|
{ level: 'warn' },
|
||||||
|
)
|
||||||
|
|
||||||
|
void gracefulShutdown(1, 'bypass_permissions_disabled')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDefaultPermissionModeAuto(): boolean {
|
export function isDefaultPermissionModeAuto(): boolean {
|
||||||
@@ -1278,7 +1434,11 @@ export function isDefaultPermissionModeAuto(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function shouldPlanUseAutoMode(): boolean {
|
export function shouldPlanUseAutoMode(): boolean {
|
||||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||||
return isAutoModeGateEnabled() && getUseAutoModeDuringPlan()
|
return (
|
||||||
|
hasAutoModeOptIn() &&
|
||||||
|
isAutoModeGateEnabled() &&
|
||||||
|
getUseAutoModeDuringPlan()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
32
src/utils/pipeStatus.ts
Normal file
32
src/utils/pipeStatus.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { PipeRegistry } from './pipeRegistry.js'
|
||||||
|
import { readRegistry } from './pipeRegistry.js'
|
||||||
|
|
||||||
|
export async function formatPipeRegistryStatus(): Promise<string> {
|
||||||
|
return formatPipeRegistry(await readRegistry())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPipeRegistry(registry: PipeRegistry): string {
|
||||||
|
const lines = [
|
||||||
|
`Pipe registry: ${registry.main ? 1 : 0} main, ${registry.subs.length} sub(s)`,
|
||||||
|
]
|
||||||
|
if (registry.mainMachineId) {
|
||||||
|
lines.push(` main_machine=${registry.mainMachineId.slice(0, 8)}...`)
|
||||||
|
}
|
||||||
|
if (registry.main) {
|
||||||
|
lines.push(
|
||||||
|
` [main] ${registry.main.pipeName} pid=${registry.main.pid} host=${registry.main.hostname} tcp=${registry.main.tcpPort ?? 'none'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const sub of registry.subs.slice(0, 10)) {
|
||||||
|
lines.push(
|
||||||
|
` [sub-${sub.subIndex}] ${sub.pipeName} pid=${sub.pid} host=${sub.hostname} bound=${sub.boundToMain ?? 'none'} tcp=${sub.tcpPort ?? 'none'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!registry.main && registry.subs.length === 0) {
|
||||||
|
lines.push(' none')
|
||||||
|
}
|
||||||
|
if (registry.subs.length > 10) {
|
||||||
|
lines.push(` ... ${registry.subs.length - 10} more sub pipe(s)`)
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
@@ -645,6 +645,7 @@ const PluginManifestUserConfigSchema = lazySchema(() =>
|
|||||||
.describe(
|
.describe(
|
||||||
'User-configurable values this plugin needs. Prompted at enable time. ' +
|
'User-configurable values this plugin needs. Prompted at enable time. ' +
|
||||||
'Non-sensitive values saved to settings.json; sensitive values to secure storage ' +
|
'Non-sensitive values saved to settings.json; sensitive values to secure storage ' +
|
||||||
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: ${user_config.KEY} is plugin config syntax documentation, not a JS template literal
|
||||||
'(macOS keychain or .credentials.json). Available as ${user_config.KEY} in ' +
|
'(macOS keychain or .credentials.json). Available as ${user_config.KEY} in ' +
|
||||||
'MCP/LSP server config, hook commands, and (non-sensitive only) skill/agent content. ' +
|
'MCP/LSP server config, hook commands, and (non-sensitive only) skill/agent content. ' +
|
||||||
'Note: sensitive values share a single keychain entry with OAuth tokens — keep ' +
|
'Note: sensitive values share a single keychain entry with OAuth tokens — keep ' +
|
||||||
@@ -690,6 +691,7 @@ const PluginManifestChannelsSchema = lazySchema(() =>
|
|||||||
.optional()
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
'Fields to prompt the user for when enabling this plugin in assistant mode. ' +
|
'Fields to prompt the user for when enabling this plugin in assistant mode. ' +
|
||||||
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: ${user_config.KEY} is plugin config syntax documentation, not a JS template literal
|
||||||
'Saved values are substituted into ${user_config.KEY} references in the mcpServers env.',
|
'Saved values are substituted into ${user_config.KEY} references in the mcpServers env.',
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1702,6 +1702,7 @@ export function getPipelineSegments(
|
|||||||
*/
|
*/
|
||||||
export function isNullRedirectionTarget(target: string): boolean {
|
export function isNullRedirectionTarget(target: string): boolean {
|
||||||
const t = target.trim().toLowerCase()
|
const t = target.trim().toLowerCase()
|
||||||
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: ${null} is PowerShell syntax, not a JS template literal
|
||||||
return t === '$null' || t === '${null}'
|
return t === '$null' || t === '${null}'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export function writeToStderr(data: string): void {
|
|||||||
// Write error to stderr and exit with code 1. Consolidates the
|
// Write error to stderr and exit with code 1. Consolidates the
|
||||||
// console.error + process.exit(1) pattern used in entrypoint fast-paths.
|
// console.error + process.exit(1) pattern used in entrypoint fast-paths.
|
||||||
export function exitWithError(message: string): never {
|
export function exitWithError(message: string): never {
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.error(message)
|
console.error(message)
|
||||||
// eslint-disable-next-line custom-rules/no-process-exit
|
// eslint-disable-next-line custom-rules/no-process-exit
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ function recollapsePastedContent(
|
|||||||
// Find pasted content in the edited text and re-collapse it
|
// Find pasted content in the edited text and re-collapse it
|
||||||
for (const [id, content] of Object.entries(pastedContents)) {
|
for (const [id, content] of Object.entries(pastedContents)) {
|
||||||
if (content.type === 'text') {
|
if (content.type === 'text') {
|
||||||
const pasteId = parseInt(id)
|
const pasteId = parseInt(id, 10)
|
||||||
const contentStr = content.content
|
const contentStr = content.content
|
||||||
|
|
||||||
// Check if this exact content exists in the edited prompt
|
// Check if this exact content exists in the edited prompt
|
||||||
|
|||||||
23
src/utils/remoteControlStatus.ts
Normal file
23
src/utils/remoteControlStatus.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
getBridgeAccessToken,
|
||||||
|
getBridgeBaseUrl,
|
||||||
|
isSelfHostedBridge,
|
||||||
|
} from '../bridge/bridgeConfig.js'
|
||||||
|
|
||||||
|
export function formatRemoteControlLocalStatus(): string {
|
||||||
|
try {
|
||||||
|
const selfHosted = isSelfHostedBridge()
|
||||||
|
const token = getBridgeAccessToken()
|
||||||
|
return [
|
||||||
|
`Remote Control: ${selfHosted ? 'self-hosted' : 'official'}`,
|
||||||
|
` base_url=${getBridgeBaseUrl()}`,
|
||||||
|
` token=${token ? 'present' : 'missing'}`,
|
||||||
|
' entitlement=checked at remote-control startup',
|
||||||
|
].join('\n')
|
||||||
|
} catch (error) {
|
||||||
|
return [
|
||||||
|
'Remote Control: unknown',
|
||||||
|
` reason=${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/utils/remoteTriggerAudit.ts
Normal file
91
src/utils/remoteTriggerAudit.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
import { mkdir, readFile, appendFile } from 'fs/promises'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { getProjectRoot } from '../bootstrap/state.js'
|
||||||
|
|
||||||
|
const REMOTE_TRIGGER_AUDIT_REL = join('.claude', 'remote-trigger-audit.jsonl')
|
||||||
|
const MAX_AUDIT_RECORDS = 200
|
||||||
|
|
||||||
|
export type RemoteTriggerAuditRecord = {
|
||||||
|
auditId: string
|
||||||
|
action: string
|
||||||
|
triggerId?: string
|
||||||
|
ok: boolean
|
||||||
|
status?: number
|
||||||
|
error?: string
|
||||||
|
createdAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRemoteTriggerAuditPath(
|
||||||
|
rootDir: string = getProjectRoot(),
|
||||||
|
): string {
|
||||||
|
return join(rootDir, REMOTE_TRIGGER_AUDIT_REL)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendRemoteTriggerAuditRecord(
|
||||||
|
record: Omit<RemoteTriggerAuditRecord, 'auditId' | 'createdAt'> & {
|
||||||
|
auditId?: string
|
||||||
|
createdAt?: number
|
||||||
|
},
|
||||||
|
rootDir: string = getProjectRoot(),
|
||||||
|
): Promise<RemoteTriggerAuditRecord> {
|
||||||
|
const fullRecord: RemoteTriggerAuditRecord = {
|
||||||
|
auditId: record.auditId ?? randomUUID(),
|
||||||
|
action: record.action,
|
||||||
|
...(record.triggerId ? { triggerId: record.triggerId } : {}),
|
||||||
|
ok: record.ok,
|
||||||
|
...(record.status !== undefined ? { status: record.status } : {}),
|
||||||
|
...(record.error ? { error: record.error } : {}),
|
||||||
|
createdAt: record.createdAt ?? Date.now(),
|
||||||
|
}
|
||||||
|
const path = resolveRemoteTriggerAuditPath(rootDir)
|
||||||
|
await mkdir(dirname(path), { recursive: true })
|
||||||
|
await appendFile(path, `${JSON.stringify(fullRecord)}\n`, 'utf-8')
|
||||||
|
return fullRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRemoteTriggerAuditRecords(
|
||||||
|
rootDir: string = getProjectRoot(),
|
||||||
|
): Promise<RemoteTriggerAuditRecord[]> {
|
||||||
|
let raw: string
|
||||||
|
try {
|
||||||
|
raw = await readFile(resolveRemoteTriggerAuditPath(rootDir), 'utf-8')
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const records: RemoteTriggerAuditRecord[] = []
|
||||||
|
for (const line of raw.split('\n')) {
|
||||||
|
if (!line.trim()) continue
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line) as Partial<RemoteTriggerAuditRecord>
|
||||||
|
if (
|
||||||
|
parsed &&
|
||||||
|
typeof parsed.auditId === 'string' &&
|
||||||
|
typeof parsed.action === 'string' &&
|
||||||
|
typeof parsed.ok === 'boolean' &&
|
||||||
|
typeof parsed.createdAt === 'number'
|
||||||
|
) {
|
||||||
|
records.push(parsed as RemoteTriggerAuditRecord)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore malformed historical lines.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
.sort((a, b) => b.createdAt - a.createdAt)
|
||||||
|
.slice(0, MAX_AUDIT_RECORDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRemoteTriggerAuditStatus(
|
||||||
|
records: RemoteTriggerAuditRecord[],
|
||||||
|
): string {
|
||||||
|
const failures = records.filter(r => !r.ok)
|
||||||
|
const latest = records[0]
|
||||||
|
return [
|
||||||
|
`RemoteTrigger audit records: ${records.length}`,
|
||||||
|
`Failures: ${failures.length}`,
|
||||||
|
latest
|
||||||
|
? `Latest: ${latest.action}${latest.triggerId ? ` ${latest.triggerId}` : ''} ${latest.ok ? 'ok' : 'failed'} (${new Date(latest.createdAt).toLocaleString()})`
|
||||||
|
: 'Latest: none',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
@@ -52,7 +52,6 @@ function spawnSecurity(serviceName: string): Promise<SpawnResult> {
|
|||||||
// Exit 44 (entry not found) is a valid "no key" result and safe to
|
// Exit 44 (entry not found) is a valid "no key" result and safe to
|
||||||
// prime as null. But timeout (err.killed) means the keychain MAY have
|
// prime as null. But timeout (err.killed) means the keychain MAY have
|
||||||
// a key we couldn't fetch — don't prime, let sync spawn retry.
|
// a key we couldn't fetch — don't prime, let sync spawn retry.
|
||||||
// biome-ignore lint/nursery/noFloatingPromises: resolve() is not a floating promise
|
|
||||||
resolve({
|
resolve({
|
||||||
stdout: err ? null : stdout?.trim() || null,
|
stdout: err ? null : stdout?.trim() || null,
|
||||||
timedOut: Boolean(err && 'killed' in err && err.killed),
|
timedOut: Boolean(err && 'killed' in err && err.killed),
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ function execFilePromise(
|
|||||||
args,
|
args,
|
||||||
{ encoding: 'utf-8', timeout: MDM_SUBPROCESS_TIMEOUT_MS },
|
{ encoding: 'utf-8', timeout: MDM_SUBPROCESS_TIMEOUT_MS },
|
||||||
(err, stdout) => {
|
(err, stdout) => {
|
||||||
// biome-ignore lint/nursery/noFloatingPromises: resolve() is not a floating promise
|
|
||||||
resolve({ stdout: stdout ?? '', code: err ? 1 : 0 })
|
resolve({ stdout: stdout ?? '', code: err ? 1 : 0 })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -710,8 +710,8 @@ export const SettingsSchema = lazySchema(() =>
|
|||||||
effortLevel: z
|
effortLevel: z
|
||||||
.enum(
|
.enum(
|
||||||
process.env.USER_TYPE === 'ant'
|
process.env.USER_TYPE === 'ant'
|
||||||
? ['low', 'medium', 'high', 'max']
|
? ['low', 'medium', 'high', 'xhigh', 'max']
|
||||||
: ['low', 'medium', 'high'],
|
: ['low', 'medium', 'high', 'xhigh'],
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.catch(undefined)
|
.catch(undefined)
|
||||||
|
|||||||
@@ -203,7 +203,6 @@ async function getCommandPrefixImpl(
|
|||||||
if (nonInteractive) {
|
if (nonInteractive) {
|
||||||
process.stderr.write(jsonStringify({ level: 'warn', message }) + '\n')
|
process.stderr.write(jsonStringify({ level: 'warn', message }) + '\n')
|
||||||
} else {
|
} else {
|
||||||
// biome-ignore lint/suspicious/noConsole: intentional warning
|
|
||||||
console.warn(chalk.yellow(`⚠️ ${message}`))
|
console.warn(chalk.yellow(`⚠️ ${message}`))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
fsyncSync,
|
fsyncSync,
|
||||||
openSync,
|
openSync,
|
||||||
} from 'fs'
|
} from 'fs'
|
||||||
// biome-ignore lint: This file IS the cloneDeep wrapper - it must import the original
|
|
||||||
import lodashCloneDeep from 'lodash-es/cloneDeep.js'
|
import lodashCloneDeep from 'lodash-es/cloneDeep.js'
|
||||||
import { addSlowOperation } from '../bootstrap/state.js'
|
import { addSlowOperation } from '../bootstrap/state.js'
|
||||||
import { logForDebugging } from './debug.js'
|
import { logForDebugging } from './debug.js'
|
||||||
@@ -132,6 +131,7 @@ function slowLoggingAnt(
|
|||||||
..._values: unknown[]
|
..._values: unknown[]
|
||||||
): AntSlowLogger {
|
): AntSlowLogger {
|
||||||
// eslint-disable-next-line prefer-rest-params
|
// eslint-disable-next-line prefer-rest-params
|
||||||
|
// biome-ignore lint/complexity/noArguments: intentional use of arguments object for AntSlowLogger
|
||||||
return new AntSlowLogger(arguments)
|
return new AntSlowLogger(arguments)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -793,7 +793,7 @@ function processedStatsToClaudeCodeStats(
|
|||||||
hourEntries.length > 0
|
hourEntries.length > 0
|
||||||
? parseInt(
|
? parseInt(
|
||||||
hourEntries.reduce((max, [hour, count]) =>
|
hourEntries.reduce((max, [hour, count]) =>
|
||||||
count > parseInt(max[1].toString()) ? [hour, count] : max,
|
count > parseInt(max[1].toString(), 10) ? [hour, count] : max,
|
||||||
)[0],
|
)[0],
|
||||||
10,
|
10,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* Used by the Teams UI in the footer to show team status.
|
* Used by the Teams UI in the footer to show team status.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { isPaneBackend, type PaneBackendType } from './swarm/backends/types.js'
|
import { type BackendType } from './swarm/backends/types.js'
|
||||||
import { readTeamFile } from './swarm/teamHelpers.js'
|
import { readTeamFile } from './swarm/teamHelpers.js'
|
||||||
|
|
||||||
export type TeamSummary = {
|
export type TeamSummary = {
|
||||||
@@ -28,7 +28,7 @@ export type TeammateStatus = {
|
|||||||
cwd: string
|
cwd: string
|
||||||
worktreePath?: string
|
worktreePath?: string
|
||||||
isHidden?: boolean // Whether the pane is currently hidden from the swarm view
|
isHidden?: boolean // Whether the pane is currently hidden from the swarm view
|
||||||
backendType?: PaneBackendType // The backend type used for this teammate
|
backendType?: BackendType // The backend type used for this teammate
|
||||||
mode?: string // Current permission mode for this teammate
|
mode?: string // Current permission mode for this teammate
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,10 +67,7 @@ export function getTeammateStatuses(teamName: string): TeammateStatus[] {
|
|||||||
cwd: member.cwd,
|
cwd: member.cwd,
|
||||||
worktreePath: member.worktreePath,
|
worktreePath: member.worktreePath,
|
||||||
isHidden: hiddenPaneIds.has(member.tmuxPaneId),
|
isHidden: hiddenPaneIds.has(member.tmuxPaneId),
|
||||||
backendType:
|
backendType: member.backendType,
|
||||||
member.backendType && isPaneBackend(member.backendType)
|
|
||||||
? member.backendType
|
|
||||||
: undefined,
|
|
||||||
mode: member.mode,
|
mode: member.mode,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -262,7 +262,6 @@ export function waitForTeammatesToBecomeIdle(
|
|||||||
const onIdle = (): void => {
|
const onIdle = (): void => {
|
||||||
remaining--
|
remaining--
|
||||||
if (remaining === 0) {
|
if (remaining === 0) {
|
||||||
// biome-ignore lint/nursery/noFloatingPromises: resolve is a callback, not a Promise
|
|
||||||
resolve()
|
resolve()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ async function getOtlpReaders() {
|
|||||||
const exportInterval = parseInt(
|
const exportInterval = parseInt(
|
||||||
process.env.OTEL_METRIC_EXPORT_INTERVAL ||
|
process.env.OTEL_METRIC_EXPORT_INTERVAL ||
|
||||||
DEFAULT_METRICS_EXPORT_INTERVAL_MS.toString(),
|
DEFAULT_METRICS_EXPORT_INTERVAL_MS.toString(),
|
||||||
|
10,
|
||||||
)
|
)
|
||||||
|
|
||||||
const exporters = []
|
const exporters = []
|
||||||
@@ -527,6 +528,7 @@ export async function initializeTelemetry() {
|
|||||||
const shutdownTelemetry = async () => {
|
const shutdownTelemetry = async () => {
|
||||||
const timeoutMs = parseInt(
|
const timeoutMs = parseInt(
|
||||||
process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000',
|
process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000',
|
||||||
|
10,
|
||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
endInteractionSpan()
|
endInteractionSpan()
|
||||||
@@ -589,6 +591,7 @@ export async function initializeTelemetry() {
|
|||||||
scheduledDelayMillis: parseInt(
|
scheduledDelayMillis: parseInt(
|
||||||
process.env.OTEL_LOGS_EXPORT_INTERVAL ||
|
process.env.OTEL_LOGS_EXPORT_INTERVAL ||
|
||||||
DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(),
|
DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(),
|
||||||
|
10,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -635,6 +638,7 @@ export async function initializeTelemetry() {
|
|||||||
scheduledDelayMillis: parseInt(
|
scheduledDelayMillis: parseInt(
|
||||||
process.env.OTEL_TRACES_EXPORT_INTERVAL ||
|
process.env.OTEL_TRACES_EXPORT_INTERVAL ||
|
||||||
DEFAULT_TRACES_EXPORT_INTERVAL_MS.toString(),
|
DEFAULT_TRACES_EXPORT_INTERVAL_MS.toString(),
|
||||||
|
10,
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -654,6 +658,7 @@ export async function initializeTelemetry() {
|
|||||||
const shutdownTelemetry = async () => {
|
const shutdownTelemetry = async () => {
|
||||||
const timeoutMs = parseInt(
|
const timeoutMs = parseInt(
|
||||||
process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000',
|
process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000',
|
||||||
|
10,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -712,6 +717,7 @@ export async function flushTelemetry(): Promise<void> {
|
|||||||
|
|
||||||
const timeoutMs = parseInt(
|
const timeoutMs = parseInt(
|
||||||
process.env.CLAUDE_CODE_OTEL_FLUSH_TIMEOUT_MS || '5000',
|
process.env.CLAUDE_CODE_OTEL_FLUSH_TIMEOUT_MS || '5000',
|
||||||
|
10,
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -118,10 +118,14 @@ export function modelSupportsAdaptiveThinking(model: string): boolean {
|
|||||||
}
|
}
|
||||||
const canonical = getCanonicalName(model)
|
const canonical = getCanonicalName(model)
|
||||||
// Supported by a subset of Claude 4 models
|
// Supported by a subset of Claude 4 models
|
||||||
if (canonical.includes('opus-4-6') || canonical.includes('sonnet-4-6')) {
|
if (
|
||||||
|
canonical.includes('opus-4-7') ||
|
||||||
|
canonical.includes('opus-4-6') ||
|
||||||
|
canonical.includes('sonnet-4-6')
|
||||||
|
) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Exclude any other known legacy models (allowlist above catches 4-6 variants first)
|
// Exclude any other known legacy models (allowlist above catches 4-6+ variants first)
|
||||||
if (
|
if (
|
||||||
canonical.includes('opus') ||
|
canonical.includes('opus') ||
|
||||||
canonical.includes('sonnet') ||
|
canonical.includes('sonnet') ||
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ information. Do not blow your cover.
|
|||||||
|
|
||||||
NEVER include in commit messages or PR descriptions:
|
NEVER include in commit messages or PR descriptions:
|
||||||
- Internal model codenames (animal names like Capybara, Tengu, etc.)
|
- Internal model codenames (animal names like Capybara, Tengu, etc.)
|
||||||
- Unreleased model version numbers (e.g., opus-4-7, sonnet-4-8)
|
- Unreleased model version numbers (e.g., sonnet-4-8)
|
||||||
- Internal repo or project names (e.g., claude-cli-internal, anthropics/…)
|
- Internal repo or project names (e.g., claude-cli-internal, anthropics/…)
|
||||||
- Internal tooling, Slack channels, or short links (e.g., go/cc, #claude-code-…)
|
- Internal tooling, Slack channels, or short links (e.g., go/cc, #claude-code-…)
|
||||||
- The phrase "Claude Code" or any mention that you are an AI
|
- The phrase "Claude Code" or any mention that you are an AI
|
||||||
@@ -64,8 +64,10 @@ GOOD:
|
|||||||
BAD (never write these):
|
BAD (never write these):
|
||||||
- "Fix bug found while testing with Claude Capybara"
|
- "Fix bug found while testing with Claude Capybara"
|
||||||
- "1-shotted by claude-opus-4-6"
|
- "1-shotted by claude-opus-4-6"
|
||||||
|
- "1-shotted by claude-opus-4-7"
|
||||||
- "Generated with Claude Code"
|
- "Generated with Claude Code"
|
||||||
- "Co-Authored-By: Claude Opus 4.6 <…>"
|
- "Co-Authored-By: Claude Opus 4.6 <…>"
|
||||||
|
- "Co-Authored-By: Claude Opus 4.7 <…>"
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ export const findGitBashPath = memoize((): string => {
|
|||||||
if (checkPathExists(process.env.CLAUDE_CODE_GIT_BASH_PATH)) {
|
if (checkPathExists(process.env.CLAUDE_CODE_GIT_BASH_PATH)) {
|
||||||
return process.env.CLAUDE_CODE_GIT_BASH_PATH
|
return process.env.CLAUDE_CODE_GIT_BASH_PATH
|
||||||
}
|
}
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.error(
|
console.error(
|
||||||
`Claude Code was unable to find CLAUDE_CODE_GIT_BASH_PATH path "${process.env.CLAUDE_CODE_GIT_BASH_PATH}"`,
|
`Claude Code was unable to find CLAUDE_CODE_GIT_BASH_PATH path "${process.env.CLAUDE_CODE_GIT_BASH_PATH}"`,
|
||||||
)
|
)
|
||||||
@@ -115,7 +114,6 @@ export const findGitBashPath = memoize((): string => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// biome-ignore lint/suspicious/noConsole:: intentional console output
|
|
||||||
console.error(
|
console.error(
|
||||||
'Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win). If installed but not in PATH, set environment variable pointing to your bash.exe, similar to: CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe',
|
'Claude Code on Windows requires git-bash (https://git-scm.com/downloads/win). If installed but not in PATH, set environment variable pointing to your bash.exe, similar to: CLAUDE_CODE_GIT_BASH_PATH=C:\\Program Files\\Git\\bin\\bash.exe',
|
||||||
)
|
)
|
||||||
|
|||||||
160
src/utils/workflowRuns.ts
Normal file
160
src/utils/workflowRuns.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { readdir, readFile } from 'fs/promises'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { getProjectRoot } from '../bootstrap/state.js'
|
||||||
|
import { safeParseJSON } from './json.js'
|
||||||
|
|
||||||
|
const WORKFLOW_RUNS_REL = join('.claude', 'workflow-runs')
|
||||||
|
const MAX_WORKFLOW_RUNS = 200
|
||||||
|
|
||||||
|
const WORKFLOW_RUN_STATUSES = ['running', 'completed', 'cancelled'] as const
|
||||||
|
const WORKFLOW_STEP_STATUSES = [
|
||||||
|
'pending',
|
||||||
|
'running',
|
||||||
|
'completed',
|
||||||
|
'cancelled',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type WorkflowRunStatus = (typeof WORKFLOW_RUN_STATUSES)[number]
|
||||||
|
type WorkflowStepStatus = (typeof WORKFLOW_STEP_STATUSES)[number]
|
||||||
|
|
||||||
|
export type WorkflowRunStepRecord = {
|
||||||
|
name: string
|
||||||
|
prompt?: string
|
||||||
|
status: WorkflowStepStatus
|
||||||
|
startedAt?: number
|
||||||
|
completedAt?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkflowRunRecord = {
|
||||||
|
runId: string
|
||||||
|
workflow: string
|
||||||
|
args?: string
|
||||||
|
status: WorkflowRunStatus
|
||||||
|
createdAt: number
|
||||||
|
updatedAt: number
|
||||||
|
currentStepIndex: number
|
||||||
|
steps: WorkflowRunStepRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkflowRunStatus(value: unknown): value is WorkflowRunStatus {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
WORKFLOW_RUN_STATUSES.includes(value as WorkflowRunStatus)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkflowStepStatus(value: unknown): value is WorkflowStepStatus {
|
||||||
|
return (
|
||||||
|
typeof value === 'string' &&
|
||||||
|
WORKFLOW_STEP_STATUSES.includes(value as WorkflowStepStatus)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkflowStep(value: unknown): WorkflowRunStepRecord | null {
|
||||||
|
if (!isRecord(value)) return null
|
||||||
|
if (typeof value.name !== 'string') return null
|
||||||
|
if (!isWorkflowStepStatus(value.status)) return null
|
||||||
|
return {
|
||||||
|
name: value.name,
|
||||||
|
...(typeof value.prompt === 'string' ? { prompt: value.prompt } : {}),
|
||||||
|
status: value.status,
|
||||||
|
...(typeof value.startedAt === 'number'
|
||||||
|
? { startedAt: value.startedAt }
|
||||||
|
: {}),
|
||||||
|
...(typeof value.completedAt === 'number'
|
||||||
|
? { completedAt: value.completedAt }
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWorkflowRun(value: unknown): WorkflowRunRecord | null {
|
||||||
|
if (!isRecord(value)) return null
|
||||||
|
if (typeof value.runId !== 'string') return null
|
||||||
|
if (typeof value.workflow !== 'string') return null
|
||||||
|
if (!isWorkflowRunStatus(value.status)) return null
|
||||||
|
if (typeof value.createdAt !== 'number') return null
|
||||||
|
if (typeof value.updatedAt !== 'number') return null
|
||||||
|
if (typeof value.currentStepIndex !== 'number') return null
|
||||||
|
if (!Array.isArray(value.steps)) return null
|
||||||
|
const steps = value.steps
|
||||||
|
.map(normalizeWorkflowStep)
|
||||||
|
.filter((step): step is WorkflowRunStepRecord => step !== null)
|
||||||
|
if (steps.length !== value.steps.length) return null
|
||||||
|
return {
|
||||||
|
runId: value.runId,
|
||||||
|
workflow: value.workflow,
|
||||||
|
...(typeof value.args === 'string' ? { args: value.args } : {}),
|
||||||
|
status: value.status,
|
||||||
|
createdAt: value.createdAt,
|
||||||
|
updatedAt: value.updatedAt,
|
||||||
|
currentStepIndex: value.currentStepIndex,
|
||||||
|
steps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readWorkflowRun(
|
||||||
|
rootDir: string,
|
||||||
|
runId: string,
|
||||||
|
): Promise<WorkflowRunRecord | null> {
|
||||||
|
try {
|
||||||
|
const parsed = safeParseJSON(
|
||||||
|
await readFile(
|
||||||
|
join(rootDir, WORKFLOW_RUNS_REL, `${runId}.json`),
|
||||||
|
'utf-8',
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
return normalizeWorkflowRun(parsed)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listWorkflowRuns(
|
||||||
|
rootDir: string = getProjectRoot(),
|
||||||
|
): Promise<WorkflowRunRecord[]> {
|
||||||
|
let files: string[]
|
||||||
|
try {
|
||||||
|
files = await readdir(join(rootDir, WORKFLOW_RUNS_REL))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const jsonFiles = files.filter(file => file.endsWith('.json'))
|
||||||
|
const runs = await Promise.all(
|
||||||
|
jsonFiles
|
||||||
|
.slice(0, MAX_WORKFLOW_RUNS)
|
||||||
|
.map(file => readWorkflowRun(rootDir, file.slice(0, -'.json'.length))),
|
||||||
|
)
|
||||||
|
return runs
|
||||||
|
.filter((run): run is WorkflowRunRecord => run !== null)
|
||||||
|
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatWorkflowRunsStatus(runs: WorkflowRunRecord[]): string {
|
||||||
|
if (runs.length === 0) {
|
||||||
|
return ['Workflow runs: 0', ' none'].join('\n')
|
||||||
|
}
|
||||||
|
const running = runs.filter(run => run.status === 'running').length
|
||||||
|
const completed = runs.filter(run => run.status === 'completed').length
|
||||||
|
const cancelled = runs.filter(run => run.status === 'cancelled').length
|
||||||
|
const lines = [
|
||||||
|
`Workflow runs: ${runs.length}`,
|
||||||
|
` Running: ${running}`,
|
||||||
|
` Completed: ${completed}`,
|
||||||
|
` Cancelled: ${cancelled}`,
|
||||||
|
]
|
||||||
|
for (const run of runs.slice(0, 10)) {
|
||||||
|
const currentStep = run.steps[run.currentStepIndex]
|
||||||
|
lines.push(
|
||||||
|
` ${run.runId}: ${run.workflow}: ${run.status} step=${currentStep?.name ?? 'none'} updated=${new Date(run.updatedAt).toLocaleString()}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (runs.length > 10) {
|
||||||
|
lines.push(` ... ${runs.length - 10} more workflow run(s)`)
|
||||||
|
}
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
@@ -1268,7 +1268,6 @@ export async function execIntoTmuxWorktree(args: string[]): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
repoName = basename(findCanonicalGitRoot(getCwd()) ?? getCwd())
|
repoName = basename(findCanonicalGitRoot(getCwd()) ?? getCwd())
|
||||||
// biome-ignore lint/suspicious/noConsole: intentional console output
|
|
||||||
console.log(`Using worktree via hook: ${worktreeDir}`)
|
console.log(`Using worktree via hook: ${worktreeDir}`)
|
||||||
} else {
|
} else {
|
||||||
// Get main git repo root (resolves through worktrees)
|
// Get main git repo root (resolves through worktrees)
|
||||||
@@ -1291,7 +1290,6 @@ export async function execIntoTmuxWorktree(args: string[]): Promise<{
|
|||||||
prNumber !== null ? { prNumber } : undefined,
|
prNumber !== null ? { prNumber } : undefined,
|
||||||
)
|
)
|
||||||
if (!result.existed) {
|
if (!result.existed) {
|
||||||
// biome-ignore lint/suspicious/noConsole: intentional console output
|
|
||||||
console.log(
|
console.log(
|
||||||
`Created worktree: ${worktreeDir} (based on ${(result as any).baseBranch})`,
|
`Created worktree: ${worktreeDir} (based on ${(result as any).baseBranch})`,
|
||||||
)
|
)
|
||||||
@@ -1383,7 +1381,6 @@ export async function execIntoTmuxWorktree(args: string[]): Promise<{
|
|||||||
// Print hint about iTerm2 preferences when using control mode
|
// Print hint about iTerm2 preferences when using control mode
|
||||||
if (useControlMode && !sessionExists) {
|
if (useControlMode && !sessionExists) {
|
||||||
const y = chalk.yellow
|
const y = chalk.yellow
|
||||||
// biome-ignore lint/suspicious/noConsole: intentional user guidance
|
|
||||||
console.log(
|
console.log(
|
||||||
`\n${y('╭─ iTerm2 Tip ────────────────────────────────────────────────────────╮')}\n` +
|
`\n${y('╭─ iTerm2 Tip ────────────────────────────────────────────────────────╮')}\n` +
|
||||||
`${y('│')} To open as a tab instead of a new window: ${y('│')}\n` +
|
`${y('│')} To open as a tab instead of a new window: ${y('│')}\n` +
|
||||||
|
|||||||
Reference in New Issue
Block a user