mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-24 09:05:50 +00:00
Fixture/flick (#1280)
* fix: 终端内容溢出 viewport 时的重影 bug 主屏幕模式下 frame 持续溢出 viewport 时,cursor-restore LF 把内容滚入 scrollback 导致相对光标追踪漂移,可见区 diff 落到错误行产生重影(重复 banner / 错位)。 扩展 log-update overflow 分支为无条件 fullReset(含 \x1b[3J 清 scrollback), 并将主屏 self-healing 清屏从 ERASE_SCREEN (CSI 2 J) 换成 ERASE_DOWN (CSI J), 避免 xterm.js / VSCode 集成终端的 scrollback 边界副作用。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除 3 个孤立诊断脚本 - scripts/verify-autofix-pr.ts: 一次性 autofix-pr 验证脚本,全仓零引用 - scripts/smoke-test-commands.ts: 开发期冒烟测试脚本,无任何 import - scripts/probe-subscription-endpoints.ts: 手动 endpoint 探针,无引用 均不在 package.json scripts、build.ts、vite.config.ts、CI workflows 中。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 self-hosted-runner stub 及其 cli.tsx fast-path - 删除 src/self-hosted-runner/main.ts(自动生成的 Promise.resolve() stub) - 同步移除 src/entrypoints/cli.tsx 中 feature('SELF_HOSTED_RUNNER') 守卫的 fast-path 分支 - 该 flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码 删除 stub 单独会留下未解析的动态 import,必须协同拆除。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除 agentSdkTypes 中三个 not-implemented stub 移除 watchScheduledTasks、buildMissedTaskNotification、connectRemoteControl 三个 stub 函数(函数体仅 throw new Error('not implemented')),以及仅被这些 stub 引用的孤儿类型(ScheduledTasksHandle、ConnectRemoteControlOptions、RemoteControlHandle、InboundPrompt 等)。 全仓零外部引用。buildMissedTaskNotification 在 src/utils/cronScheduler.ts 有真实可用实现,未受影响。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 Cursor.ts 中未引用的 kill ring 访问器 - 删除 getKillRingItem、getKillRingSize、clearKillRing、canYankPop(全仓零引用的独立 export) - 移除 VIM_WORD_CHAR_REGEX 的 export 关键字(仍由 isVimWordChar 内部使用,保留常量本体) kill ring 特性本身仍活跃(getLastKill/pushToKillRing/yankPop 在 useSearchInput/useTextInput 使用),仅这几个孤儿 helper 未接入。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 insights.ts 中未引用的导出 - 删除 deduplicateSessionBranches(全仓零调用,含 JSDoc) - 删除 buildExportData(全仓零调用,原 S3 上传路径实际用 HTML 而非 JSON) - InsightsExport 仅移除 export 关键字(保留类型本体,仍作为内部返回类型) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 autonomyCommandSpec.ts 中未引用的导出 - 删除 AUTONOMY_CLI(CLI 子命令描述对象,零引用;handler 仅用 AUTONOMY_USAGE) - 删除 AUTONOMY_COMMAND_DESCRIPTION(值已在 main.tsx:5181 内联) - ParsedAutonomyCommand 仅移除 export 关键字(保留类型作为 parseAutonomyArgs 返回类型) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 binaryCheck/claudeAiLimits/codeIndexing 中未引用的导出 - binaryCheck.ts: 删除 clearBinaryCache(零调用,binaryCache 仍由 isBinaryInstalled 使用) - claudeAiLimits.ts: 删除 RATE_LIMIT_DISPLAY_NAMES 常量 + getRateLimitDisplayName(互为唯一消费者) - codeIndexing.ts: 删除 detectCodeIndexingFromMcpTool(同胞 detectCodeIndexingFromCommand/McpServerName 仍活跃) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除多处仅内部使用的 export 关键字 下列符号均仅在本文件内被引用,export 关键字冗余;保留符号本体不动: - internalLogging.ts: getContainerId(line 88 内部调用) - api/errors.ts: isMediaSizeError(line 151 内部调用) - api/withRetry.ts: parseMaxTokensContextOverflowError(line 389/724 内部调用) - statsCache.ts: STATS_CACHE_VERSION(7 处内部使用) - startupProfiler.ts: logStartupPerf(line 128 内部调用) - bashCommandHelpers.ts: CommandIdentityCheckers(3 处内部参数类型) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 清理注释代码块与 legacy shim 注释代码(已死的、引用不存在符号的注释块): - Onboarding.tsx: 注释化的 preflight if-block(引用不存在的 preflightStep) - ultraplan.tsx: 两处引用不存在符号的注释(ULTRAPLAN_INSTRUCTIONS、getUltraplanModel) - types/hooks.ts: 禁用的 type-fest IsEqual 类型断言块 - types/global.d.ts: 已被真实模块取代的 Ultraplan ambient declares - types/textInputTypes.ts: 注释化的 onMessage interface 成员 legacy shim: - cli/bg.ts: 删除 handleBgFlag 别名 export(同胞 handleBgStart 已被所有调用点使用) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 ccshareResume stub 及 main.tsx 的 ccshare fast-path - 删除 src/utils/ccshareResume.ts(parseCcshareId 恒返回 null、loadCcshare 恒抛错的 stub) - 同步移除 src/main.tsx 中 USER_TYPE === 'ant' 守卫下的 if (ccshareId) {...} else {...} 双分支 - 提升 else 块(文件路径 resume 处理)为直接进入 if (options.resume) 块内 ccshare 是 Anthropic 内部特性(go/ccshare URL),stub 未实现导致 ccshareId 恒为 null,整个 ccshare 分支永不进入;保留的文件路径 resume 路径不变。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 environment-runner stub 及其 cli.tsx fast-path 与 self-hosted-runner 相同模式的 sibling(工作流 1 verifier 建议同步处理): - 删除 src/environment-runner/main.ts(自动生成的 Promise.resolve() stub) - 同步移除 src/entrypoints/cli.tsx 中 feature('BYOC_ENVIRONMENT_RUNNER') 守卫的 fast-path 分支 - 清理两个空目录(src/self-hosted-runner/、src/environment-runner/) BYOC_ENVIRONMENT_RUNNER flag 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除孤立诊断脚本 probe-local-wiring.ts #!/usr/bin/env bun shebang 的手动诊断脚本,全仓零引用,不在 package.json/build.ts/vite.config.ts/CI workflows 中。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 ultrareview preflight stub 及其测试 - 删除 src/services/api/ultrareviewPreflight.ts(自动生成的 stub) - 删除 src/commands/review/UltrareviewPreflightDialog.tsx(依赖前者的 UI stub) - 删除 src/services/api/__tests__/ultrareviewPreflight.test.ts(测试已删代码) - 同步移除 ultrareviewCommand.test.tsx 中对 UltrareviewPreflightDialog 的 mock Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 cachedMCConfig stub 及 prompts.ts 的 CACHED_MICROCOMPACT 死代码 - 删除 src/services/compact/cachedMCConfig.ts(自动生成的 stub) - 同步移除 src/constants/prompts.ts 中依赖该 stub 的代码: - getCachedMCConfigForFRC 变量(feature('CACHED_MICROCOMPACT') 守卫的 require) - getFunctionResultClearingSection 函数(约 18 行) - systemPrompt 数组中的 frc section 调用与注册 CACHED_MICROCOMPACT 不在 build.ts DEFAULT_BUILD_FEATURES 也不在 dev 默认列表,所有默认配置下整段为构建期死代码。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 goalAudit stub 及其测试引用 - 删除 src/services/goal/goalAudit.ts(导出 COMPLETION_AUDIT_RULES/BLOCKED_AUDIT_RULES/isGoalTerminal 等未引用的 stub) - 同步移除 tests/integration/goal-lifecycle.test.ts 中对 goalAudit 的 import 和一个测试用例(budget_limited is terminal) Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 删除 agentSdkTypes 第二批 not-implemented stub 移除运行时函数体仅为 throw new Error 或 placeholder 的 stub: - createSdkMcpToolDefinition、createSdkMcpServer - query 函数重载与实现 - unstable_v2_* 系列函数 - session 操作 stub(getSessionMessages/listSessions/getSessionInfo/renameSession/tagSession/forkSession) - AbortError 类 保留所有 export type 重导出和类型别名(仍是公共类型面)。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 Tool.ts 中 backwards-compat 重导出 shim 删除 "// Re-export progress types for backwards compatibility" 注释块及其重导出语句。所有消费方已直接从 src/types/tools.js 导入,无需重导出转发。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 bootstrap/state.ts 中 4 个未引用的 export - clearRegisteredHooks(STATE.registeredHooks 仍由其他函数管理) - getInvokedSkills(getInvokedSkillsForAgent 是活跃入口) - getSessionSource(setSessionSource 仍活跃,sessionSource state 字段保留) - markScrollActivity(scrollDraining/getIsScrollDraining/waitForScrollDrain 仍活跃) 仅删除孤儿访问器,不动模块级 state 副作用。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 src/ 下多处未引用的导出 涉及 18 个文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除: - bridge/bridgeStatusUtil.ts、components/TrustDialog/utils.ts、context/stats.tsx - keybindings/loadUserBindings.ts、memdir/paths.ts、remote/sdkMessageAdapter.ts - services/acp/utils.ts(删除 nodeToWebReadable,全仓零引用) - services/api/metricsOptOut.ts、services/lsp/LSPDiagnosticRegistry.ts、services/lsp/manager.ts - services/mcp/utils.ts、services/skillLearning/projectContext.ts - services/teamMemorySync/secretScanner.ts、services/teamMemorySync/watcher.ts - skills/loadSkillsDir.ts、utils/attachments.ts、utils/filePersistence/filePersistence.ts - utils/messageQueueManager.ts Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * chore: 移除 packages/ 下多处未引用的导出 涉及 11 个 workspace 包文件,每处均为独立的 unreferenced export 删除或 export 关键字冗余移除: - @ant/ink/core/termio/csi.ts(eraseLine) - acp-link/manager/types.ts、acp-link/ws-message.ts - builtin-tools/AgentTool/agentMemory.ts、BashTool/bashSecurity.ts、BashTool/sedEditParser.ts - builtin-tools/ConfigTool/supportedSettings.ts、FileEditTool/utils.ts - remote-control-server/store.ts、transport/event-bus.ts、types/messages.ts Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * Revert "fix: 终端内容溢出 viewport 时的重影 bug" This reverts commit3d18e1da58. * revert: 移除主屏幕周期性 self-healing 重绘 回退f69c7051中引入的 ink.tsx self-healing 机制(lastMainScreenHealTime 字段 + 每 5 秒触发全量重绘 + needsEraseBeforePaint 主屏幕分支)。该机制在 workflow 面板持续刷新场景下表现为可见的"重复刷新",且修复效果不稳定。 alt-screen 的 needsEraseBeforePaint 路径和 prevFrameContaminated 字段保留, 它们仍服务于 handleResize / layout shift / selection 高亮。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * fix: /workflows 面板默认只显示运行中 run,根治 tab 行乱码 之前几次渲染层修复都失败,因为没动 tab 列表的数据源:打开 /workflows 会 自动 hydrate 最多 20 个历史 done/killed run,全部塞进一行 TabsBar,超出 终端宽度后 Ink 把字符画到屏外造成重影乱码。 - selectors.ts 加 filterActiveRuns(只留 status === 'running')和 capTabsForDisplay(超额 fold 成 +N)两个 pure function - WorkflowsPanel 接线 activeRuns:focus clamp、focused、nextTab/prevTab、 TabsBar 全部基于过滤后的 activeRuns - TabsBar 复用 truncateLabel 限制每个 tab 名 18 字符 + 最多 6 个 tab, 多余显示 +N,从结构上钉死单行总宽度 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * fix: /workflows 面板 phase 状态在脚本省略 phase() 时显示错乱 ultracode canonical pipeline 脚本常在 agent() 直接传 opts.phase 而不调 phase() hook,导致 phase_started 从未发出;同时 phase_done 只在下次 phase() 触发,上一 个 phase 在 run.phases 里一直停在 running。mergePhases 之前把 actual 当权威, 于是出现 "Map 8/8 全 done 还显示 running、Find 1/4 running 反而显示 pending"。 改为派生层修复:mergePhases 新增 derivePhaseStatus——actual.status==='done' 权威;否则有 agents 就按 agents 状态推(全 done→done,否则 running);否则看 actual 是否 running。再补一层遍历,让只在 agents 上出现的 phase 也进 sidebar。 不改 store 状态语义,已有 state.json 无需迁移。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> * docs: 更新 readme * fix: ACP 模式未读取 settings.local.json entry.ts 在 ACP 握手期调用的 applySafeConfigEnvironmentVariables 触发了 loadSettingsFromDisk,此时 getOriginalCwd() 还是进程启动 cwd(非项目目录), 导致 localSettings/projectSettings 按错误路径解析为空并被 session cache 锁住, 后续 createSession 里 setOriginalCwd 也无法纠正。在 setOriginalCwd 与 chdir 之后清缓存并重新应用,让 settings.local.json 和项目级 env 对 readSettingsPermissionMode 及下游可见。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win> --------- Co-authored-by: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
11
src/Tool.ts
11
src/Tool.ts
@@ -62,17 +62,6 @@ import type { DenialTrackingState } from './utils/permissions/denialTracking.js'
|
||||
import type { SystemPrompt } from './utils/systemPromptType.js'
|
||||
import type { ContentReplacementState } from './utils/toolResultStorage.js'
|
||||
|
||||
// Re-export progress types for backwards compatibility
|
||||
export type {
|
||||
AgentToolProgress,
|
||||
BashProgress,
|
||||
MCPProgress,
|
||||
REPLToolProgress,
|
||||
SkillToolProgress,
|
||||
TaskOutputProgress,
|
||||
WebSearchProgress,
|
||||
}
|
||||
|
||||
import type { SpinnerMode } from './components/Spinner.js'
|
||||
import type { QuerySource } from './constants/querySource.js'
|
||||
import type { SDKStatus } from './entrypoints/agentSdkTypes.js'
|
||||
|
||||
@@ -787,18 +787,6 @@ let scrollDraining = false
|
||||
let scrollDrainTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const SCROLL_DRAIN_IDLE_MS = 150
|
||||
|
||||
/** Mark that a scroll event just happened. Background intervals gate on
|
||||
* getIsScrollDraining() and skip their work until the debounce clears. */
|
||||
export function markScrollActivity(): void {
|
||||
scrollDraining = true
|
||||
if (scrollDrainTimer) clearTimeout(scrollDrainTimer)
|
||||
scrollDrainTimer = setTimeout(() => {
|
||||
scrollDraining = false
|
||||
scrollDrainTimer = undefined
|
||||
}, SCROLL_DRAIN_IDLE_MS)
|
||||
scrollDrainTimer.unref?.()
|
||||
}
|
||||
|
||||
/** True while scroll is actively draining (within 150ms of last event).
|
||||
* Intervals should early-return when this is set — the work picks up next
|
||||
* tick after scroll settles. */
|
||||
@@ -1103,10 +1091,6 @@ export function setUserMsgOptIn(value: boolean): void {
|
||||
STATE.userMsgOptIn = value
|
||||
}
|
||||
|
||||
export function getSessionSource(): string | undefined {
|
||||
return STATE.sessionSource
|
||||
}
|
||||
|
||||
export function setSessionSource(source: string): void {
|
||||
STATE.sessionSource = source
|
||||
}
|
||||
@@ -1433,10 +1417,6 @@ export function getRegisteredHooks(): Partial<
|
||||
return STATE.registeredHooks
|
||||
}
|
||||
|
||||
export function clearRegisteredHooks(): void {
|
||||
STATE.registeredHooks = null
|
||||
}
|
||||
|
||||
export function clearRegisteredPluginHooks(): void {
|
||||
if (!STATE.registeredHooks) {
|
||||
return
|
||||
@@ -1527,10 +1507,6 @@ export function addInvokedSkill(
|
||||
})
|
||||
}
|
||||
|
||||
export function getInvokedSkills(): Map<string, InvokedSkillInfo> {
|
||||
return STATE.invokedSkills
|
||||
}
|
||||
|
||||
export function getInvokedSkillsForAgent(
|
||||
agentId: string | undefined | null,
|
||||
): Map<string, InvokedSkillInfo> {
|
||||
|
||||
@@ -28,11 +28,6 @@ export function timestamp(): string {
|
||||
|
||||
export { formatDuration, truncateToWidth as truncatePrompt }
|
||||
|
||||
/** Abbreviate a tool activity summary for the trail display. */
|
||||
export function abbreviateActivity(summary: string): string {
|
||||
return truncateToWidth(summary, 30)
|
||||
}
|
||||
|
||||
/** Build the connect URL shown when the bridge is idle. */
|
||||
export function buildBridgeConnectUrl(
|
||||
environmentId: string,
|
||||
|
||||
@@ -336,6 +336,3 @@ export async function handleBgStart(args: string[]): Promise<void> {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy export alias — kept for backward compatibility with cli.tsx
|
||||
export const handleBgFlag = handleBgStart
|
||||
|
||||
@@ -800,34 +800,6 @@ function logToSessionMeta(log: LogOption): SessionMeta {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate conversation branches within the same session.
|
||||
*
|
||||
* When a session file has multiple leaf messages (from retries or branching),
|
||||
* loadAllLogsFromSessionFile produces one LogOption per leaf. Each branch
|
||||
* shares the same root message, so its duration overlaps with sibling
|
||||
* branches. This keeps only the branch with the most user messages
|
||||
* (tie-break by longest duration) per session_id.
|
||||
*/
|
||||
export function deduplicateSessionBranches(
|
||||
entries: Array<{ log: LogOption; meta: SessionMeta }>,
|
||||
): Array<{ log: LogOption; meta: SessionMeta }> {
|
||||
const bestBySession = new Map<string, { log: LogOption; meta: SessionMeta }>()
|
||||
for (const entry of entries) {
|
||||
const id = entry.meta.session_id
|
||||
const existing = bestBySession.get(id)
|
||||
if (
|
||||
!existing ||
|
||||
entry.meta.user_message_count > existing.meta.user_message_count ||
|
||||
(entry.meta.user_message_count === existing.meta.user_message_count &&
|
||||
entry.meta.duration_minutes > existing.meta.duration_minutes)
|
||||
) {
|
||||
bestBySession.set(id, entry)
|
||||
}
|
||||
}
|
||||
return [...bestBySession.values()]
|
||||
}
|
||||
|
||||
function formatTranscriptForFacets(log: LogOption): string {
|
||||
const lines: string[] = []
|
||||
const meta = logToSessionMeta(log)
|
||||
@@ -2658,7 +2630,7 @@ function generateHtmlReport(
|
||||
/**
|
||||
* Structured export format for claudescope consumption
|
||||
*/
|
||||
export type InsightsExport = {
|
||||
type InsightsExport = {
|
||||
metadata: {
|
||||
username: string
|
||||
generated_at: string
|
||||
@@ -2678,70 +2650,6 @@ export type InsightsExport = {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build export data from already-computed values.
|
||||
* Used by background upload to S3.
|
||||
*/
|
||||
export function buildExportData(
|
||||
data: AggregatedData,
|
||||
insights: InsightResults,
|
||||
facets: Map<string, SessionFacets>,
|
||||
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number },
|
||||
): InsightsExport {
|
||||
const version = typeof MACRO !== 'undefined' ? MACRO.VERSION : 'unknown'
|
||||
|
||||
const remote_hosts_collected = remoteStats?.hosts
|
||||
.filter(h => h.sessionCount > 0)
|
||||
.map(h => h.name)
|
||||
|
||||
const facets_summary = {
|
||||
total: facets.size,
|
||||
goal_categories: {} as Record<string, number>,
|
||||
outcomes: {} as Record<string, number>,
|
||||
satisfaction: {} as Record<string, number>,
|
||||
friction: {} as Record<string, number>,
|
||||
}
|
||||
for (const f of facets.values()) {
|
||||
for (const [cat, count] of safeEntries(f.goal_categories)) {
|
||||
if (count > 0) {
|
||||
facets_summary.goal_categories[cat] =
|
||||
(facets_summary.goal_categories[cat] || 0) + count
|
||||
}
|
||||
}
|
||||
facets_summary.outcomes[f.outcome] =
|
||||
(facets_summary.outcomes[f.outcome] || 0) + 1
|
||||
for (const [level, count] of safeEntries(f.user_satisfaction_counts)) {
|
||||
if (count > 0) {
|
||||
facets_summary.satisfaction[level] =
|
||||
(facets_summary.satisfaction[level] || 0) + count
|
||||
}
|
||||
}
|
||||
for (const [type, count] of safeEntries(f.friction_counts)) {
|
||||
if (count > 0) {
|
||||
facets_summary.friction[type] =
|
||||
(facets_summary.friction[type] || 0) + count
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
username: process.env.SAFEUSER || process.env.USER || 'unknown',
|
||||
generated_at: new Date().toISOString(),
|
||||
claude_code_version: version,
|
||||
date_range: data.date_range,
|
||||
session_count: data.total_sessions,
|
||||
...(remote_hosts_collected &&
|
||||
remote_hosts_collected.length > 0 && {
|
||||
remote_hosts_collected,
|
||||
}),
|
||||
},
|
||||
aggregated_data: data,
|
||||
insights,
|
||||
facets_summary,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Lite Session Scanning
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { Box, Dialog, Text } from '@anthropic/ink';
|
||||
import { Select } from '../../components/CustomSelect/select.js';
|
||||
|
||||
type Props = {
|
||||
billingNote: string | null;
|
||||
onConfirm: (signal: AbortSignal) => Promise<void>;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Dialog shown when /v1/ultrareview/preflight returns action='confirm'.
|
||||
* Displays the server-provided billing_note (or a generic fallback) and
|
||||
* gives the user a Proceed / Cancel choice.
|
||||
*/
|
||||
export function UltrareviewPreflightDialog({ billingNote, onConfirm, onCancel }: Props): React.ReactNode {
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const abortControllerRef = useRef(new AbortController());
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === 'proceed') {
|
||||
setIsLaunching(true);
|
||||
void onConfirm(abortControllerRef.current.signal).catch(() => setIsLaunching(false));
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[onConfirm, onCancel],
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
abortControllerRef.current.abort();
|
||||
onCancel();
|
||||
}, [onCancel]);
|
||||
|
||||
const options = [
|
||||
{ label: 'Proceed', value: 'proceed' },
|
||||
{ label: 'Cancel', value: 'cancel' },
|
||||
];
|
||||
|
||||
const displayNote = billingNote ?? 'This run may incur additional cost.';
|
||||
|
||||
return (
|
||||
<Dialog title="Ultrareview — additional cost" onCancel={handleCancel} color="background">
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text>{displayNote}</Text>
|
||||
{isLaunching ? (
|
||||
<Text color="background">Launching…</Text>
|
||||
) : (
|
||||
<Select options={options} onChange={handleSelect} onCancel={handleCancel} />
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -179,13 +179,10 @@ mock.module('src/components/CustomSelect/select.js', () => ({
|
||||
Select: 'Select',
|
||||
}));
|
||||
|
||||
// UltrareviewOverageDialog and PreflightDialog — return a simple marker
|
||||
// UltrareviewOverageDialog — return a simple marker
|
||||
mock.module('src/commands/review/UltrareviewOverageDialog.js', () => ({
|
||||
UltrareviewOverageDialog: () => ({ type: 'UltrareviewOverageDialog' }),
|
||||
}));
|
||||
mock.module('src/commands/review/UltrareviewPreflightDialog.js', () => ({
|
||||
UltrareviewPreflightDialog: () => ({ type: 'UltrareviewPreflightDialog' }),
|
||||
}));
|
||||
|
||||
import { call } from '../ultrareviewCommand.js';
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ export function buildUltraplanPrompt(blurb: string, seedPlan?: string, promptId?
|
||||
if (seedPlan) {
|
||||
parts.push('Here is a draft plan to refine:', '', seedPlan, '');
|
||||
}
|
||||
// parts.push(ULTRAPLAN_INSTRUCTIONS)
|
||||
parts.push(getPromptText(promptId!));
|
||||
|
||||
if (blurb) {
|
||||
@@ -341,8 +340,6 @@ async function launchDetached(opts: {
|
||||
// occurs after teleportToRemote succeeds (avoids 30min orphan).
|
||||
let sessionId: string | undefined;
|
||||
try {
|
||||
// const model = getUltraplanModel()
|
||||
|
||||
const eligibility = await checkRemoteAgentEligibility();
|
||||
if (!eligibility.eligible) {
|
||||
logEvent('tengu_ultraplan_create_failed', {
|
||||
@@ -365,7 +362,6 @@ async function launchDetached(opts: {
|
||||
const session = await teleportToRemote({
|
||||
initialMessage: prompt,
|
||||
description: blurb || 'Refine local plan',
|
||||
// model,
|
||||
permissionMode: 'plan',
|
||||
ultraplan: true,
|
||||
signal,
|
||||
@@ -404,7 +400,6 @@ async function launchDetached(opts: {
|
||||
logEvent('tengu_ultraplan_launched', {
|
||||
has_seed_plan: Boolean(seedPlan),
|
||||
prompt_identifier: promptIdentifier as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
// model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
// TODO(#23985): replace registerRemoteAgentTask + startDetachedPoll with
|
||||
// ExitPlanModeScanner inside startRemoteSessionPolling.
|
||||
|
||||
@@ -134,10 +134,6 @@ export function Onboarding({ onDone }: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
const steps: OnboardingStep[] = [];
|
||||
// Preflight check disabled — users may use third-party API providers
|
||||
// if (oauthEnabled) {
|
||||
// steps.push({ id: 'preflight', component: preflightStep })
|
||||
// }
|
||||
steps.push({ id: 'theme', component: themeStep });
|
||||
|
||||
if (apiKeyNeedingApproval) {
|
||||
|
||||
@@ -71,38 +71,6 @@ export function getBashPermissionSources(): string[] {
|
||||
return sources
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a list of items with proper "and" conjunction.
|
||||
* @param items - Array of items to format
|
||||
* @param limit - Optional limit for how many items to show before summarizing (ignored if 0)
|
||||
*/
|
||||
export function formatListWithAnd(items: string[], limit?: number): string {
|
||||
if (items.length === 0) return ''
|
||||
|
||||
// Ignore limit if it's 0
|
||||
const effectiveLimit = limit === 0 ? undefined : limit
|
||||
|
||||
// If no limit or items are within limit, use normal formatting
|
||||
if (!effectiveLimit || items.length <= effectiveLimit) {
|
||||
if (items.length === 1) return items[0]!
|
||||
if (items.length === 2) return `${items[0]} and ${items[1]}`
|
||||
|
||||
const lastItem = items[items.length - 1]!
|
||||
const allButLast = items.slice(0, -1)
|
||||
return `${allButLast.join(', ')}, and ${lastItem}`
|
||||
}
|
||||
|
||||
// If we have more items than the limit, show first few and count the rest
|
||||
const shown = items.slice(0, effectiveLimit)
|
||||
const remaining = items.length - effectiveLimit
|
||||
|
||||
if (shown.length === 1) {
|
||||
return `${shown[0]} and ${remaining} more`
|
||||
}
|
||||
|
||||
return `${shown.join(', ')}, and ${remaining} more`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if settings have otelHeadersHelper configured
|
||||
*/
|
||||
|
||||
@@ -67,12 +67,6 @@ import { getCurrentMode } from 'src/modes/store.js'
|
||||
|
||||
// Dead code elimination: conditional imports for feature-gated modules
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const getCachedMCConfigForFRC = feature('CACHED_MICROCOMPACT')
|
||||
? (
|
||||
require('../services/compact/cachedMCConfig.js') as typeof import('../services/compact/cachedMCConfig.js')
|
||||
).getCachedMCConfig
|
||||
: null
|
||||
|
||||
const proactiveModule =
|
||||
feature('PROACTIVE') || feature('KAIROS')
|
||||
? require('../proactive/index.js')
|
||||
@@ -454,7 +448,6 @@ ${CYBER_RISK_INSTRUCTION}`,
|
||||
? null
|
||||
: getMcpInstructionsSection(mcpClients),
|
||||
getScratchpadInstructions(),
|
||||
getFunctionResultClearingSection(model),
|
||||
SUMMARIZE_TOOL_RESULTS_SECTION,
|
||||
getProactiveSection(),
|
||||
].filter(s => s !== null)
|
||||
@@ -492,7 +485,6 @@ ${CYBER_RISK_INSTRUCTION}`,
|
||||
'MCP servers connect/disconnect between turns',
|
||||
),
|
||||
systemPromptSection('scratchpad', () => getScratchpadInstructions()),
|
||||
systemPromptSection('frc', () => getFunctionResultClearingSection(model)),
|
||||
systemPromptSection(
|
||||
'summarize_tool_results',
|
||||
() => SUMMARIZE_TOOL_RESULTS_SECTION,
|
||||
@@ -781,26 +773,6 @@ Only use \`/tmp\` if the user explicitly requests it.
|
||||
The scratchpad directory is session-specific, isolated from the user's project, and can be used freely without permission prompts.`
|
||||
}
|
||||
|
||||
function getFunctionResultClearingSection(model: string): string | null {
|
||||
if (!feature('CACHED_MICROCOMPACT') || !getCachedMCConfigForFRC) {
|
||||
return null
|
||||
}
|
||||
const config = getCachedMCConfigForFRC()
|
||||
const isModelSupported = config.supportedModels?.some(pattern =>
|
||||
model.includes(pattern),
|
||||
)
|
||||
if (
|
||||
!config.enabled ||
|
||||
!config.systemPromptSuggestSummaries ||
|
||||
!isModelSupported
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return `# Function Result Clearing
|
||||
|
||||
Old tool results will be automatically cleared from context to free up space. The ${config.keepRecent} most recent results are always kept.`
|
||||
}
|
||||
|
||||
const SUMMARIZE_TOOL_RESULTS_SECTION = `When working with tool results, write down any important information you might need later in your response, as the original tool result may be cleared later.`
|
||||
|
||||
function getBriefSection(): string | null {
|
||||
|
||||
@@ -137,11 +137,6 @@ export function useStats(): StatsStore {
|
||||
return store;
|
||||
}
|
||||
|
||||
export function useCounter(name: string): (value?: number) => void {
|
||||
const store = useStats();
|
||||
return useCallback((value?: number) => store.increment(name, value), [store, name]);
|
||||
}
|
||||
|
||||
export function useGauge(name: string): (value: number) => void {
|
||||
const store = useStats();
|
||||
return useCallback((value: number) => store.set(name, value), [store, name]);
|
||||
|
||||
@@ -35,7 +35,6 @@ export * from './sdk/toolTypes.js'
|
||||
// ============================================================================
|
||||
|
||||
import type {
|
||||
SDKMessage,
|
||||
SDKResultMessage,
|
||||
SDKSessionInfo,
|
||||
SDKUserMessage,
|
||||
@@ -72,208 +71,6 @@ export type {
|
||||
SDKSessionInfo,
|
||||
}
|
||||
|
||||
export function tool<Schema extends AnyZodRawShape>(
|
||||
_name: string,
|
||||
_description: string,
|
||||
_inputSchema: Schema,
|
||||
_handler: (
|
||||
args: InferShape<Schema>,
|
||||
extra: unknown,
|
||||
) => Promise<CallToolResult>,
|
||||
_extras?: {
|
||||
annotations?: ToolAnnotations
|
||||
searchHint?: string
|
||||
alwaysLoad?: boolean
|
||||
},
|
||||
): SdkMcpToolDefinition<Schema> {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
type CreateSdkMcpServerOptions = {
|
||||
name: string
|
||||
version?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tools?: Array<SdkMcpToolDefinition<any>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an MCP server instance that can be used with the SDK transport.
|
||||
* This allows SDK users to define custom tools that run in the same process.
|
||||
*
|
||||
* If your SDK MCP calls will run longer than 60s, override CLAUDE_CODE_STREAM_CLOSE_TIMEOUT
|
||||
*/
|
||||
export function createSdkMcpServer(
|
||||
_options: CreateSdkMcpServerOptions,
|
||||
): McpSdkServerConfigWithInstance {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
export class AbortError extends Error {}
|
||||
|
||||
/** @internal */
|
||||
export function query(_params: {
|
||||
prompt: string | AsyncIterable<SDKUserMessage>
|
||||
options?: InternalOptions
|
||||
}): InternalQuery
|
||||
export function query(_params: {
|
||||
prompt: string | AsyncIterable<SDKUserMessage>
|
||||
options?: Options
|
||||
}): Query
|
||||
export function query(): Query {
|
||||
throw new Error('query is not implemented in the SDK')
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API - UNSTABLE
|
||||
* Create a persistent session for multi-turn conversations.
|
||||
* @alpha
|
||||
*/
|
||||
export function unstable_v2_createSession(
|
||||
_options: SDKSessionOptions,
|
||||
): SDKSession {
|
||||
throw new Error('unstable_v2_createSession is not implemented in the SDK')
|
||||
}
|
||||
|
||||
/**
|
||||
* V2 API - UNSTABLE
|
||||
* Resume an existing session by ID.
|
||||
* @alpha
|
||||
*/
|
||||
export function unstable_v2_resumeSession(
|
||||
_sessionId: string,
|
||||
_options: SDKSessionOptions,
|
||||
): SDKSession {
|
||||
throw new Error('unstable_v2_resumeSession is not implemented in the SDK')
|
||||
}
|
||||
|
||||
// @[MODEL LAUNCH]: Update the example model ID in this docstring.
|
||||
/**
|
||||
* V2 API - UNSTABLE
|
||||
* One-shot convenience function for single prompts.
|
||||
* @alpha
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await unstable_v2_prompt("What files are here?", {
|
||||
* model: 'claude-sonnet-4-6'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export async function unstable_v2_prompt(
|
||||
_message: string,
|
||||
_options: SDKSessionOptions,
|
||||
): Promise<SDKResultMessage> {
|
||||
throw new Error('unstable_v2_prompt is not implemented in the SDK')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a session's conversation messages from its JSONL transcript file.
|
||||
*
|
||||
* Parses the transcript, builds the conversation chain via parentUuid links,
|
||||
* and returns user/assistant messages in chronological order. Set
|
||||
* `includeSystemMessages: true` in options to also include system messages.
|
||||
*
|
||||
* @param sessionId - UUID of the session to read
|
||||
* @param options - Optional dir, limit, offset, and includeSystemMessages
|
||||
* @returns Array of messages, or empty array if session not found
|
||||
*/
|
||||
export async function getSessionMessages(
|
||||
_sessionId: string,
|
||||
_options?: GetSessionMessagesOptions,
|
||||
): Promise<SessionMessage[]> {
|
||||
throw new Error('getSessionMessages is not implemented in the SDK')
|
||||
}
|
||||
|
||||
/**
|
||||
* List sessions with metadata.
|
||||
*
|
||||
* When `dir` is provided, returns sessions for that project directory
|
||||
* and its git worktrees. When omitted, returns sessions across all
|
||||
* projects.
|
||||
*
|
||||
* Use `limit` and `offset` for pagination.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // List sessions for a specific project
|
||||
* const sessions = await listSessions({ dir: '/path/to/project' })
|
||||
*
|
||||
* // Paginate
|
||||
* const page1 = await listSessions({ limit: 50 })
|
||||
* const page2 = await listSessions({ limit: 50, offset: 50 })
|
||||
* ```
|
||||
*/
|
||||
export async function listSessions(
|
||||
_options?: ListSessionsOptions,
|
||||
): Promise<SDKSessionInfo[]> {
|
||||
throw new Error('listSessions is not implemented in the SDK')
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads metadata for a single session by ID. Unlike `listSessions`, this only
|
||||
* reads the single session file rather than every session in the project.
|
||||
* Returns undefined if the session file is not found, is a sidechain session,
|
||||
* or has no extractable summary.
|
||||
*
|
||||
* @param sessionId - UUID of the session
|
||||
* @param options - `{ dir?: string }` project path; omit to search all project directories
|
||||
*/
|
||||
export async function getSessionInfo(
|
||||
_sessionId: string,
|
||||
_options?: GetSessionInfoOptions,
|
||||
): Promise<SDKSessionInfo | undefined> {
|
||||
throw new Error('getSessionInfo is not implemented in the SDK')
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a session. Appends a custom-title entry to the session's JSONL file.
|
||||
* @param sessionId - UUID of the session
|
||||
* @param title - New title
|
||||
* @param options - `{ dir?: string }` project path; omit to search all projects
|
||||
*/
|
||||
export async function renameSession(
|
||||
_sessionId: string,
|
||||
_title: string,
|
||||
_options?: SessionMutationOptions,
|
||||
): Promise<void> {
|
||||
throw new Error('renameSession is not implemented in the SDK')
|
||||
}
|
||||
|
||||
/**
|
||||
* Tag a session. Pass null to clear the tag.
|
||||
* @param sessionId - UUID of the session
|
||||
* @param tag - Tag string, or null to clear
|
||||
* @param options - `{ dir?: string }` project path; omit to search all projects
|
||||
*/
|
||||
export async function tagSession(
|
||||
_sessionId: string,
|
||||
_tag: string | null,
|
||||
_options?: SessionMutationOptions,
|
||||
): Promise<void> {
|
||||
throw new Error('tagSession is not implemented in the SDK')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork a session into a new branch with fresh UUIDs.
|
||||
*
|
||||
* Copies transcript messages from the source session into a new session file,
|
||||
* remapping every message UUID and preserving the parentUuid chain. Supports
|
||||
* `upToMessageId` for branching from a specific point in the conversation.
|
||||
*
|
||||
* Forked sessions start without undo history (file-history snapshots are not
|
||||
* copied).
|
||||
*
|
||||
* @param sessionId - UUID of the source session
|
||||
* @param options - `{ dir?, upToMessageId?, title? }`
|
||||
* @returns `{ sessionId }` — UUID of the new forked session
|
||||
*/
|
||||
export async function forkSession(
|
||||
_sessionId: string,
|
||||
_options?: ForkSessionOptions,
|
||||
): Promise<ForkSessionResult> {
|
||||
throw new Error('forkSession is not implemented in the SDK')
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Assistant daemon primitives (internal)
|
||||
// ============================================================================
|
||||
@@ -306,144 +103,6 @@ export type CronJitterConfig = {
|
||||
recurringMaxAgeMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Event yielded by `watchScheduledTasks()`.
|
||||
* @internal
|
||||
*/
|
||||
export type ScheduledTaskEvent =
|
||||
| { type: 'fire'; task: CronTask }
|
||||
| { type: 'missed'; tasks: CronTask[] }
|
||||
|
||||
/**
|
||||
* Handle returned by `watchScheduledTasks()`.
|
||||
* @internal
|
||||
*/
|
||||
export type ScheduledTasksHandle = {
|
||||
/** Async stream of fire/missed events. Drain with `for await`. */
|
||||
events(): AsyncGenerator<ScheduledTaskEvent>
|
||||
/**
|
||||
* Epoch ms of the soonest scheduled fire across all loaded tasks, or null
|
||||
* if nothing is scheduled. Useful for deciding whether to tear down an
|
||||
* idle agent subprocess or keep it warm for an imminent fire.
|
||||
*/
|
||||
getNextFireTime(): number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch `<dir>/.claude/scheduled_tasks.json` and yield events as tasks fire.
|
||||
*
|
||||
* Acquires the per-directory scheduler lock (PID-based liveness) so a REPL
|
||||
* session in the same dir won't double-fire. Releases the lock and closes
|
||||
* the file watcher when the signal aborts.
|
||||
*
|
||||
* - `fire` — a task whose cron schedule was met. One-shot tasks are already
|
||||
* deleted from the file when this yields; recurring tasks are rescheduled
|
||||
* (or deleted if aged out).
|
||||
* - `missed` — one-shot tasks whose window passed while the daemon was down.
|
||||
* Yielded once on initial load; a background delete removes them from the
|
||||
* file shortly after.
|
||||
*
|
||||
* Intended for daemon architectures that own the scheduler externally and
|
||||
* spawn the agent via `query()`; the agent subprocess (`-p` mode) does not
|
||||
* run its own scheduler.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export function watchScheduledTasks(_opts: {
|
||||
dir: string
|
||||
signal: AbortSignal
|
||||
getJitterConfig?: () => CronJitterConfig
|
||||
}): ScheduledTasksHandle {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format missed one-shot tasks into a prompt that asks the model to confirm
|
||||
* with the user (via AskUserQuestion) before executing.
|
||||
* @internal
|
||||
*/
|
||||
export function buildMissedTaskNotification(_missed: CronTask[]): string {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
/**
|
||||
* A user message typed on claude.ai, extracted from the bridge WS.
|
||||
* @internal
|
||||
*/
|
||||
export type InboundPrompt = {
|
||||
content: string | unknown[]
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for connectRemoteControl.
|
||||
* @internal
|
||||
*/
|
||||
export type ConnectRemoteControlOptions = {
|
||||
dir: string
|
||||
name?: string
|
||||
workerType?: string
|
||||
branch?: string
|
||||
gitRepoUrl?: string | null
|
||||
getAccessToken: () => string | undefined
|
||||
baseUrl: string
|
||||
orgUUID: string
|
||||
model: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle returned by connectRemoteControl. Write query() yields in,
|
||||
* read inbound prompts out. See src/assistant/daemonBridge.ts for full
|
||||
* field documentation.
|
||||
* @internal
|
||||
*/
|
||||
export type RemoteControlHandle = {
|
||||
sessionUrl: string
|
||||
environmentId: string
|
||||
bridgeSessionId: string
|
||||
write(msg: SDKMessage): void
|
||||
sendResult(): void
|
||||
sendControlRequest(req: unknown): void
|
||||
sendControlResponse(res: unknown): void
|
||||
sendControlCancelRequest(requestId: string): void
|
||||
inboundPrompts(): AsyncGenerator<InboundPrompt>
|
||||
controlRequests(): AsyncGenerator<unknown>
|
||||
permissionResponses(): AsyncGenerator<unknown>
|
||||
onStateChange(
|
||||
cb: (
|
||||
state: 'ready' | 'connected' | 'reconnecting' | 'failed',
|
||||
detail?: string,
|
||||
) => void,
|
||||
): void
|
||||
teardown(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a claude.ai remote-control bridge connection from a daemon process.
|
||||
*
|
||||
* The daemon owns the WebSocket in the PARENT process — if the agent
|
||||
* subprocess (spawned via `query()`) crashes, the daemon respawns it while
|
||||
* claude.ai keeps the same session. Contrast with `query.enableRemoteControl`
|
||||
* which puts the WS in the CHILD process (dies with the agent).
|
||||
*
|
||||
* Pipe `query()` yields through `write()` + `sendResult()`. Read
|
||||
* `inboundPrompts()` (user typed on claude.ai) into `query()`'s input
|
||||
* stream. Handle `controlRequests()` locally (interrupt → abort, set_model
|
||||
* → reconfigure).
|
||||
*
|
||||
* Skips the `tengu_ccr_bridge` gate and policy-limits check — @internal
|
||||
* caller is pre-entitled. OAuth is still required (env var or keychain).
|
||||
*
|
||||
* Returns null on no-OAuth or registration failure.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
export async function connectRemoteControl(
|
||||
_opts: ConnectRemoteControlOptions,
|
||||
): Promise<RemoteControlHandle | null> {
|
||||
throw new Error('not implemented')
|
||||
}
|
||||
|
||||
/** 会话钩子事件名(与 `HOOK_EVENTS` / settings schema 一致)。 */
|
||||
export type HookEvent = (typeof HOOK_EVENTS)[number] // 与 `coreSchemas.HOOK_EVENTS` 逐项对应
|
||||
|
||||
|
||||
@@ -314,25 +314,6 @@ async function main(): Promise<void> {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Fast-path for `claude environment-runner`: headless BYOC runner.
|
||||
// feature() must stay inline for build-time dead code elimination.
|
||||
if (feature('BYOC_ENVIRONMENT_RUNNER') && args[0] === 'environment-runner') {
|
||||
profileCheckpoint('cli_environment_runner_path');
|
||||
const { environmentRunnerMain } = await import('../environment-runner/main.js');
|
||||
await environmentRunnerMain(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for `claude self-hosted-runner`: headless self-hosted-runner
|
||||
// targeting the SelfHostedRunnerWorkerService API (register + poll; poll IS
|
||||
// heartbeat). feature() must stay inline for build-time dead code elimination.
|
||||
if (feature('SELF_HOSTED_RUNNER') && args[0] === 'self-hosted-runner') {
|
||||
profileCheckpoint('cli_self_hosted_runner_path');
|
||||
const { selfHostedRunnerMain } = await import('../self-hosted-runner/main.js');
|
||||
await selfHostedRunnerMain(args.slice(1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast-path for --worktree --tmux: exec into tmux before loading full CLI
|
||||
const hasTmuxFlag = args.includes('--tmux') || args.includes('--tmux=classic');
|
||||
if (
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {}
|
||||
export const environmentRunnerMain: (args: string[]) => Promise<void> = () =>
|
||||
Promise.resolve()
|
||||
@@ -454,19 +454,3 @@ function handleDelete(path: string): void {
|
||||
export function getCachedKeybindingWarnings(): KeybindingWarning[] {
|
||||
return cachedWarnings
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset internal state for testing.
|
||||
*/
|
||||
export function resetKeybindingLoaderForTesting(): void {
|
||||
initialized = false
|
||||
disposed = false
|
||||
cachedBindings = null
|
||||
cachedWarnings = []
|
||||
lastCustomBindingsLogDate = null
|
||||
if (watcher) {
|
||||
void watcher.close()
|
||||
watcher = null
|
||||
}
|
||||
keybindingsChanged.clear()
|
||||
}
|
||||
|
||||
91
src/main.tsx
91
src/main.tsx
@@ -4238,19 +4238,24 @@ async function run(): Promise<CommanderCommand> {
|
||||
}
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
if (options.resume && typeof options.resume === 'string' && !maybeSessionId) {
|
||||
// Check for ccshare URL (e.g. https://go/ccshare/boris-20260311-211036)
|
||||
const { parseCcshareId, loadCcshare } = await import('./utils/ccshareResume.js');
|
||||
const ccshareId = parseCcshareId(options.resume);
|
||||
if (ccshareId) {
|
||||
const resolvedPath = resolve(options.resume);
|
||||
try {
|
||||
const resumeStart = performance.now();
|
||||
let logOption;
|
||||
try {
|
||||
const resumeStart = performance.now();
|
||||
const logOption = await loadCcshare(ccshareId);
|
||||
const result = await loadConversationForResume(logOption, undefined);
|
||||
// Attempt to load as a transcript file; ENOENT falls through to session-ID handling
|
||||
logOption = await loadTranscriptFromFile(resolvedPath);
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error;
|
||||
// ENOENT: not a file path — fall through to session-ID handling
|
||||
}
|
||||
if (logOption) {
|
||||
const result = await loadConversationForResume(logOption, undefined /* sourceFile */);
|
||||
if (result) {
|
||||
processedResume = await processResumedConversation(
|
||||
result,
|
||||
{
|
||||
forkSession: true,
|
||||
forkSession: !!options.forkSession,
|
||||
transcriptPath: result.fullPath,
|
||||
},
|
||||
resumeContext,
|
||||
@@ -4259,74 +4264,26 @@ async function run(): Promise<CommanderCommand> {
|
||||
mainThreadAgentDefinition = processedResume.restoredAgentDef;
|
||||
}
|
||||
logEvent('tengu_session_resumed', {
|
||||
entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
success: true,
|
||||
resume_duration_ms: Math.round(performance.now() - resumeStart),
|
||||
});
|
||||
} else {
|
||||
logEvent('tengu_session_resumed', {
|
||||
entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logEvent('tengu_session_resumed', {
|
||||
entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
success: false,
|
||||
});
|
||||
logError(error);
|
||||
await exitWithError(root, `Unable to resume from ccshare: ${errorMessage(error)}`, () =>
|
||||
gracefulShutdown(1),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const resolvedPath = resolve(options.resume);
|
||||
try {
|
||||
const resumeStart = performance.now();
|
||||
let logOption;
|
||||
try {
|
||||
// Attempt to load as a transcript file; ENOENT falls through to session-ID handling
|
||||
logOption = await loadTranscriptFromFile(resolvedPath);
|
||||
} catch (error) {
|
||||
if (!isENOENT(error)) throw error;
|
||||
// ENOENT: not a file path — fall through to session-ID handling
|
||||
}
|
||||
if (logOption) {
|
||||
const result = await loadConversationForResume(logOption, undefined /* sourceFile */);
|
||||
if (result) {
|
||||
processedResume = await processResumedConversation(
|
||||
result,
|
||||
{
|
||||
forkSession: !!options.forkSession,
|
||||
transcriptPath: result.fullPath,
|
||||
},
|
||||
resumeContext,
|
||||
);
|
||||
if (processedResume.restoredAgentDef) {
|
||||
mainThreadAgentDefinition = processedResume.restoredAgentDef;
|
||||
}
|
||||
logEvent('tengu_session_resumed', {
|
||||
entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
success: true,
|
||||
resume_duration_ms: Math.round(performance.now() - resumeStart),
|
||||
});
|
||||
} else {
|
||||
logEvent('tengu_session_resumed', {
|
||||
entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
success: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logEvent('tengu_session_resumed', {
|
||||
entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
success: false,
|
||||
});
|
||||
logError(error);
|
||||
await exitWithError(root, `Unable to load transcript from file: ${options.resume}`, () =>
|
||||
gracefulShutdown(1),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logEvent('tengu_session_resumed', {
|
||||
entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
success: false,
|
||||
});
|
||||
logError(error);
|
||||
await exitWithError(root, `Unable to load transcript from file: ${options.resume}`, () =>
|
||||
gracefulShutdown(1),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,22 +234,6 @@ export const getAutoMemPath = memoize(
|
||||
() => getProjectRoot(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Returns the daily log file path for the given date (defaults to today).
|
||||
* Shape: <autoMemPath>/logs/YYYY/MM/YYYY-MM-DD.md
|
||||
*
|
||||
* Used by assistant mode (feature('KAIROS')): rather than maintaining
|
||||
* MEMORY.md as a live index, the agent appends to a date-named log file
|
||||
* as it works. A separate nightly /dream skill distills these logs into
|
||||
* topic files + MEMORY.md.
|
||||
*/
|
||||
export function getAutoMemDailyLogPath(date: Date = new Date()): string {
|
||||
const yyyy = date.getFullYear().toString()
|
||||
const mm = (date.getMonth() + 1).toString().padStart(2, '0')
|
||||
const dd = date.getDate().toString().padStart(2, '0')
|
||||
return join(getAutoMemPath(), 'logs', yyyy, mm, `${yyyy}-${mm}-${dd}.md`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the auto-memory entrypoint (MEMORY.md inside the auto-memory dir).
|
||||
* Follows the same resolution order as getAutoMemPath().
|
||||
|
||||
@@ -313,13 +313,3 @@ export function isSessionEndMessage(msg: SDKMessage): boolean {
|
||||
export function isSuccessResult(msg: SDKResultMessage): boolean {
|
||||
return msg.subtype === 'success'
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the result text from a successful SDKResultMessage
|
||||
*/
|
||||
export function getResultText(msg: SDKResultMessage): string | null {
|
||||
if (msg.subtype === 'success') {
|
||||
return msg.result ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {}
|
||||
export const selfHostedRunnerMain: (args: string[]) => Promise<void> = () =>
|
||||
Promise.resolve()
|
||||
@@ -26,6 +26,8 @@ import {
|
||||
} from '../../../bootstrap/state.js'
|
||||
import type { SessionId } from '../../../types/ids.js'
|
||||
import { enableConfigs } from '../../../utils/config.js'
|
||||
import { applySafeConfigEnvironmentVariables } from '../../../utils/managedEnv.js'
|
||||
import { resetSettingsCache } from '../../../utils/settings/settingsCache.js'
|
||||
import { FileStateCache } from '../../../utils/fileStateCache.js'
|
||||
import { getDefaultAppState } from '../../../state/AppStateStore.js'
|
||||
import type { AppState } from '../../../state/AppStateStore.js'
|
||||
@@ -89,6 +91,16 @@ async function createSession(
|
||||
// CWD may not exist yet; best-effort
|
||||
}
|
||||
|
||||
// entry.ts calls applySafeConfigEnvironmentVariables() during handshake so the
|
||||
// API client can authenticate before createSession arrives. At that point
|
||||
// getOriginalCwd() is still the spawn cwd (not the project dir), so
|
||||
// loadSettingsFromDisk() resolves localSettings/projectSettings against the
|
||||
// wrong root and caches the empty result. Now that we've set the real project
|
||||
// cwd, drop the cache and re-apply so settings.local.json and project env
|
||||
// become visible to readSettingsPermissionMode() and downstream consumers.
|
||||
resetSettingsCache()
|
||||
applySafeConfigEnvironmentVariables()
|
||||
|
||||
try {
|
||||
// Build tools with a permissive permission context.
|
||||
const permissionContext = getEmptyToolPermissionContext()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Shared utilities for the ACP service.
|
||||
* Ported from claude-agent-acp-main/src/utils.ts and acp-agent.ts helpers.
|
||||
*/
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { Writable } from 'node:stream'
|
||||
import type { PermissionMode } from '../../entrypoints/sdk/coreTypes.generated.js'
|
||||
|
||||
// ── Pushable ──────────────────────────────────────────────────────
|
||||
@@ -71,20 +71,6 @@ export function nodeToWebWritable(
|
||||
})
|
||||
}
|
||||
|
||||
export function nodeToWebReadable(
|
||||
nodeStream: Readable,
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
nodeStream.on('data', (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk))
|
||||
})
|
||||
nodeStream.on('end', () => controller.close())
|
||||
nodeStream.on('error', err => controller.error(err))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── unreachable ───────────────────────────────────────────────────
|
||||
|
||||
export function unreachable(
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
/**
|
||||
* Regression tests for fetchUltrareviewPreflight.
|
||||
* Verifies all three action enum states (proceed/confirm/blocked),
|
||||
* network/HTTP error handling, and Zod schema mismatch fallback.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, mock, test } from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
// Mock dependency chain before any subject import
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: () => {},
|
||||
}))
|
||||
|
||||
// Mock auth utilities
|
||||
mock.module('src/utils/auth.js', () => ({
|
||||
isClaudeAISubscriber: () => true,
|
||||
isTeamSubscriber: () => false,
|
||||
isEnterpriseSubscriber: () => false,
|
||||
}))
|
||||
|
||||
// Mock OAuth config
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
|
||||
// Mock prepareApiRequest and getOAuthHeaders
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
prepareApiRequest: async () => ({
|
||||
accessToken: 'test-token',
|
||||
orgUUID: 'org-uuid-test',
|
||||
}),
|
||||
getOAuthHeaders: (token: string) => ({
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
}),
|
||||
}))
|
||||
|
||||
// We'll mock axios at module level.
|
||||
// Typed as any in test code (CLAUDE.md: mock data may use as any).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mockAxiosPost = mock(async (..._args: any[]): Promise<any> => {
|
||||
throw new Error('not configured')
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.post = mockAxiosPost
|
||||
axiosHandle.stubs.isAxiosError = (e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
(e as { isAxiosError?: boolean }).isAxiosError === true
|
||||
|
||||
beforeAll(() => {
|
||||
axiosHandle.useStubs = true
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
import {
|
||||
fetchUltrareviewPreflight,
|
||||
type UltrareviewPreflightResponse,
|
||||
} from '../ultrareviewPreflight.js'
|
||||
|
||||
describe('fetchUltrareviewPreflight', () => {
|
||||
test('returns proceed action when server responds with proceed', async () => {
|
||||
const serverResponse: UltrareviewPreflightResponse = {
|
||||
action: 'proceed',
|
||||
billing_note: null,
|
||||
}
|
||||
mockAxiosPost.mockImplementationOnce(async () => ({
|
||||
status: 200,
|
||||
data: serverResponse,
|
||||
}))
|
||||
|
||||
const result = await fetchUltrareviewPreflight({ repo: 'owner/repo' })
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.action).toBe('proceed')
|
||||
expect(result?.billing_note).toBeNull()
|
||||
})
|
||||
|
||||
test('returns confirm action with billing_note when server responds with confirm', async () => {
|
||||
const serverResponse: UltrareviewPreflightResponse = {
|
||||
action: 'confirm',
|
||||
billing_note: 'This run will cost approximately $2.50.',
|
||||
}
|
||||
mockAxiosPost.mockImplementationOnce(async () => ({
|
||||
status: 200,
|
||||
data: serverResponse,
|
||||
}))
|
||||
|
||||
const result = await fetchUltrareviewPreflight({ repo: 'owner/repo' })
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.action).toBe('confirm')
|
||||
expect(result?.billing_note).toBe('This run will cost approximately $2.50.')
|
||||
})
|
||||
|
||||
test('returns blocked action when server responds with blocked', async () => {
|
||||
const serverResponse: UltrareviewPreflightResponse = {
|
||||
action: 'blocked',
|
||||
billing_note: null,
|
||||
}
|
||||
mockAxiosPost.mockImplementationOnce(async () => ({
|
||||
status: 200,
|
||||
data: serverResponse,
|
||||
}))
|
||||
|
||||
const result = await fetchUltrareviewPreflight({ repo: 'owner/repo' })
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.action).toBe('blocked')
|
||||
})
|
||||
|
||||
test('returns null on schema mismatch (invalid action value)', async () => {
|
||||
mockAxiosPost.mockImplementationOnce(async () => ({
|
||||
status: 200,
|
||||
data: { action: 'unknown_action', billing_note: null },
|
||||
}))
|
||||
|
||||
const result = await fetchUltrareviewPreflight({ repo: 'owner/repo' })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null on network error (no response)', async () => {
|
||||
const networkError = new Error('ECONNREFUSED')
|
||||
;(networkError as unknown as { isAxiosError: boolean }).isAxiosError = true
|
||||
mockAxiosPost.mockImplementationOnce(async () => {
|
||||
throw networkError
|
||||
})
|
||||
|
||||
const result = await fetchUltrareviewPreflight({ repo: 'owner/repo' })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null on 401 Unauthorized', async () => {
|
||||
const authError = new Error('Unauthorized')
|
||||
;(
|
||||
authError as unknown as {
|
||||
isAxiosError: boolean
|
||||
response: { status: number }
|
||||
}
|
||||
).isAxiosError = true
|
||||
;(authError as unknown as { response: { status: number } }).response = {
|
||||
status: 401,
|
||||
}
|
||||
mockAxiosPost.mockImplementationOnce(async () => {
|
||||
throw authError
|
||||
})
|
||||
|
||||
const result = await fetchUltrareviewPreflight({ repo: 'owner/repo' })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null on 403 Forbidden', async () => {
|
||||
const forbiddenError = new Error('Forbidden')
|
||||
;(
|
||||
forbiddenError as unknown as {
|
||||
isAxiosError: boolean
|
||||
response: { status: number }
|
||||
}
|
||||
).isAxiosError = true
|
||||
;(forbiddenError as unknown as { response: { status: number } }).response =
|
||||
{ status: 403 }
|
||||
mockAxiosPost.mockImplementationOnce(async () => {
|
||||
throw forbiddenError
|
||||
})
|
||||
|
||||
const result = await fetchUltrareviewPreflight({ repo: 'owner/repo' })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('returns null on 5xx server error', async () => {
|
||||
const serverError = new Error('Internal Server Error')
|
||||
;(
|
||||
serverError as unknown as {
|
||||
isAxiosError: boolean
|
||||
response: { status: number }
|
||||
}
|
||||
).isAxiosError = true
|
||||
;(serverError as unknown as { response: { status: number } }).response = {
|
||||
status: 500,
|
||||
}
|
||||
mockAxiosPost.mockImplementationOnce(async () => {
|
||||
throw serverError
|
||||
})
|
||||
|
||||
const result = await fetchUltrareviewPreflight({ repo: 'owner/repo' })
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test('passes pr_number to request body when provided', async () => {
|
||||
mockAxiosPost.mockImplementationOnce(
|
||||
async (_url: unknown, body: unknown) => {
|
||||
const b = body as { pr_number: number }
|
||||
expect(b.pr_number).toBe(42)
|
||||
return { status: 200, data: { action: 'proceed', billing_note: null } }
|
||||
},
|
||||
)
|
||||
|
||||
const result = await fetchUltrareviewPreflight({
|
||||
repo: 'owner/repo',
|
||||
pr_number: 42,
|
||||
})
|
||||
expect(result?.action).toBe('proceed')
|
||||
})
|
||||
|
||||
test('passes confirm flag to request body when provided', async () => {
|
||||
mockAxiosPost.mockImplementationOnce(
|
||||
async (_url: unknown, body: unknown) => {
|
||||
const b = body as { confirm: boolean }
|
||||
expect(b.confirm).toBe(true)
|
||||
return { status: 200, data: { action: 'proceed', billing_note: null } }
|
||||
},
|
||||
)
|
||||
|
||||
const result = await fetchUltrareviewPreflight({
|
||||
repo: 'owner/repo',
|
||||
confirm: true,
|
||||
})
|
||||
expect(result?.action).toBe('proceed')
|
||||
})
|
||||
})
|
||||
@@ -130,7 +130,7 @@ export function getPromptTooLongTokenGap(
|
||||
* wording drift causes graceful degradation (errorDetails stays undefined,
|
||||
* caller short-circuits), not a false negative.
|
||||
*/
|
||||
export function isMediaSizeError(raw: string): boolean {
|
||||
function isMediaSizeError(raw: string): boolean {
|
||||
return (
|
||||
(raw.includes('image exceeds') && raw.includes('maximum')) ||
|
||||
(raw.includes('image dimensions exceed') && raw.includes('many-image')) ||
|
||||
|
||||
@@ -152,8 +152,3 @@ export async function checkMetricsEnabled(): Promise<MetricsStatus> {
|
||||
// First-ever run on this machine: block on the network to populate disk.
|
||||
return refreshMetricsStatus()
|
||||
}
|
||||
|
||||
// Export for testing purposes only
|
||||
export const _clearMetricsEnabledCacheForTesting = (): void => {
|
||||
memoizedCheckMetrics.cache.clear()
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import axios from 'axios'
|
||||
import z from 'zod/v4'
|
||||
import { getOauthConfig } from '../../constants/oauth.js'
|
||||
import { logForDebugging } from '../../utils/debug.js'
|
||||
import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js'
|
||||
|
||||
/**
|
||||
* Zod schema for the /v1/ultrareview/preflight response.
|
||||
* Based on binary-extracted schema: vq.object({action: vq.enum([...]), billing_note: ...})
|
||||
*/
|
||||
const UltrareviewPreflightSchema = z.object({
|
||||
action: z.enum(['proceed', 'confirm', 'blocked']),
|
||||
billing_note: z.string().nullable().optional(),
|
||||
})
|
||||
|
||||
export type UltrareviewPreflightResponse = z.infer<
|
||||
typeof UltrareviewPreflightSchema
|
||||
>
|
||||
|
||||
export type UltrareviewPreflightArgs = {
|
||||
repo: string
|
||||
pr_number?: number
|
||||
pr_url?: string
|
||||
confirm?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/ultrareview/preflight — server-side gate before launch.
|
||||
*
|
||||
* Returns the preflight result (proceed / confirm / blocked) or null on any
|
||||
* failure (network error, auth error, schema mismatch). Callers must treat
|
||||
* null as "fallback to direct launch" to preserve existing behavior.
|
||||
*
|
||||
* The `confirm` flag should be set to true when the user has already
|
||||
* acknowledged the billing dialog (or passed --confirm on the CLI), which
|
||||
* skips the server-side confirm prompt and gets a direct proceed/blocked.
|
||||
*/
|
||||
export async function fetchUltrareviewPreflight(
|
||||
args: UltrareviewPreflightArgs,
|
||||
): Promise<UltrareviewPreflightResponse | null> {
|
||||
try {
|
||||
const { accessToken, orgUUID } = await prepareApiRequest()
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
repo: args.repo,
|
||||
}
|
||||
if (args.pr_number !== undefined) {
|
||||
body.pr_number = args.pr_number
|
||||
}
|
||||
if (args.pr_url !== undefined) {
|
||||
body.pr_url = args.pr_url
|
||||
}
|
||||
if (args.confirm !== undefined) {
|
||||
body.confirm = args.confirm
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${getOauthConfig().BASE_API_URL}/v1/ultrareview/preflight`,
|
||||
body,
|
||||
{
|
||||
headers: {
|
||||
...getOAuthHeaders(accessToken),
|
||||
'x-organization-uuid': orgUUID,
|
||||
},
|
||||
timeout: 10000,
|
||||
},
|
||||
)
|
||||
|
||||
const parsed = UltrareviewPreflightSchema.safeParse(response.data)
|
||||
if (!parsed.success) {
|
||||
logForDebugging(
|
||||
`fetchUltrareviewPreflight: schema mismatch — ${parsed.error.message}`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
return parsed.data
|
||||
} catch (error) {
|
||||
logForDebugging(`fetchUltrareviewPreflight failed: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -544,7 +544,7 @@ export function getRetryDelay(
|
||||
return baseDelay + jitter
|
||||
}
|
||||
|
||||
export function parseMaxTokensContextOverflowError(error: APIError):
|
||||
function parseMaxTokensContextOverflowError(error: APIError):
|
||||
| {
|
||||
inputTokens: number
|
||||
maxTokens: number
|
||||
|
||||
@@ -78,18 +78,6 @@ const EARLY_WARNING_CLAIM_MAP: Record<string, RateLimitType> = {
|
||||
overage: 'overage',
|
||||
}
|
||||
|
||||
const RATE_LIMIT_DISPLAY_NAMES: Record<RateLimitType, string> = {
|
||||
five_hour: 'session limit',
|
||||
seven_day: 'weekly limit',
|
||||
seven_day_opus: 'Opus limit',
|
||||
seven_day_sonnet: 'Sonnet limit',
|
||||
overage: 'extra usage limit',
|
||||
}
|
||||
|
||||
export function getRateLimitDisplayName(type: RateLimitType): string {
|
||||
return RATE_LIMIT_DISPLAY_NAMES[type] || type
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate what fraction of a time window has elapsed.
|
||||
* Used for time-relative early warning fallback.
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
export {}
|
||||
export const getCachedMCConfig: () => {
|
||||
enabled?: boolean
|
||||
systemPromptSuggestSummaries?: boolean
|
||||
supportedModels?: string[]
|
||||
[key: string]: unknown
|
||||
} = () => ({})
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Audit rules constants for goal completion and blocked assessment.
|
||||
* Shared by prompt templates and integration tests.
|
||||
*/
|
||||
import { BLOCKED_CONSECUTIVE_THRESHOLD, MAX_GOAL_TURNS } from './goalState.js'
|
||||
import type { GoalStatus } from '../../types/logs.js'
|
||||
|
||||
export { BLOCKED_CONSECUTIVE_THRESHOLD, MAX_GOAL_TURNS }
|
||||
|
||||
export const COMPLETION_AUDIT_RULES = [
|
||||
'Derive concrete requirements from the objective and any referenced files.',
|
||||
'Preserve the original scope — do not redefine success around what is already done.',
|
||||
'For every explicit requirement, identify authoritative evidence (test output, file content, command result).',
|
||||
'Treat tests, manifests, and verifiers as evidence only after confirming they actually cover the requirement.',
|
||||
'Treat uncertain or indirect evidence as "not achieved".',
|
||||
'The audit must PROVE completion, not merely fail to find remaining work.',
|
||||
] as const
|
||||
|
||||
export const BLOCKED_AUDIT_RULES = [
|
||||
'The same blocking condition must persist across at least 3 consecutive continuation turns.',
|
||||
'"Difficult", "slow", or "partially incomplete" is NOT blocked.',
|
||||
'Only genuinely insurmountable obstacles qualify (missing credentials, external service down, etc.).',
|
||||
] as const
|
||||
|
||||
export function isGoalTerminal(status: GoalStatus): boolean {
|
||||
return (
|
||||
status === 'complete' ||
|
||||
status === 'blocked' ||
|
||||
status === 'budget_limited' ||
|
||||
status === 'usage_limited' ||
|
||||
status === 'max_turns'
|
||||
)
|
||||
}
|
||||
@@ -32,7 +32,7 @@ const getKubernetesNamespace = memoize(async (): Promise<string | null> => {
|
||||
/**
|
||||
* Get the OCI container ID from within a running container
|
||||
*/
|
||||
export const getContainerId = memoize(async (): Promise<string | null> => {
|
||||
const getContainerId = memoize(async (): Promise<string | null> => {
|
||||
if (process.env.USER_TYPE !== 'ant') {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -377,10 +377,3 @@ export function clearDeliveredDiagnosticsForFile(fileUri: string): void {
|
||||
deliveredDiagnostics.delete(fileUri)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of pending diagnostics (for monitoring)
|
||||
*/
|
||||
export function getPendingLSPDiagnosticCount(): number {
|
||||
return pendingDiagnostics.size
|
||||
}
|
||||
|
||||
@@ -39,19 +39,6 @@ let initializationGeneration = 0
|
||||
*/
|
||||
let initializationPromise: Promise<void> | undefined
|
||||
|
||||
/**
|
||||
* Test-only sync reset. shutdownLspServerManager() is async and tears down
|
||||
* real connections; this only clears the module-scope singleton state so
|
||||
* reinitializeLspServerManager() early-returns on 'not-started' in downstream
|
||||
* tests on the same shard.
|
||||
*/
|
||||
export function _resetLspManagerForTesting(): void {
|
||||
initializationState = 'not-started'
|
||||
initializationError = undefined
|
||||
initializationPromise = undefined
|
||||
initializationGeneration++
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton LSP server manager instance.
|
||||
* Returns undefined if not yet initialized, initialization failed, or still pending.
|
||||
|
||||
@@ -246,15 +246,6 @@ export function isMcpTool(tool: Tool): boolean {
|
||||
return tool.name?.startsWith('mcp__') || tool.isMcp === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a command belongs to any MCP server
|
||||
* @param command The command to check
|
||||
* @returns True if the command is from an MCP server
|
||||
*/
|
||||
export function isMcpCommand(command: Command): boolean {
|
||||
return command.name?.startsWith('mcp__') || command.isMcp === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe the file path for a given MCP config scope.
|
||||
* @param scope The config scope ('user', 'project', 'local', or 'dynamic')
|
||||
|
||||
@@ -100,11 +100,6 @@ export function resolveProjectContext(
|
||||
return resolved
|
||||
}
|
||||
|
||||
export function resetProjectContextCacheForTest(): void {
|
||||
contextCache.clear()
|
||||
lastPersistAt = 0
|
||||
}
|
||||
|
||||
export function listKnownProjects(): SkillLearningProjectRecord[] {
|
||||
const registry = readProjectsRegistry(getProjectsRegistryPath())
|
||||
return Object.values(registry.projects).sort((a, b) =>
|
||||
|
||||
@@ -301,24 +301,3 @@ export function scanForSecrets(content: string): SecretMatch[] {
|
||||
export function getSecretLabel(ruleId: string): string {
|
||||
return ruleIdToLabel(ruleId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Redact any matched secrets in-place with [REDACTED].
|
||||
* Unlike scanForSecrets, this returns the content with spans replaced
|
||||
* so the surrounding text can still be written to disk safely.
|
||||
*/
|
||||
let redactRules: RegExp[] | null = null
|
||||
|
||||
export function redactSecrets(content: string): string {
|
||||
redactRules ??= SECRET_RULES.map(
|
||||
r => new RegExp(r.source, (r.flags ?? '').replace('g', '') + 'g'),
|
||||
)
|
||||
for (const re of redactRules) {
|
||||
// Replace only the captured group, not the full match — patterns include
|
||||
// boundary chars (space, quote, ;) outside the group that must survive.
|
||||
content = content.replace(re, (match, g1) =>
|
||||
typeof g1 === 'string' ? match.replace(g1, '[REDACTED]') : '[REDACTED]',
|
||||
)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -350,38 +350,3 @@ export async function stopTeamMemoryWatcher(): Promise<void> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: reset module state and optionally seed syncState.
|
||||
* The feature('TEAMMEM') gate at the top of startTeamMemoryWatcher() is
|
||||
* always false in bun test, so tests can't set syncState through the normal
|
||||
* path. This helper lets tests drive notifyTeamMemoryWrite() /
|
||||
* stopTeamMemoryWatcher() directly.
|
||||
*
|
||||
* `skipWatcher: true` marks the watcher as already-started without actually
|
||||
* starting it. Tests that only exercise the schedulePush/flush path don't
|
||||
* need a real watcher.
|
||||
*/
|
||||
export function _resetWatcherStateForTesting(opts?: {
|
||||
syncState?: SyncState
|
||||
skipWatcher?: boolean
|
||||
pushSuppressedReason?: string | null
|
||||
}): void {
|
||||
watcher = null
|
||||
debounceTimer = null
|
||||
pushInProgress = false
|
||||
hasPendingChanges = false
|
||||
currentPushPromise = null
|
||||
watcherStarted = opts?.skipWatcher ?? false
|
||||
pushSuppressedReason = opts?.pushSuppressedReason ?? null
|
||||
syncState = opts?.syncState ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: start the real fs.watch on a specified directory.
|
||||
* Used by the fd-count regression test — startTeamMemoryWatcher() is gated
|
||||
* by feature('TEAMMEM') which is false under bun test.
|
||||
*/
|
||||
export function _startFileWatcherForTesting(dir: string): Promise<void> {
|
||||
return startFileWatcher(dir)
|
||||
}
|
||||
|
||||
@@ -1057,13 +1057,6 @@ export function activateConditionalSkillsForPaths(
|
||||
return activated
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of pending conditional skills (for testing/debugging).
|
||||
*/
|
||||
export function getConditionalSkillCount(): number {
|
||||
return conditionalSkills.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears dynamic skill state (for testing).
|
||||
*/
|
||||
|
||||
5
src/types/global.d.ts
vendored
5
src/types/global.d.ts
vendored
@@ -51,11 +51,6 @@ declare function ExperimentEnrollmentNotice(): JSX.Element | null
|
||||
// Hook timing threshold (re-exported from services/tools/toolExecution.ts)
|
||||
declare const HOOK_TIMING_DISPLAY_THRESHOLD_MS: number
|
||||
|
||||
// Ultraplan (internal)
|
||||
// declare function UltraplanChoiceDialog(props: Record<string, unknown>): JSX.Element | null
|
||||
// declare function UltraplanLaunchDialog(props: Record<string, unknown>): JSX.Element | null
|
||||
// declare function launchUltraplan(...args: unknown[]): Promise<string>
|
||||
|
||||
// T — Generic type parameter leaked from React compiler output
|
||||
// (react/compiler-runtime emits compiled JSX that loses generic type params)
|
||||
declare type T = unknown
|
||||
|
||||
@@ -191,9 +191,6 @@ export function isAsyncHookJSONOutput(
|
||||
|
||||
// Compile-time assertion that SDK and Zod types match
|
||||
// Disabled: decompilation type mismatch makes these types non-equal
|
||||
// import type { IsEqual } from 'type-fest'
|
||||
// type Assert<T extends true> = T
|
||||
// type _assertSDKTypesMatch = Assert<IsEqual<SchemaHookJSONOutput, HookJSONOutput>>
|
||||
|
||||
/** Context passed to callback hooks for state access */
|
||||
export type HookCallbackContext = {
|
||||
|
||||
@@ -91,11 +91,6 @@ export type BaseTextInputProps = {
|
||||
*/
|
||||
readonly onExitMessage?: (show: boolean, key?: string) => void
|
||||
|
||||
/**
|
||||
* Optional callback to show custom message
|
||||
*/
|
||||
// readonly onMessage?: (show: boolean, message?: string) => void
|
||||
|
||||
/**
|
||||
* Optional callback to reset history position
|
||||
*/
|
||||
|
||||
@@ -51,26 +51,6 @@ export function getLastKill(): string {
|
||||
return killRing[0] ?? ''
|
||||
}
|
||||
|
||||
export function getKillRingItem(index: number): string {
|
||||
if (killRing.length === 0) return ''
|
||||
const normalizedIndex =
|
||||
((index % killRing.length) + killRing.length) % killRing.length
|
||||
return killRing[normalizedIndex] ?? ''
|
||||
}
|
||||
|
||||
export function getKillRingSize(): number {
|
||||
return killRing.length
|
||||
}
|
||||
|
||||
export function clearKillRing(): void {
|
||||
killRing = []
|
||||
killRingIndex = 0
|
||||
lastActionWasKill = false
|
||||
lastActionWasYank = false
|
||||
lastYankStart = 0
|
||||
lastYankLength = 0
|
||||
}
|
||||
|
||||
export function resetKillAccumulation(): void {
|
||||
lastActionWasKill = false
|
||||
}
|
||||
@@ -83,10 +63,6 @@ export function recordYank(start: number, length: number): void {
|
||||
killRingIndex = 0
|
||||
}
|
||||
|
||||
export function canYankPop(): boolean {
|
||||
return lastActionWasYank && killRing.length > 1
|
||||
}
|
||||
|
||||
export function yankPop(): {
|
||||
text: string
|
||||
start: number
|
||||
@@ -130,7 +106,7 @@ export function resetYankState(): void {
|
||||
*/
|
||||
|
||||
// Pre-compiled regex patterns for Vim word detection (avoid creating in hot loops)
|
||||
export const VIM_WORD_CHAR_REGEX = /^[\p{L}\p{N}\p{M}_]$/u
|
||||
const VIM_WORD_CHAR_REGEX = /^[\p{L}\p{N}\p{M}_]$/u
|
||||
export const WHITESPACE_REGEX = /\s/
|
||||
|
||||
// Exported helper functions for Vim character classification
|
||||
|
||||
@@ -1106,7 +1106,7 @@ export async function getQueuedCommandAttachments(
|
||||
// Include both 'prompt' and 'task-notification' commands as attachments.
|
||||
// During proactive agentic loops, task-notification commands would otherwise
|
||||
// stay in the queue permanently (useQueueProcessor can't run while a query
|
||||
// is active), causing hasPendingNotifications() to return true and Sleep to
|
||||
// is active), causing hasCommandsInQueue() to return true and Sleep to
|
||||
// wake immediately with 0ms duration in an infinite loop.
|
||||
const filtered = queuedCommands.filter(_ =>
|
||||
INLINE_NOTIFICATION_MODES.has(_.mode),
|
||||
|
||||
@@ -1,47 +1,12 @@
|
||||
export const AUTONOMY_COMMAND_NAME = 'autonomy'
|
||||
|
||||
export const AUTONOMY_COMMAND_DESCRIPTION =
|
||||
'Inspect and manage automatic autonomy runs and flows'
|
||||
|
||||
export const AUTONOMY_ARGUMENT_HINT =
|
||||
'[status [--deep]|runs [limit]|flows [limit]|flow <id>|flow cancel <id>|flow resume <id>]'
|
||||
|
||||
export const AUTONOMY_USAGE =
|
||||
'Usage: /autonomy [status [--deep]|runs [limit]|flows [limit]|flow <id>|flow cancel <id>|flow resume <id>]'
|
||||
|
||||
export const AUTONOMY_CLI = {
|
||||
status: {
|
||||
command: 'status',
|
||||
description:
|
||||
'Print autonomy run, flow, team, pipe, and remote-control status',
|
||||
},
|
||||
runs: {
|
||||
command: 'runs [limit]',
|
||||
description: 'List recent autonomy runs',
|
||||
},
|
||||
flows: {
|
||||
command: 'flows [limit]',
|
||||
description: 'List recent autonomy flows',
|
||||
},
|
||||
flow: {
|
||||
command: 'flow',
|
||||
description: 'Inspect or manage a single autonomy flow',
|
||||
argument: '[flowId]',
|
||||
argumentDescription: 'Flow ID to inspect',
|
||||
usage: 'Usage: claude autonomy flow <flow-id>',
|
||||
cancel: {
|
||||
command: 'cancel <flowId>',
|
||||
description: 'Cancel a queued, waiting, or running autonomy flow',
|
||||
},
|
||||
resume: {
|
||||
command: 'resume <flowId>',
|
||||
description:
|
||||
'Resume a waiting autonomy flow and print the prepared prompt',
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export type ParsedAutonomyCommand =
|
||||
type ParsedAutonomyCommand =
|
||||
| { type: 'status'; deep: boolean }
|
||||
| { type: 'runs'; limit?: string }
|
||||
| { type: 'flows'; limit?: string }
|
||||
|
||||
@@ -44,10 +44,3 @@ export async function isBinaryInstalled(command: string): Promise<boolean> {
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the binary check cache (useful for testing)
|
||||
*/
|
||||
export function clearBinaryCache(): void {
|
||||
binaryCache.clear()
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
// Auto-generated stub — replace with real implementation
|
||||
import type { LogOption } from 'src/types/logs.js'
|
||||
export const parseCcshareId: (resume: string) => string | null = () => null
|
||||
export const loadCcshare: (ccshareId: string) => Promise<LogOption> =
|
||||
async () => {
|
||||
throw new Error('ccshare not implemented')
|
||||
}
|
||||
@@ -145,44 +145,6 @@ export function detectCodeIndexingFromCommand(
|
||||
return CLI_COMMAND_MAPPING[firstWord]
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if an MCP tool is from a code indexing server.
|
||||
*
|
||||
* @param toolName - The MCP tool name (format: mcp__serverName__toolName)
|
||||
* @returns The code indexing tool identifier, or undefined if not a code indexing tool
|
||||
*
|
||||
* @example
|
||||
* detectCodeIndexingFromMcpTool('mcp__sourcegraph__search') // returns 'sourcegraph'
|
||||
* detectCodeIndexingFromMcpTool('mcp__cody__chat') // returns 'cody'
|
||||
* detectCodeIndexingFromMcpTool('mcp__filesystem__read') // returns undefined
|
||||
*/
|
||||
export function detectCodeIndexingFromMcpTool(
|
||||
toolName: string,
|
||||
): CodeIndexingTool | undefined {
|
||||
// MCP tool names follow the format: mcp__serverName__toolName
|
||||
if (!toolName.startsWith('mcp__')) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const parts = toolName.split('__')
|
||||
if (parts.length < 3) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const serverName = parts[1]
|
||||
if (!serverName) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
for (const { pattern, tool } of MCP_SERVER_PATTERNS) {
|
||||
if (pattern.test(serverName)) {
|
||||
return tool
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects if an MCP server name corresponds to a code indexing tool.
|
||||
*
|
||||
|
||||
@@ -91,17 +91,13 @@ export async function runFilePersistence(
|
||||
})
|
||||
|
||||
try {
|
||||
let result: FilesPersistedEventData
|
||||
if (environmentKind === 'byoc') {
|
||||
result = await executeBYOCPersistence(
|
||||
turnStartTime,
|
||||
config,
|
||||
outputsDir,
|
||||
signal,
|
||||
)
|
||||
} else {
|
||||
result = await executeCloudPersistence()
|
||||
}
|
||||
// environmentKind === 'byoc' is guaranteed by the early return above
|
||||
const result = await executeBYOCPersistence(
|
||||
turnStartTime,
|
||||
config,
|
||||
outputsDir,
|
||||
signal,
|
||||
)
|
||||
|
||||
// Nothing to report
|
||||
if (result.files.length === 0 && result.failed.length === 0) {
|
||||
@@ -240,16 +236,6 @@ async function executeBYOCPersistence(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Cloud (1P) mode persistence.
|
||||
* TODO: Read file_id from xattr on output files. xattr-based file IDs are
|
||||
* currently being added for 1P environments.
|
||||
*/
|
||||
function executeCloudPersistence(): FilesPersistedEventData {
|
||||
logDebug('Cloud mode: xattr-based file ID reading not yet implemented')
|
||||
return { files: [], failed: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute file persistence and emit result via callback.
|
||||
* Handles errors internally.
|
||||
|
||||
@@ -485,38 +485,6 @@ export function popAllEditable(
|
||||
return { text: newInput, cursorOffset, images }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Backward-compatible aliases (deprecated — prefer new names)
|
||||
// ============================================================================
|
||||
|
||||
/** @deprecated Use subscribeToCommandQueue */
|
||||
export const subscribeToPendingNotifications = subscribeToCommandQueue
|
||||
|
||||
/** @deprecated Use getCommandQueueSnapshot */
|
||||
export function getPendingNotificationsSnapshot(): readonly QueuedCommand[] {
|
||||
return snapshot
|
||||
}
|
||||
|
||||
/** @deprecated Use hasCommandsInQueue */
|
||||
export const hasPendingNotifications = hasCommandsInQueue
|
||||
|
||||
/** @deprecated Use getCommandQueueLength */
|
||||
export const getPendingNotificationsCount = getCommandQueueLength
|
||||
|
||||
/** @deprecated Use recheckCommandQueue */
|
||||
export const recheckPendingNotifications = recheckCommandQueue
|
||||
|
||||
/** @deprecated Use dequeue */
|
||||
export function dequeuePendingNotification(): QueuedCommand | undefined {
|
||||
return dequeue()
|
||||
}
|
||||
|
||||
/** @deprecated Use resetCommandQueue */
|
||||
export const resetPendingNotifications = resetCommandQueue
|
||||
|
||||
/** @deprecated Use clearCommandQueue */
|
||||
export const clearPendingNotifications = clearCommandQueue
|
||||
|
||||
/**
|
||||
* Get commands at or above a given priority level without removing them.
|
||||
* Useful for mid-chain draining where only urgent items should be processed.
|
||||
|
||||
@@ -163,7 +163,7 @@ export function getStartupPerfLogPath(): string {
|
||||
* Log startup performance phases to Statsig.
|
||||
* Only logs if this session was sampled at startup.
|
||||
*/
|
||||
export function logStartupPerf(): void {
|
||||
function logStartupPerf(): void {
|
||||
// Only log if we were sampled (decision made at module load)
|
||||
if (!STATSIG_LOGGING_SAMPLED) return
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { logError } from './log.js'
|
||||
import { jsonParse, jsonStringify } from './slowOperations.js'
|
||||
import type { DailyActivity, DailyModelTokens, SessionStats } from './stats.js'
|
||||
|
||||
export const STATS_CACHE_VERSION = 3
|
||||
const STATS_CACHE_VERSION = 3
|
||||
const MIN_MIGRATABLE_VERSION = 1
|
||||
const STATS_CACHE_FILENAME = 'stats-cache.json'
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import { expect, test } from 'bun:test'
|
||||
import type { AgentProgress, RunProgress } from '../progress/store.js'
|
||||
import {
|
||||
ALL_PHASE,
|
||||
capTabsForDisplay,
|
||||
filterActiveRuns,
|
||||
mergePhases,
|
||||
filterAgentsByPhase,
|
||||
tabLabel,
|
||||
@@ -61,6 +63,57 @@ test('mergePhases: actual but undeclared phase appended to the end', () => {
|
||||
expect(mergePhases(r).map(p => p.title)).toEqual(['Find', 'Adhoc'])
|
||||
})
|
||||
|
||||
// Regression: scripts that pass opts.phase directly to agent() without a phase() hook call
|
||||
// (the ultracode canonical pipeline pattern). phase_started is never emitted for those phases,
|
||||
// so run.phases lacks them. The sidebar used to show them as pending forever while agents were
|
||||
// clearly running under them — and worse, the previous phase stayed "running" because phase_done
|
||||
// only fires on the next phase() call. Derive status from agents when no actual record exists.
|
||||
test('mergePhases: derives status from agents when phase_started was never emitted', () => {
|
||||
// Mirrors the real .claude/workflow-runs/wnxct9u3q/script.js shape:
|
||||
// phase('Map') called, 8 Map agents done; pipeline stage with phase:'Find' running (1/4);
|
||||
// Verify / Synthesize declared but not started; phase('Synthesize') not yet reached so
|
||||
// phase_done Map has not fired either — actual Map is still 'running'.
|
||||
const r = run({
|
||||
declaredPhases: ['Map', 'Find', 'Verify', 'Synthesize'],
|
||||
phases: [{ title: 'Map', status: 'running' }],
|
||||
agents: [
|
||||
...Array.from({ length: 8 }, (_, i) => ({
|
||||
id: i,
|
||||
phase: 'Map',
|
||||
status: 'done' as const,
|
||||
resultKind: 'ok',
|
||||
})),
|
||||
{ id: 100, phase: 'Find', status: 'done', resultKind: 'ok' },
|
||||
{ id: 101, phase: 'Find', status: 'running' },
|
||||
{ id: 102, phase: 'Find', status: 'running' },
|
||||
{ id: 103, phase: 'Find', status: 'running' },
|
||||
],
|
||||
})
|
||||
expect(mergePhases(r)).toEqual([
|
||||
{ title: 'Map', status: 'done', done: 8, total: 8 },
|
||||
{ title: 'Find', status: 'running', done: 1, total: 4 },
|
||||
{ title: 'Verify', status: 'pending', done: 0, total: 0 },
|
||||
{ title: 'Synthesize', status: 'pending', done: 0, total: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
// A phase that appears only on agents (not in declaredPhases, not in run.phases) is still
|
||||
// surfaced so the user sees it in the sidebar.
|
||||
test('mergePhases: phase only present on agents is appended and derived from agent states', () => {
|
||||
const r = run({
|
||||
declaredPhases: ['Scan'],
|
||||
phases: [],
|
||||
agents: [
|
||||
{ id: 1, phase: 'AdhocFromAgent', status: 'running' },
|
||||
{ id: 2, phase: 'AdhocFromAgent', status: 'done', resultKind: 'ok' },
|
||||
],
|
||||
})
|
||||
expect(mergePhases(r)).toEqual([
|
||||
{ title: 'Scan', status: 'pending', done: 0, total: 0 },
|
||||
{ title: 'AdhocFromAgent', status: 'running', done: 1, total: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
test('filterAgentsByPhase: All / undefined → all; specified → only that phase', () => {
|
||||
const agents: AgentProgress[] = [
|
||||
{ id: 1, phase: 'A', status: 'running' },
|
||||
@@ -80,3 +133,76 @@ test('filterAgentsByPhase: All / undefined → all; specified → only that phas
|
||||
test('tabLabel: workflow name + last 4 chars short code of runId', () => {
|
||||
expect(tabLabel('review-changes', 'wf_abc123def')).toBe('review-changes#3def')
|
||||
})
|
||||
|
||||
// filterActiveRuns: only running runs reach the panel's tab row. Done/killed/completed are hidden
|
||||
// so opening /workflows no longer floods the tab row with months of historical runs (caused
|
||||
// tab overflow → garbled render when total width exceeded the terminal).
|
||||
test('filterActiveRuns: only status === "running" survives; completed/failed/killed dropped', () => {
|
||||
const r1 = run({ runId: 'r1', status: 'running' })
|
||||
const r2 = run({ runId: 'r2', status: 'running' })
|
||||
const r3 = run({ runId: 'r3', status: 'completed' })
|
||||
const r4 = run({ runId: 'r4', status: 'failed' })
|
||||
const r5 = run({ runId: 'r5', status: 'killed' })
|
||||
expect(filterActiveRuns([r1, r2, r3, r4, r5])).toEqual([r1, r2])
|
||||
})
|
||||
|
||||
test('filterActiveRuns: empty input -> empty output', () => {
|
||||
expect(filterActiveRuns([])).toEqual([])
|
||||
})
|
||||
|
||||
test('filterActiveRuns: all terminal -> empty (panel falls back to "(no active runs)")', () => {
|
||||
expect(
|
||||
filterActiveRuns([run({ status: 'completed' }), run({ status: 'killed' })]),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('filterActiveRuns: preserves input order (no re-sort)', () => {
|
||||
const a = run({ runId: 'a', status: 'running', startedAt: 5 })
|
||||
const b = run({ runId: 'b', status: 'running', startedAt: 1 })
|
||||
expect(filterActiveRuns([a, b]).map(r => r.runId)).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
// capTabsForDisplay: even if active runs somehow accumulate (long-lived sessions, runaway launcher),
|
||||
// the tab row must never overflow the terminal — cap at maxTabs, fold the remainder into a +N marker.
|
||||
test('capTabsForDisplay: under cap -> as-is', () => {
|
||||
const runs = [
|
||||
run({ runId: 'r1', status: 'running' }),
|
||||
run({ runId: 'r2', status: 'running' }),
|
||||
]
|
||||
expect(capTabsForDisplay(runs, 8)).toEqual({ runs, overflow: 0 })
|
||||
})
|
||||
|
||||
test('capTabsForDisplay: over cap -> first maxTabs runs + overflow count', () => {
|
||||
const runs = Array.from({ length: 10 }, (_, i) =>
|
||||
run({ runId: `r${i}`, status: 'running' }),
|
||||
)
|
||||
const capped = capTabsForDisplay(runs, 8)
|
||||
expect(capped.runs).toHaveLength(8)
|
||||
expect(capped.runs.map(r => r.runId)).toEqual([
|
||||
'r0',
|
||||
'r1',
|
||||
'r2',
|
||||
'r3',
|
||||
'r4',
|
||||
'r5',
|
||||
'r6',
|
||||
'r7',
|
||||
])
|
||||
expect(capped.overflow).toBe(2)
|
||||
})
|
||||
|
||||
test('capTabsForDisplay: exactly at cap -> no overflow', () => {
|
||||
const runs = Array.from({ length: 8 }, (_, i) =>
|
||||
run({ runId: `r${i}`, status: 'running' }),
|
||||
)
|
||||
const capped = capTabsForDisplay(runs, 8)
|
||||
expect(capped.runs).toHaveLength(8)
|
||||
expect(capped.overflow).toBe(0)
|
||||
})
|
||||
|
||||
test('capTabsForDisplay: maxTabs=0 -> all folded into overflow (degenerate but defined)', () => {
|
||||
const runs = [run({ runId: 'r1', status: 'running' })]
|
||||
const capped = capTabsForDisplay(runs, 0)
|
||||
expect(capped.runs).toEqual([])
|
||||
expect(capped.overflow).toBe(1)
|
||||
})
|
||||
|
||||
@@ -3,21 +3,40 @@ import { Box, Text } from '@anthropic/ink';
|
||||
import type { Theme } from '@anthropic/ink';
|
||||
import type { RunProgress } from '../progress/store.js';
|
||||
import { RUN_STATUS_COLOR, STATUS_DOT } from './status.js';
|
||||
import { tabLabel } from './selectors.js';
|
||||
import { capTabsForDisplay, tabLabel } from './selectors.js';
|
||||
import { truncateLabel } from './AgentList.js';
|
||||
|
||||
/**
|
||||
* Per-tab name width budget. Long workflow names truncate (keeping the `#xxxx` short-code suffix so
|
||||
* same-name runs stay distinguishable). Sized for a ~120-col terminal: ~6 tabs fit per row.
|
||||
*/
|
||||
const TAB_LABEL_MAX = 18;
|
||||
|
||||
/**
|
||||
* Hard ceiling on simultaneously rendered tabs. Defensive fallback: even if active runs accumulate
|
||||
* (long-lived session, runaway launcher), the row must never overflow the terminal width and
|
||||
* re-introduce the garbled overlapping render seen previously. Surplus runs are folded into `+N`.
|
||||
*/
|
||||
const MAX_TABS = 6;
|
||||
|
||||
/**
|
||||
* Top run tab row: one tab per run (status dot + name + #short code).
|
||||
* The current tab is highlighted with an orange ═ underline.
|
||||
*
|
||||
* Defenses against overflow:
|
||||
* - Per-tab name truncated via truncateLabel (keeps `#xxxx` suffix for disambiguation).
|
||||
* - Row capped at MAX_TABS; remainder rendered as a `+N` marker so total width is bounded.
|
||||
*/
|
||||
export function TabsBar({ runs, activeRunId }: { runs: RunProgress[]; activeRunId: string | null }): React.ReactNode {
|
||||
if (runs.length === 0) {
|
||||
return <Text color="subtle">(no runs)</Text>;
|
||||
}
|
||||
const { runs: visible, overflow } = capTabsForDisplay(runs, MAX_TABS);
|
||||
return (
|
||||
<Box>
|
||||
{runs.map(r => {
|
||||
{visible.map(r => {
|
||||
const active = r.runId === activeRunId;
|
||||
const label = tabLabel(r.workflowName, r.runId);
|
||||
const label = truncateLabel(tabLabel(r.workflowName, r.runId), TAB_LABEL_MAX);
|
||||
const underline = '═'.repeat(label.length + 2);
|
||||
return (
|
||||
<Box key={r.runId} flexDirection="column" marginRight={2}>
|
||||
@@ -32,6 +51,12 @@ export function TabsBar({ runs, activeRunId }: { runs: RunProgress[]; activeRunI
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 ? (
|
||||
<Box flexDirection="column" marginRight={2}>
|
||||
<Text color="subtle">+{overflow}</Text>
|
||||
<Text> </Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PhaseSidebar } from './PhaseSidebar.js';
|
||||
import { TabsBar } from './TabsBar.js';
|
||||
import { RUN_STATUS_COLOR, RUN_STATUS_TEXT } from './status.js';
|
||||
import { type FocusColumn, type WorkflowKeyboardHandlers, useWorkflowKeyboard } from './useWorkflowKeyboard.js';
|
||||
import { ALL_PHASE, filterAgentsByPhase, formatDuration, mergePhases } from './selectors.js';
|
||||
import { ALL_PHASE, filterActiveRuns, filterAgentsByPhase, formatDuration, mergePhases } from './selectors.js';
|
||||
|
||||
/**
|
||||
* Clamp the selected index to a valid range (empty list -> 0; out of range -> last position; negative/NaN -> 0).
|
||||
@@ -61,6 +61,10 @@ export function WorkflowsPanel({
|
||||
() => svc.listRuns(),
|
||||
() => [],
|
||||
);
|
||||
// Only in-flight runs reach the tab row. Terminal (completed/failed/killed) runs are hidden so opening
|
||||
// the panel no longer floods the row with persisted history (which overflowed the terminal and rendered
|
||||
// garbled overlapping text). They stay on disk and remain resumable via getRunAsync.
|
||||
const activeRuns = filterActiveRuns(runs);
|
||||
|
||||
const [activeRunId, setActiveRunId] = useState<string | null>(null);
|
||||
const [focusColumn, setFocusColumn] = useState<FocusColumn>('phases');
|
||||
@@ -76,18 +80,19 @@ export function WorkflowsPanel({
|
||||
void svc.loadPersistedRuns();
|
||||
}, [svc]);
|
||||
|
||||
// On runs change: activeRunId invalidated (killed / first time) -> clamp to the first one
|
||||
// On activeRuns change: activeRunId invalidated (killed / first time) -> clamp to the first one.
|
||||
// Tracks activeRuns (not raw runs) so focus never lands on a hidden terminal run.
|
||||
useEffect(() => {
|
||||
if (runs.length === 0) {
|
||||
if (activeRuns.length === 0) {
|
||||
if (activeRunId !== null) setActiveRunId(null);
|
||||
return;
|
||||
}
|
||||
if (!runs.some(r => r.runId === activeRunId)) {
|
||||
setActiveRunId(runs[0]!.runId);
|
||||
if (!activeRuns.some(r => r.runId === activeRunId)) {
|
||||
setActiveRunId(activeRuns[0]!.runId);
|
||||
}
|
||||
}, [runs, activeRunId]);
|
||||
}, [activeRuns, activeRunId]);
|
||||
|
||||
const focused: RunProgress | undefined = runs.find(r => r.runId === activeRunId);
|
||||
const focused: RunProgress | undefined = activeRuns.find(r => r.runId === activeRunId);
|
||||
const phases = focused ? mergePhases(focused) : [];
|
||||
// The sidebar includes the All row: prepend one item to the phases array -> total rows = phases.length + 1
|
||||
const phaseRowCount = phases.length + 1;
|
||||
@@ -122,15 +127,15 @@ export function WorkflowsPanel({
|
||||
};
|
||||
|
||||
const nextTab = (): void => {
|
||||
if (runs.length === 0) return;
|
||||
const idx = runs.findIndex(r => r.runId === activeRunId);
|
||||
const next = runs[(idx + 1) % runs.length]!;
|
||||
if (activeRuns.length === 0) return;
|
||||
const idx = activeRuns.findIndex(r => r.runId === activeRunId);
|
||||
const next = activeRuns[(idx + 1) % activeRuns.length]!;
|
||||
switchTab(next.runId);
|
||||
};
|
||||
const prevTab = (): void => {
|
||||
if (runs.length === 0) return;
|
||||
const idx = runs.findIndex(r => r.runId === activeRunId);
|
||||
const next = runs[(idx - 1 + runs.length) % runs.length]!;
|
||||
if (activeRuns.length === 0) return;
|
||||
const idx = activeRuns.findIndex(r => r.runId === activeRunId);
|
||||
const next = activeRuns[(idx - 1 + activeRuns.length) % activeRuns.length]!;
|
||||
switchTab(next.runId);
|
||||
};
|
||||
|
||||
@@ -225,9 +230,9 @@ export function WorkflowsPanel({
|
||||
</Box>
|
||||
{focused?.description ? <Text color="subtle">{focused.description}</Text> : null}
|
||||
|
||||
{runs.length > 1 ? (
|
||||
{activeRuns.length > 1 ? (
|
||||
<Box marginTop={1}>
|
||||
<TabsBar runs={runs} activeRunId={activeRunId} />
|
||||
<TabsBar runs={activeRuns} activeRunId={activeRunId} />
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -13,9 +13,40 @@ export type MergedPhase = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge declaredPhases (declared by meta) and run.phases (actually running/done):
|
||||
* - Declared order takes priority; phases present in actual but not declared are appended at the end.
|
||||
* - No actual record -> pending; otherwise take the actual status.
|
||||
* Derive a phase's sidebar status from the actual record + the agents grouped under it.
|
||||
*
|
||||
* The actual record comes from `phase_started`/`phase_done` events. Scripts that follow the
|
||||
* ultracode canonical pipeline pattern pass `opts.phase` directly to `agent()` inside
|
||||
* `pipeline()`/`parallel()` stages and never call `phase()` for those phases — so no
|
||||
* `phase_started` ever fires and `run.phases` lacks them. Worse, because `phase_done` only
|
||||
* emits when the *next* `phase()` runs, the previous phase stays "running" in `run.phases`
|
||||
* even after all its agents finish.
|
||||
*
|
||||
* Rules (checked in order):
|
||||
* 1. `phase_done` already fired → done is authoritative, respect it.
|
||||
* 2. Agents exist under this phase → derive from their states
|
||||
* (all done → done; otherwise → running). This is what the user actually sees.
|
||||
* 3. No agents yet → fall back to the actual record
|
||||
* (`running` if `phase()` was called and is still active, else pending).
|
||||
*/
|
||||
function derivePhaseStatus(
|
||||
actual: { status: 'running' | 'done' } | undefined,
|
||||
inPhase: AgentProgress[],
|
||||
): PhaseStatus {
|
||||
if (actual?.status === 'done') return 'done'
|
||||
if (inPhase.length > 0) {
|
||||
return inPhase.every(a => a.status === 'done') ? 'done' : 'running'
|
||||
}
|
||||
return actual?.status === 'running' ? 'running' : 'pending'
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge declaredPhases (declared by meta), run.phases (actually running/done),
|
||||
* and phases that appear only on agents:
|
||||
* - Declared order takes priority; then actual-but-undeclared; then agent-only phases.
|
||||
* Agent-only phases surface in the sidebar even when the script never called `phase()`
|
||||
* for them — otherwise the user sees agents running under a phase that isn't listed.
|
||||
* - Status is derived via {@link derivePhaseStatus}.
|
||||
* - done/total = done under that phase / total agents under that phase.
|
||||
*/
|
||||
export function mergePhases(
|
||||
@@ -28,17 +59,22 @@ export function mergePhases(
|
||||
if (seen.has(title)) return
|
||||
seen.add(title)
|
||||
const actual = actualByTitle.get(title)
|
||||
const status: PhaseStatus = !actual ? 'pending' : actual.status
|
||||
const inPhase = run.agents.filter(a => a.phase === title)
|
||||
out.push({
|
||||
title,
|
||||
status,
|
||||
status: derivePhaseStatus(actual, inPhase),
|
||||
done: inPhase.filter(a => a.status === 'done').length,
|
||||
total: inPhase.length,
|
||||
})
|
||||
}
|
||||
for (const t of run.declaredPhases) push(t)
|
||||
for (const p of run.phases) push(p.title)
|
||||
// Scripts that pass opts.phase directly to agent() (the ultracode pipeline pattern)
|
||||
// may have agents grouped under phases that never got a phase() call — surface them
|
||||
// so the sidebar reflects every phase the user can actually observe agents running in.
|
||||
for (const a of run.agents) {
|
||||
if (a.phase) push(a.phase)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -54,6 +90,37 @@ export function filterAgentsByPhase(
|
||||
return agents.filter(a => a.phase === selectedPhase)
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only runs still in flight. The /workflows panel defaults to this view: opening the panel
|
||||
* no longer floods the tab row with months of persisted historical runs (which overflowed the
|
||||
* terminal width and produced garbled overlapping text). Terminal runs (completed/failed/killed)
|
||||
* stay on disk and remain resumable via getRunAsync; only the tab row filters them out.
|
||||
*
|
||||
* Pure + order-preserving: callers rely on the same relative order as the input (store.list()
|
||||
* already returns newest-first by updatedAt).
|
||||
*/
|
||||
export function filterActiveRuns(runs: RunProgress[]): RunProgress[] {
|
||||
return runs.filter(r => r.status === 'running')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap how many runs reach the tab row. Defensive fallback: even if active runs accumulate
|
||||
* (long-lived session, runaway launcher), the row must never overflow the terminal width and
|
||||
* re-introduce the garbled render. Anything past maxTabs is folded into an `overflow` count
|
||||
* that the panel renders as `+N`.
|
||||
*
|
||||
* `runs` is sliced as-is (no re-sort); the caller is expected to have already applied
|
||||
* filterActiveRuns and any ordering upstream.
|
||||
*/
|
||||
export function capTabsForDisplay(
|
||||
runs: RunProgress[],
|
||||
maxTabs: number,
|
||||
): { runs: RunProgress[]; overflow: number } {
|
||||
const cap = Math.max(0, Math.trunc(maxTabs))
|
||||
const visible = runs.slice(0, cap)
|
||||
return { runs: visible, overflow: Math.max(0, runs.length - visible.length) }
|
||||
}
|
||||
|
||||
/** tab label: workflow name + `#` + last 4 chars of runId (disambiguates same-name runs). */
|
||||
export function tabLabel(workflowName: string, runId: string): string {
|
||||
return `${workflowName}#${runId.slice(-4)}`
|
||||
|
||||
Reference in New Issue
Block a user