merge: resolve conflict with origin/main
Integrate upstream activations (MONITOR_TOOL, TEAMMEM, MESSAGE_ACTIONS) into our commented section structure. Move these 3 flags from "disabled" to "upstream defaults" section.
This commit is contained in:
@@ -26,11 +26,9 @@ const featureFlags: Record<string, boolean> = {
|
|||||||
BRIDGE_MODE: false, // Remote desktop bridge via CCR infrastructure
|
BRIDGE_MODE: false, // Remote desktop bridge via CCR infrastructure
|
||||||
DAEMON: false, // Background daemon process (stubbed in open build)
|
DAEMON: false, // Background daemon process (stubbed in open build)
|
||||||
AGENT_TRIGGERS: false, // Scheduled remote agent triggers
|
AGENT_TRIGGERS: false, // Scheduled remote agent triggers
|
||||||
MONITOR_TOOL: false, // MCP server monitoring/streaming tool
|
|
||||||
ABLATION_BASELINE: false, // A/B testing harness for eval experiments
|
ABLATION_BASELINE: false, // A/B testing harness for eval experiments
|
||||||
CONTEXT_COLLAPSE: false, // Context collapsing optimization (stubbed)
|
CONTEXT_COLLAPSE: false, // Context collapsing optimization (stubbed)
|
||||||
COMMIT_ATTRIBUTION: false, // Co-Authored-By metadata in git commits
|
COMMIT_ATTRIBUTION: false, // Co-Authored-By metadata in git commits
|
||||||
TEAMMEM: false, // Team memory management
|
|
||||||
UDS_INBOX: false, // Unix Domain Socket inter-session messaging
|
UDS_INBOX: false, // Unix Domain Socket inter-session messaging
|
||||||
BG_SESSIONS: false, // Background sessions via tmux (stubbed)
|
BG_SESSIONS: false, // Background sessions via tmux (stubbed)
|
||||||
WEB_BROWSER_TOOL: false, // Built-in browser automation (source not mirrored)
|
WEB_BROWSER_TOOL: false, // Built-in browser automation (source not mirrored)
|
||||||
@@ -41,13 +39,15 @@ const featureFlags: Record<string, boolean> = {
|
|||||||
COORDINATOR_MODE: true, // Multi-agent coordinator with worker delegation
|
COORDINATOR_MODE: true, // Multi-agent coordinator with worker delegation
|
||||||
BUILTIN_EXPLORE_PLAN_AGENTS: true, // Built-in Explore/Plan specialized subagents
|
BUILTIN_EXPLORE_PLAN_AGENTS: true, // Built-in Explore/Plan specialized subagents
|
||||||
BUDDY: true, // Buddy mode for paired programming
|
BUDDY: true, // Buddy mode for paired programming
|
||||||
|
MONITOR_TOOL: true, // MCP server monitoring/streaming tool
|
||||||
|
TEAMMEM: true, // Team memory management
|
||||||
|
MESSAGE_ACTIONS: true, // Message action buttons in the UI
|
||||||
|
|
||||||
// ── Enabled: new activations ────────────────────────────────────────
|
// ── Enabled: new activations ────────────────────────────────────────
|
||||||
DUMP_SYSTEM_PROMPT: true, // --dump-system-prompt CLI flag for debugging
|
DUMP_SYSTEM_PROMPT: true, // --dump-system-prompt CLI flag for debugging
|
||||||
CACHED_MICROCOMPACT: true, // Cache-aware tool result truncation optimization
|
CACHED_MICROCOMPACT: true, // Cache-aware tool result truncation optimization
|
||||||
AWAY_SUMMARY: true, // "While you were away" recap after 5min blur
|
AWAY_SUMMARY: true, // "While you were away" recap after 5min blur
|
||||||
TRANSCRIPT_CLASSIFIER: true, // Auto-approval classifier for safe tool uses
|
TRANSCRIPT_CLASSIFIER: true, // Auto-approval classifier for safe tool uses
|
||||||
MESSAGE_ACTIONS: true, // Message action buttons in the UI
|
|
||||||
ULTRATHINK: true, // Deep thinking mode — type "ultrathink" to boost reasoning
|
ULTRATHINK: true, // Deep thinking mode — type "ultrathink" to boost reasoning
|
||||||
TOKEN_BUDGET: true, // Token budget tracking with usage warnings
|
TOKEN_BUDGET: true, // Token budget tracking with usage warnings
|
||||||
HISTORY_PICKER: true, // Enhanced interactive prompt history picker
|
HISTORY_PICKER: true, // Enhanced interactive prompt history picker
|
||||||
|
|||||||
146
scripts/no-telemetry-growthbook-stub.test.ts
Normal file
146
scripts/no-telemetry-growthbook-stub.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, test } from 'bun:test'
|
||||||
|
import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup: extract the growthbook stub from no-telemetry-plugin.ts, write it to
|
||||||
|
// a temp .mjs file, and dynamically import it so we can test the real code
|
||||||
|
// that gets bundled.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const pluginSource = readFileSync(join(__dirname, 'no-telemetry-plugin.ts'), 'utf-8')
|
||||||
|
const stubMatch = pluginSource.match(/'services\/analytics\/growthbook': `([\s\S]*?)`/)
|
||||||
|
if (!stubMatch) throw new Error('Could not extract growthbook stub from no-telemetry-plugin.ts')
|
||||||
|
|
||||||
|
const testDir = join(tmpdir(), `growthbook-stub-test-${process.pid}`)
|
||||||
|
const stubFile = join(testDir, 'growthbook-stub.mjs')
|
||||||
|
const flagsFile = join(testDir, 'test-flags.json')
|
||||||
|
|
||||||
|
mkdirSync(testDir, { recursive: true })
|
||||||
|
writeFileSync(stubFile, stubMatch[1])
|
||||||
|
|
||||||
|
// Point the stub at our test flags file (checked by _loadFlags on first access)
|
||||||
|
process.env.CLAUDE_FEATURE_FLAGS_FILE = flagsFile
|
||||||
|
|
||||||
|
const stub = await import(stubFile)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('growthbook stub — local feature flag overrides', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
stub.resetGrowthBook()
|
||||||
|
try { unlinkSync(flagsFile) } catch { /* may not exist */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
rmSync(testDir, { recursive: true, force: true })
|
||||||
|
delete process.env.CLAUDE_FEATURE_FLAGS_FILE
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── File absent ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('returns defaultValue when flags file is absent', () => {
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 42)).toBe(42)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getAllGrowthBookFeatures returns {} when file is absent', () => {
|
||||||
|
expect(stub.getAllGrowthBookFeatures()).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Valid JSON object ────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('loads and returns values from a valid JSON file', () => {
|
||||||
|
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true, tengu_bar: 'hello' }))
|
||||||
|
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', false)).toBe(true)
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_bar', 'default')).toBe('hello')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns defaultValue for keys not present in the file', () => {
|
||||||
|
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: true }))
|
||||||
|
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_missing', 99)).toBe(99)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getAllGrowthBookFeatures returns the full flags object', () => {
|
||||||
|
const flags = { tengu_a: true, tengu_b: false, tengu_c: 42 }
|
||||||
|
writeFileSync(flagsFile, JSON.stringify(flags))
|
||||||
|
|
||||||
|
expect(stub.getAllGrowthBookFeatures()).toEqual(flags)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Malformed / non-object JSON ──────────────────────────────────
|
||||||
|
|
||||||
|
test('falls back to defaults on malformed JSON', () => {
|
||||||
|
writeFileSync(flagsFile, '{not valid json!!!')
|
||||||
|
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to defaults when JSON is a primitive (true)', () => {
|
||||||
|
writeFileSync(flagsFile, 'true')
|
||||||
|
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back to defaults when JSON is an array', () => {
|
||||||
|
writeFileSync(flagsFile, '["a", "b"]')
|
||||||
|
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'fallback')).toBe('fallback')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Cache invalidation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
test('resetGrowthBook clears cache so the file is re-read', () => {
|
||||||
|
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'first' }))
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first')
|
||||||
|
|
||||||
|
// Update the file — cached value is still 'first'
|
||||||
|
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'second' }))
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('first')
|
||||||
|
|
||||||
|
// After reset, the new value is picked up
|
||||||
|
stub.resetGrowthBook()
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('second')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('refreshGrowthBookFeatures clears cache', async () => {
|
||||||
|
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v1' }))
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v1')
|
||||||
|
|
||||||
|
writeFileSync(flagsFile, JSON.stringify({ tengu_foo: 'v2' }))
|
||||||
|
await stub.refreshGrowthBookFeatures()
|
||||||
|
expect(stub.getFeatureValue_CACHED_MAY_BE_STALE('tengu_foo', 'x')).toBe('v2')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Multiple getter variants ─────────────────────────────────────
|
||||||
|
|
||||||
|
test('all getter functions read from local flags', async () => {
|
||||||
|
writeFileSync(flagsFile, JSON.stringify({ tengu_gate: true, tengu_config: { a: 1 } }))
|
||||||
|
|
||||||
|
expect(await stub.getFeatureValue_DEPRECATED('tengu_gate', false)).toBe(true)
|
||||||
|
stub.resetGrowthBook()
|
||||||
|
expect(stub.getFeatureValue_CACHED_WITH_REFRESH('tengu_gate', false)).toBe(true)
|
||||||
|
stub.resetGrowthBook()
|
||||||
|
expect(stub.checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_gate')).toBe(true)
|
||||||
|
stub.resetGrowthBook()
|
||||||
|
expect(await stub.checkGate_CACHED_OR_BLOCKING('tengu_gate')).toBe(true)
|
||||||
|
stub.resetGrowthBook()
|
||||||
|
expect(await stub.getDynamicConfig_BLOCKS_ON_INIT('tengu_config', {})).toEqual({ a: 1 })
|
||||||
|
stub.resetGrowthBook()
|
||||||
|
expect(stub.getDynamicConfig_CACHED_MAY_BE_STALE('tengu_config', {})).toEqual({ a: 1 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Security gate ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
test('checkSecurityRestrictionGate always returns false regardless of flags', async () => {
|
||||||
|
writeFileSync(flagsFile, JSON.stringify({
|
||||||
|
tengu_disable_bypass_permissions_mode: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
expect(await stub.checkSecurityRestrictionGate()).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -34,28 +34,55 @@ export function _resetForTesting() {}
|
|||||||
`,
|
`,
|
||||||
|
|
||||||
'services/analytics/growthbook': `
|
'services/analytics/growthbook': `
|
||||||
|
import _fs from 'node:fs';
|
||||||
|
import _path from 'node:path';
|
||||||
|
import _os from 'node:os';
|
||||||
|
|
||||||
|
let _flags = undefined;
|
||||||
|
|
||||||
|
function _loadFlags() {
|
||||||
|
if (_flags !== undefined) return;
|
||||||
|
try {
|
||||||
|
const flagsPath = process.env.CLAUDE_FEATURE_FLAGS_FILE
|
||||||
|
|| _path.join(_os.homedir(), '.claude', 'feature-flags.json');
|
||||||
|
const parsed = JSON.parse(_fs.readFileSync(flagsPath, 'utf-8'));
|
||||||
|
_flags = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
_flags = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getFlagValue(key, defaultValue) {
|
||||||
|
_loadFlags();
|
||||||
|
if (_flags != null && Object.hasOwn(_flags, key)) return _flags[key];
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
export function onGrowthBookRefresh() { return noop; }
|
export function onGrowthBookRefresh() { return noop; }
|
||||||
export function hasGrowthBookEnvOverride() { return false; }
|
export function hasGrowthBookEnvOverride() { return false; }
|
||||||
export function getAllGrowthBookFeatures() { return {}; }
|
export function getAllGrowthBookFeatures() { _loadFlags(); return _flags || {}; }
|
||||||
export function getGrowthBookConfigOverrides() { return {}; }
|
export function getGrowthBookConfigOverrides() { return {}; }
|
||||||
export function setGrowthBookConfigOverride() {}
|
export function setGrowthBookConfigOverride() {}
|
||||||
export function clearGrowthBookConfigOverrides() {}
|
export function clearGrowthBookConfigOverrides() {}
|
||||||
export function getApiBaseUrlHost() { return undefined; }
|
export function getApiBaseUrlHost() { return undefined; }
|
||||||
export const initializeGrowthBook = async () => null;
|
export const initializeGrowthBook = async () => null;
|
||||||
export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return defaultValue; }
|
export async function getFeatureValue_DEPRECATED(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
|
||||||
export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return defaultValue; }
|
export function getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
|
||||||
export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return defaultValue; }
|
export function getFeatureValue_CACHED_WITH_REFRESH(feature, defaultValue) { return _getFlagValue(feature, defaultValue); }
|
||||||
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE() { return false; }
|
export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE(gate) { return Boolean(_getFlagValue(gate, false)); }
|
||||||
export async function checkSecurityRestrictionGate() { return false; }
|
// Security killswitch — always false in the open build. Anthropic uses this
|
||||||
export async function checkGate_CACHED_OR_BLOCKING() { return false; }
|
// gate to remotely disable bypassPermissions mode; exposing it via local flags
|
||||||
|
// would let users accidentally lock themselves out of --dangerously-skip-permissions.
|
||||||
|
export async function checkSecurityRestrictionGate(gate) { return false; }
|
||||||
|
export async function checkGate_CACHED_OR_BLOCKING(gate) { return Boolean(_getFlagValue(gate, false)); }
|
||||||
export function refreshGrowthBookAfterAuthChange() {}
|
export function refreshGrowthBookAfterAuthChange() {}
|
||||||
export function resetGrowthBook() {}
|
export function resetGrowthBook() { _flags = undefined; }
|
||||||
export async function refreshGrowthBookFeatures() {}
|
export async function refreshGrowthBookFeatures() { _flags = undefined; }
|
||||||
export function setupPeriodicGrowthBookRefresh() {}
|
export function setupPeriodicGrowthBookRefresh() {}
|
||||||
export function stopPeriodicGrowthBookRefresh() {}
|
export function stopPeriodicGrowthBookRefresh() {}
|
||||||
export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return defaultValue; }
|
export async function getDynamicConfig_BLOCKS_ON_INIT(configName, defaultValue) { return _getFlagValue(configName, defaultValue); }
|
||||||
export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return defaultValue; }
|
export function getDynamicConfig_CACHED_MAY_BE_STALE(configName, defaultValue) { return _getFlagValue(configName, defaultValue); }
|
||||||
`,
|
`,
|
||||||
|
|
||||||
'services/analytics/sink': `
|
'services/analytics/sink': `
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { getOriginalCwd } from '../../../bootstrap/state.js'
|
||||||
|
import { Box, Text } from '../../../ink.js'
|
||||||
|
import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'
|
||||||
|
import { env } from '../../../utils/env.js'
|
||||||
|
import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'
|
||||||
|
import { usePermissionRequestLogging } from '../hooks.js'
|
||||||
|
import { PermissionDialog } from '../PermissionDialog.js'
|
||||||
|
import {
|
||||||
|
PermissionPrompt,
|
||||||
|
type PermissionPromptOption,
|
||||||
|
} from '../PermissionPrompt.js'
|
||||||
|
import type { PermissionRequestProps } from '../PermissionRequest.js'
|
||||||
|
import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'
|
||||||
|
import { logUnaryPermissionEvent } from '../utils.js'
|
||||||
|
|
||||||
|
type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no'
|
||||||
|
|
||||||
|
export function MonitorPermissionRequest({
|
||||||
|
toolUseConfirm,
|
||||||
|
onDone,
|
||||||
|
onReject,
|
||||||
|
workerBadge,
|
||||||
|
}: PermissionRequestProps) {
|
||||||
|
const { command, description } = toolUseConfirm.input as {
|
||||||
|
command?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
usePermissionRequestLogging(toolUseConfirm, {
|
||||||
|
completion_type: 'tool_use_single',
|
||||||
|
language_name: 'none',
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSelect = (
|
||||||
|
value: OptionValue,
|
||||||
|
feedback?: string,
|
||||||
|
) => {
|
||||||
|
switch (value) {
|
||||||
|
case 'yes': {
|
||||||
|
logUnaryPermissionEvent({
|
||||||
|
completion_type: 'tool_use_single',
|
||||||
|
event: 'accept',
|
||||||
|
metadata: {
|
||||||
|
language_name: 'none',
|
||||||
|
message_id: toolUseConfirm.assistantMessage.message.id,
|
||||||
|
platform: env.platform,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback)
|
||||||
|
onDone()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'yes-dont-ask-again': {
|
||||||
|
logUnaryPermissionEvent({
|
||||||
|
completion_type: 'tool_use_single',
|
||||||
|
event: 'accept',
|
||||||
|
metadata: {
|
||||||
|
language_name: 'none',
|
||||||
|
message_id: toolUseConfirm.assistantMessage.message.id,
|
||||||
|
platform: env.platform,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// Save the rule under 'Bash' toolName because checkPermissions
|
||||||
|
// delegates to bashToolHasPermission which matches rules against
|
||||||
|
// BashTool. Using 'Monitor' here would create a rule that's never
|
||||||
|
// checked. Command-specific prefix (like BashTool's shellRuleMatching).
|
||||||
|
const cmdForRule = command?.trim() || ''
|
||||||
|
const prefix = cmdForRule.split(/\s+/).slice(0, 2).join(' ')
|
||||||
|
toolUseConfirm.onAllow(toolUseConfirm.input, prefix ? [
|
||||||
|
{
|
||||||
|
type: 'addRules',
|
||||||
|
rules: [{ toolName: 'Bash', ruleContent: `${prefix}:*` }],
|
||||||
|
behavior: 'allow',
|
||||||
|
destination: 'localSettings',
|
||||||
|
},
|
||||||
|
] : [])
|
||||||
|
onDone()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'no': {
|
||||||
|
logUnaryPermissionEvent({
|
||||||
|
completion_type: 'tool_use_single',
|
||||||
|
event: 'reject',
|
||||||
|
metadata: {
|
||||||
|
language_name: 'none',
|
||||||
|
message_id: toolUseConfirm.assistantMessage.message.id,
|
||||||
|
platform: env.platform,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toolUseConfirm.onReject(feedback)
|
||||||
|
onReject()
|
||||||
|
onDone()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
logUnaryPermissionEvent({
|
||||||
|
completion_type: 'tool_use_single',
|
||||||
|
event: 'reject',
|
||||||
|
metadata: {
|
||||||
|
language_name: 'none',
|
||||||
|
message_id: toolUseConfirm.assistantMessage.message.id,
|
||||||
|
platform: env.platform,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
toolUseConfirm.onReject()
|
||||||
|
onReject()
|
||||||
|
onDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
const showAlwaysAllow = shouldShowAlwaysAllowOptions()
|
||||||
|
const originalCwd = getOriginalCwd()
|
||||||
|
|
||||||
|
const options: PermissionPromptOption<OptionValue>[] = [
|
||||||
|
{
|
||||||
|
label: 'Yes',
|
||||||
|
value: 'yes',
|
||||||
|
feedbackConfig: { type: 'accept' },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (showAlwaysAllow) {
|
||||||
|
options.push({
|
||||||
|
label: (
|
||||||
|
<Text>
|
||||||
|
Yes, and don't ask again for{' '}
|
||||||
|
<Text bold>Monitor</Text> commands in{' '}
|
||||||
|
<Text bold>{originalCwd}</Text>
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
value: 'yes-dont-ask-again',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
options.push({
|
||||||
|
label: 'No',
|
||||||
|
value: 'no',
|
||||||
|
feedbackConfig: { type: 'reject' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const toolAnalyticsContext = {
|
||||||
|
toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name),
|
||||||
|
isMcp: toolUseConfirm.tool.isMcp ?? false,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PermissionDialog title="Monitor" workerBadge={workerBadge}>
|
||||||
|
<Box flexDirection="column" paddingX={2} paddingY={1}>
|
||||||
|
<Text>
|
||||||
|
Monitor({command ?? ''})
|
||||||
|
</Text>
|
||||||
|
{description ? (
|
||||||
|
<Text dimColor>{description}</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<PermissionRuleExplanation
|
||||||
|
permissionResult={toolUseConfirm.permissionResult}
|
||||||
|
toolType="tool"
|
||||||
|
/>
|
||||||
|
<PermissionPrompt
|
||||||
|
options={options}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
toolAnalyticsContext={toolAnalyticsContext}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</PermissionDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ export function isTeamMemoryEnabled(): boolean {
|
|||||||
if (!isAutoMemoryEnabled()) {
|
if (!isAutoMemoryEnabled()) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', false)
|
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
102
src/tasks/MonitorMcpTask/MonitorMcpTask.ts
Normal file
102
src/tasks/MonitorMcpTask/MonitorMcpTask.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// MonitorMcpTask — task registry entry for the 'monitor_mcp' type.
|
||||||
|
//
|
||||||
|
// Architecture: MonitorTool spawns shell processes as LocalShellTask
|
||||||
|
// (type: 'local_bash', kind: 'monitor'). The 'monitor_mcp' type exists
|
||||||
|
// in TaskType for forward-compatibility with MCP-based monitoring (not
|
||||||
|
// yet implemented). This module satisfies the import from tasks.ts and
|
||||||
|
// provides killMonitorMcpTasksForAgent for agent-scoped cleanup of
|
||||||
|
// monitor-kind shell tasks.
|
||||||
|
|
||||||
|
import type { AppState } from '../../state/AppState.js'
|
||||||
|
import type { SetAppState, Task, TaskStateBase } from '../../Task.js'
|
||||||
|
import type { AgentId } from '../../types/ids.js'
|
||||||
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
|
import { dequeueAllMatching } from '../../utils/messageQueueManager.js'
|
||||||
|
import { evictTaskOutput } from '../../utils/task/diskOutput.js'
|
||||||
|
import { updateTaskState } from '../../utils/task/framework.js'
|
||||||
|
import { isLocalShellTask } from '../LocalShellTask/guards.js'
|
||||||
|
import { killTask } from '../LocalShellTask/killShellTasks.js'
|
||||||
|
|
||||||
|
export type MonitorMcpTaskState = TaskStateBase & {
|
||||||
|
type: 'monitor_mcp'
|
||||||
|
agentId?: AgentId
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMonitorMcpTask(task: unknown): task is MonitorMcpTaskState {
|
||||||
|
return (
|
||||||
|
typeof task === 'object' &&
|
||||||
|
task !== null &&
|
||||||
|
'type' in task &&
|
||||||
|
task.type === 'monitor_mcp'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MonitorMcpTask: Task = {
|
||||||
|
name: 'MonitorMcpTask',
|
||||||
|
type: 'monitor_mcp',
|
||||||
|
async kill(taskId, setAppState) {
|
||||||
|
updateTaskState<MonitorMcpTaskState>(taskId, setAppState, task => {
|
||||||
|
if (task.status !== 'running') {
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...task,
|
||||||
|
status: 'killed',
|
||||||
|
notified: true,
|
||||||
|
endTime: Date.now(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
void evictTaskOutput(taskId)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill all monitor tasks owned by a given agent.
|
||||||
|
*
|
||||||
|
* MonitorTool spawns tasks as local_bash with kind='monitor'. When an agent
|
||||||
|
* exits, killShellTasksForAgent already handles those. This function provides
|
||||||
|
* additional cleanup for any monitor_mcp-typed tasks and also kills any
|
||||||
|
* local_bash tasks with kind='monitor' that might have been missed (belt and
|
||||||
|
* suspenders). Finally, it purges queued notifications for the dead agent.
|
||||||
|
*/
|
||||||
|
export function killMonitorMcpTasksForAgent(
|
||||||
|
agentId: AgentId,
|
||||||
|
getAppState: () => AppState,
|
||||||
|
setAppState: SetAppState,
|
||||||
|
): void {
|
||||||
|
const tasks = getAppState().tasks ?? {}
|
||||||
|
|
||||||
|
for (const [taskId, task] of Object.entries(tasks)) {
|
||||||
|
// Kill monitor_mcp tasks for this agent
|
||||||
|
if (
|
||||||
|
isMonitorMcpTask(task) &&
|
||||||
|
task.agentId === agentId &&
|
||||||
|
task.status === 'running'
|
||||||
|
) {
|
||||||
|
logForDebugging(
|
||||||
|
`killMonitorMcpTasksForAgent: killing monitor_mcp task ${taskId} (agent ${agentId} exiting)`,
|
||||||
|
)
|
||||||
|
void MonitorMcpTask.kill(taskId, setAppState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also kill local_bash tasks with kind='monitor' for this agent
|
||||||
|
// (killShellTasksForAgent already does this, but being explicit
|
||||||
|
// guards against ordering issues)
|
||||||
|
if (
|
||||||
|
isLocalShellTask(task) &&
|
||||||
|
task.kind === 'monitor' &&
|
||||||
|
task.agentId === agentId &&
|
||||||
|
task.status === 'running'
|
||||||
|
) {
|
||||||
|
logForDebugging(
|
||||||
|
`killMonitorMcpTasksForAgent: killing monitor shell task ${taskId} (agent ${agentId} exiting)`,
|
||||||
|
)
|
||||||
|
killTask(taskId, setAppState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge any queued notifications addressed to this agent — its query loop
|
||||||
|
// has exited and won't drain them.
|
||||||
|
dequeueAllMatching(cmd => cmd.agentId === agentId)
|
||||||
|
}
|
||||||
195
src/tools/MonitorTool/MonitorTool.ts
Normal file
195
src/tools/MonitorTool/MonitorTool.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
|
||||||
|
import React from 'react'
|
||||||
|
import { z } from 'zod/v4'
|
||||||
|
import { buildTool, type ToolDef } from '../../Tool.js'
|
||||||
|
import { spawnShellTask } from '../../tasks/LocalShellTask/LocalShellTask.js'
|
||||||
|
import { lazySchema } from '../../utils/lazySchema.js'
|
||||||
|
import { exec } from '../../utils/Shell.js'
|
||||||
|
import { getTaskOutputPath } from '../../utils/task/diskOutput.js'
|
||||||
|
import {
|
||||||
|
bashToolHasPermission,
|
||||||
|
matchWildcardPattern,
|
||||||
|
permissionRuleExtractPrefix,
|
||||||
|
} from '../BashTool/bashPermissions.js'
|
||||||
|
import { parseForSecurity } from '../../utils/bash/ast.js'
|
||||||
|
|
||||||
|
export const MONITOR_TOOL_NAME = 'Monitor'
|
||||||
|
|
||||||
|
const MONITOR_TIMEOUT_MS = 30 * 60 * 1000 // 30 minutes
|
||||||
|
|
||||||
|
const inputSchema = lazySchema(() =>
|
||||||
|
z.strictObject({
|
||||||
|
command: z
|
||||||
|
.string()
|
||||||
|
.describe('The shell command to run and monitor'),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.describe(
|
||||||
|
'Clear, concise description of what this command does in active voice.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
type InputSchema = ReturnType<typeof inputSchema>
|
||||||
|
|
||||||
|
const outputSchema = lazySchema(() =>
|
||||||
|
z.object({
|
||||||
|
taskId: z
|
||||||
|
.string()
|
||||||
|
.describe('The ID of the background monitor task'),
|
||||||
|
outputFile: z
|
||||||
|
.string()
|
||||||
|
.describe('Path to the file where output is being written'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
type OutputSchema = ReturnType<typeof outputSchema>
|
||||||
|
|
||||||
|
type Output = z.infer<OutputSchema>
|
||||||
|
|
||||||
|
export const MonitorTool = buildTool({
|
||||||
|
name: MONITOR_TOOL_NAME,
|
||||||
|
searchHint: 'stream shell output as notifications',
|
||||||
|
maxResultSizeChars: 10_000,
|
||||||
|
strict: true,
|
||||||
|
|
||||||
|
isConcurrencySafe() {
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
|
||||||
|
toAutoClassifierInput(input) {
|
||||||
|
return input.command
|
||||||
|
},
|
||||||
|
|
||||||
|
async preparePermissionMatcher({ command }) {
|
||||||
|
const parsed = await parseForSecurity(command)
|
||||||
|
if (parsed.kind !== 'simple') {
|
||||||
|
return () => true
|
||||||
|
}
|
||||||
|
const subcommands = parsed.commands.map(c => c.argv.join(' '))
|
||||||
|
return (pattern: string) => {
|
||||||
|
const prefix = permissionRuleExtractPrefix(pattern)
|
||||||
|
return subcommands.some(cmd => {
|
||||||
|
if (prefix !== null) {
|
||||||
|
return cmd === prefix || cmd.startsWith(`${prefix} `)
|
||||||
|
}
|
||||||
|
return matchWildcardPattern(pattern, cmd)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkPermissions(input, context) {
|
||||||
|
// Delegate to the bash permission system — Monitor runs shell commands
|
||||||
|
// just like Bash does, so the same permission rules apply.
|
||||||
|
return bashToolHasPermission({ command: input.command }, context)
|
||||||
|
},
|
||||||
|
|
||||||
|
async description(input) {
|
||||||
|
return input.description || 'Monitor shell command'
|
||||||
|
},
|
||||||
|
|
||||||
|
async prompt() {
|
||||||
|
return `Execute a shell command in the background and stream its stdout line-by-line as notifications. Each polling interval (~1s), new output lines are delivered to you. Use this for monitoring logs, watching build output, or observing long-running processes. For one-shot "wait until done" commands, prefer Bash with run_in_background instead.`
|
||||||
|
},
|
||||||
|
|
||||||
|
get inputSchema(): InputSchema {
|
||||||
|
return inputSchema()
|
||||||
|
},
|
||||||
|
|
||||||
|
get outputSchema(): OutputSchema {
|
||||||
|
return outputSchema()
|
||||||
|
},
|
||||||
|
|
||||||
|
userFacingName() {
|
||||||
|
return 'Monitor'
|
||||||
|
},
|
||||||
|
|
||||||
|
getToolUseSummary(input) {
|
||||||
|
if (!input?.description) {
|
||||||
|
return input?.command ?? null
|
||||||
|
}
|
||||||
|
return input.description
|
||||||
|
},
|
||||||
|
|
||||||
|
getActivityDescription(input) {
|
||||||
|
if (!input?.description) {
|
||||||
|
return 'Starting monitor'
|
||||||
|
}
|
||||||
|
return `Monitoring ${input.description}`
|
||||||
|
},
|
||||||
|
|
||||||
|
renderToolUseMessage(
|
||||||
|
input: Partial<z.infer<InputSchema>>,
|
||||||
|
): React.ReactNode {
|
||||||
|
const cmd = input.command ?? ''
|
||||||
|
const desc = input.description ?? ''
|
||||||
|
if (desc && cmd) {
|
||||||
|
return `${desc}: ${cmd}`
|
||||||
|
}
|
||||||
|
return cmd || desc || ''
|
||||||
|
},
|
||||||
|
|
||||||
|
renderToolResultMessage(
|
||||||
|
output: Output,
|
||||||
|
): React.ReactNode {
|
||||||
|
return `Monitor started (task ${output.taskId})`
|
||||||
|
},
|
||||||
|
|
||||||
|
mapToolResultToToolResultBlockParam(
|
||||||
|
output: Output,
|
||||||
|
toolUseID: string,
|
||||||
|
): ToolResultBlockParam {
|
||||||
|
const outputPath = output.outputFile
|
||||||
|
return {
|
||||||
|
tool_use_id: toolUseID,
|
||||||
|
type: 'tool_result',
|
||||||
|
content: `Monitor task started with ID: ${output.taskId}. Output is being streamed to: ${outputPath}. You will receive notifications as new output lines appear (~1s polling). Use TaskStop to end monitoring when done.`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async call(input, toolUseContext) {
|
||||||
|
const { command, description } = input
|
||||||
|
const { abortController, setAppState } = toolUseContext
|
||||||
|
|
||||||
|
// Create the shell command — uses the same Shell.exec() as BashTool.
|
||||||
|
// This is intentionally a shell execution (not execFile) because
|
||||||
|
// MonitorTool needs full shell features (pipes, redirects, etc.)
|
||||||
|
// just like BashTool does.
|
||||||
|
const shellCommand = await exec(
|
||||||
|
command,
|
||||||
|
abortController.signal,
|
||||||
|
'bash',
|
||||||
|
{ timeout: MONITOR_TIMEOUT_MS },
|
||||||
|
)
|
||||||
|
|
||||||
|
// Spawn as a background task with kind='monitor' — identical to
|
||||||
|
// BashTool's run_in_background path but always monitor-flavored.
|
||||||
|
const handle = await spawnShellTask(
|
||||||
|
{
|
||||||
|
command,
|
||||||
|
description: description || command,
|
||||||
|
shellCommand,
|
||||||
|
toolUseId: toolUseContext.toolUseId,
|
||||||
|
agentId: toolUseContext.agentId,
|
||||||
|
kind: 'monitor',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
abortController,
|
||||||
|
getAppState: () => {
|
||||||
|
throw new Error(
|
||||||
|
'getAppState not available in MonitorTool spawn context',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
setAppState: toolUseContext.setAppStateForTasks ?? setAppState,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const taskId = handle.taskId
|
||||||
|
const outputFile = getTaskOutputPath(taskId)
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
taskId,
|
||||||
|
outputFile,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} satisfies ToolDef<InputSchema, Output>)
|
||||||
Reference in New Issue
Block a user