Add command files and enhance session management features

This commit is contained in:
ImBenji
2026-04-28 19:00:27 +01:00
parent 3588783001
commit 728c0ffe81
146 changed files with 6854 additions and 7783 deletions
+1
View File
@@ -35,6 +35,7 @@ void main() async {
ChangeNotifierProvider(
create: (context) => ChatProvider(
context.read<SettingsProvider>(),
context.read<CostProvider>(),
),
),
ChangeNotifierProvider(
+14
View File
@@ -71,6 +71,7 @@ class OpenRouterClient {
double? temperature,
List<Map<String, dynamic>>? tools,
String? toolChoice,
String? reasoning, // "low" | "medium" | "high" — maps to OpenRouter reasoning.effort
}) async {
final requestBody = <String, dynamic>{
"model": model,
@@ -99,6 +100,13 @@ class OpenRouterClient {
}
}
if (reasoning != null) {
// OpenRouter unified reasoning param — works across Anthropic, DeepSeek, Gemini etc
// "max" is our internal alias; OpenRouter calls it "xhigh"
final effort = reasoning == 'max' ? 'xhigh' : reasoning;
requestBody["reasoning"] = <String, dynamic>{"effort": effort};
}
final response = await _withRetry(
() => _makeRequest(
method: "POST",
@@ -118,6 +126,7 @@ class OpenRouterClient {
double? temperature,
List<Map<String, dynamic>>? tools,
String? toolChoice,
String? reasoning, // "low" | "medium" | "high" — maps to OpenRouter reasoning.effort
void Function(String delta)? onTextDelta,
}) async {
final requestBody = <String, dynamic>{
@@ -148,6 +157,11 @@ class OpenRouterClient {
}
}
if (reasoning != null) {
final effort = reasoning == 'max' ? 'xhigh' : reasoning;
requestBody["reasoning"] = <String, dynamic>{"effort": effort};
}
final url = Uri.parse("$_baseUrl/chat/completions");
final headers = _buildHeaders();
+196 -3718
View File
File diff suppressed because it is too large Load Diff
+128 -18
View File
@@ -1,39 +1,73 @@
import "../api/api_types.dart";
import "../api/openrouter_client.dart";
const _advisorSystemPrompt =
"You are an advisor reviewing an AI agent's work in progress. "
"You will be shown the full conversation history including tool calls and results. "
"Your job is to give concise, actionable guidance: identify mistakes, "
"suggest better approaches, flag assumptions that need verifying, or confirm "
"the agent is on the right track. Be direct and specific.";
// Matches ADVISOR_TOOL_INSTRUCTIONS from old_repo/utils/advisor.ts
// Verbatim from old_repo/utils/advisor.ts ADVISOR_TOOL_INSTRUCTIONS
const advisorToolDescription =
"Call the advisor model for a second opinion on your current approach. "
"Takes no parameters your full conversation history is forwarded automatically. "
"Call BEFORE committing to a significant approach, BEFORE declaring done, or when stuck.";
"# Advisor Tool\n\n"
"The advisor is a second-opinion and planning tool -- NOT an investigative tool. It takes NO parameters; your entire conversation history is forwarded automatically.\n\n"
"Use it for:\n"
"- Validating an implementation plan before you write code\n"
"- Getting unstuck when errors recur or an approach isn't converging\n"
"- A final review before declaring a multi-step task done\n\n"
"Do NOT use it for:\n"
"- Answering questions\n"
"- Looking up information, searching the codebase, or reading files\n"
"- Understanding what the project does or how something works\n"
"- Anything you can just do yourself with a tool call\n\n"
"The advisor cannot run tools. It only reads the conversation and gives you text guidance. If you need to know something, find it yourself first -- then call the advisor once you have a concrete plan to review.\n\n"
"Give the advice serious weight. If you follow a step and it fails empirically, or you have primary-source evidence that contradicts a specific claim (the file says X, the code does Y), adapt. A passing self-test is not evidence the advice is wrong -- it's evidence your test doesn't check what the advice is checking.\n\n"
"If you've already retrieved data pointing one way and the advisor points another: don't silently switch. Surface the conflict in one more advisor call -- \"I found X, you suggest Y, which constraint breaks the tie?\" The advisor saw your evidence but may have underweighted it; a reconcile call is cheaper than committing to the wrong branch.";
class AdvisorResult {
const AdvisorResult({required this.text, required this.response});
final String text;
// null if the call failed
final ApiMessage? response;
}
class AdvisorService {
Future<String> run({
Future<AdvisorResult> run({
required String advisorModel,
required String apiKey,
required List<Map<String, dynamic>> conversationSoFar,
required String systemPrompt,
required List<Map<String, dynamic>> toolDefinitions,
String? effortLevel,
void Function(String toolName, Map<String, dynamic> input)? onToolCall,
void Function(String toolName, String result)? onToolResult,
}) async {
onToolCall?.call("Advisor", {"model": advisorModel});
OpenRouterClient? client;
try {
client = OpenRouterClient(
config: OpenRouterConfig(apiKey: apiKey),
);
final stripped = _stripDanglingToolUse(conversationSoFar);
final transcript = _buildTranscript(stripped);
final response = await client.createMessage(
model: advisorModel,
maxTokens: 2048,
messages: conversationSoFar,
system: _advisorSystemPrompt,
maxTokens: 8192,
messages: [
{
"role": "user",
"content": "Here is the conversation so far:\n\n$transcript\n\n"
"You are acting as an advisor, not an executor. "
"You MUST NOT call any tools or functions — tool calls are strictly forbidden. "
"Give concise, actionable guidance in plain text only.",
},
],
system: "$systemPrompt\n\n"
"# Advisor Mode\n\n"
"You are acting as an advisor reviewing the above conversation transcript. "
"You MUST NOT call any tools or functions under any circumstances — not even once. "
"Tool calls are strictly forbidden in this mode. "
"Your response must be plain text only: analyze the conversation and give concise, actionable guidance.",
reasoning: effortLevel,
);
final text = response.content
@@ -45,13 +79,89 @@ class AdvisorService {
final result = text.isEmpty ? "Advisor returned no guidance." : text;
onToolResult?.call("Advisor", result);
return result;
return AdvisorResult(text: result, response: response);
} catch (e) {
final err = "Advisor call failed: $e";
onToolResult?.call("Advisor", err);
return err;
return AdvisorResult(text: err, response: null);
} finally {
client?.close();
}
}
}
// Converts conversation messages into a compact readable transcript.
// Avoids raw JSON syntax overhead — roles become labels, tool calls/results
// are shown as named blocks without all the JSON structure.
String _buildTranscript(List<Map<String, dynamic>> messages) {
final buf = StringBuffer();
for (final msg in messages) {
final role = msg["role"] as String? ?? "unknown";
if (role == "user") {
final content = msg["content"];
buf.writeln("[user]");
buf.writeln(content is String ? content : content.toString());
buf.writeln();
} else if (role == "assistant") {
final content = msg["content"];
final toolCalls = msg["tool_calls"];
if (content is String && content.isNotEmpty) {
buf.writeln("[assistant]");
buf.writeln(content);
buf.writeln();
}
if (toolCalls is List) {
for (final tc in toolCalls) {
if (tc is! Map<String, dynamic>) continue;
final fn = tc["function"] as Map<String, dynamic>?;
final name = fn?["name"] ?? "tool";
final args = fn?["arguments"] ?? "";
buf.writeln("[tool call: $name]");
buf.writeln(args);
buf.writeln();
}
}
} else if (role == "tool") {
final content = msg["content"];
buf.writeln("[tool result]");
buf.writeln(content is String ? content : content.toString());
buf.writeln();
}
}
return buf.toString().trimRight();
}
// The advisor is called mid-loop, so the last assistant message may contain
// tool_use blocks whose tool_result hasn't been appended yet. Anthropic rejects
// that. Strip any trailing assistant message that has unmatched tool_use calls.
List<Map<String, dynamic>> _stripDanglingToolUse(
List<Map<String, dynamic>> messages,
) {
if (messages.isEmpty) return messages;
final last = messages.last;
if (last["role"] != "assistant") return messages;
// OpenAI format: tool calls are in message["tool_calls"]
// Anthropic format: tool_use blocks inside message["content"] list
final toolCalls = last["tool_calls"];
final content = last["content"];
bool hasToolUse = (toolCalls is List && toolCalls.isNotEmpty) ||
(content is List &&
content.any(
(b) => b is Map<String, dynamic> && b["type"] == "tool_use",
));
if (!hasToolUse) return messages;
return messages.sublist(0, messages.length - 1);
}
+142 -9
View File
@@ -17,6 +17,22 @@ import "../services/tool_telemetry_service.dart";
import "../system_prompt/claude_md_loader.dart";
import "../system_prompt/system_prompt_builder.dart";
import "../tools/tool_registry.dart";
import "../tools/streaming_tool.dart";
import "../tools/bash_tool.dart";
class AdvisorUsage {
const AdvisorUsage({
required this.model,
required this.inputTokens,
required this.outputTokens,
required this.costUsd,
});
final String model;
final int inputTokens;
final int outputTokens;
final double costUsd;
}
class ToolLoopResult {
const ToolLoopResult({
@@ -26,6 +42,7 @@ class ToolLoopResult {
required this.finalResponseWasStreamed,
required this.webSearchRequests,
required this.webFetchRequests,
this.advisorUsages = const [],
});
final List<Map<String, dynamic>> apiMessages;
@@ -34,6 +51,9 @@ class ToolLoopResult {
final bool finalResponseWasStreamed;
final int webSearchRequests;
final int webFetchRequests;
// one entry per advisor call made this turn
final List<AdvisorUsage> advisorUsages;
}
class ToolLoopException implements Exception {
@@ -75,6 +95,7 @@ class ToolLoopService {
String? advisorModel,
void Function(String toolName, Map<String, dynamic> input)? onToolCall,
void Function(String toolName, String result)? onToolResult,
void Function(String toolName, String chunk)? onToolOutputChunk,
void Function(String delta)? onAssistantTextDelta,
void Function()? onAssistantMessageComplete,
Future<PermissionDecision> Function(String toolName, Map<String, dynamic> input, {String? suggestionRule})? onPermissionRequired,
@@ -115,19 +136,26 @@ class ToolLoopService {
}
late ApiMessage lastResponse;
final advisorUsages = <AdvisorUsage>[];
// build system prompt + tools once — reused each iteration and forwarded to advisor
final systemPrompt = await _buildSystemPrompt(workingDirectory, model);
final toolDefs = _buildToolDefinitions(advisorModel: advisorModel);
try {
while (true) {
if (shouldStop != null && shouldStop()) throw RequestCancelledException();
bool streamedTextThisIteration = false;
final currentSettings = getSettings();
lastResponse = await client.createStreamingMessage(
model: model,
maxTokens: 4096,
maxTokens: 64000,
messages: updatedMessages,
system: await _buildSystemPrompt(workingDirectory, model),
tools: _buildToolDefinitions(advisorModel: advisorModel),
system: systemPrompt,
tools: toolDefs,
toolChoice: "auto",
reasoning: currentSettings.effortLevel,
onTextDelta: (delta) {
streamedTextThisIteration = true;
onAssistantTextDelta?.call(delta);
@@ -153,6 +181,7 @@ class ToolLoopService {
finalResponseWasStreamed: streamedTextThisIteration,
webSearchRequests: lastResponse.webSearchRequests ?? 0,
webFetchRequests: lastResponse.webFetchRequests ?? 0,
advisorUsages: List.unmodifiable(advisorUsages),
);
}
@@ -161,17 +190,52 @@ class ToolLoopService {
// advisor is handled separately — not via the tool registry
if (toolUse.name == "Advisor") {
onToolCall?.call("Advisor", {"model": advisorModel!});
if (onPermissionRequired != null) {
final decision = await onPermissionRequired(
"Advisor",
{"model": advisorModel!},
suggestionRule: "Advisor",
);
if (decision == PermissionDecision.reject) {
const denied = "Advisor call declined by user.";
onToolResult?.call("Advisor", denied);
updatedMessages.add(<String, dynamic>{
"role": "tool",
"tool_call_id": toolUse.id,
"content": denied,
});
continue;
}
}
final advisorResult = await _advisorService.run(
advisorModel: advisorModel!,
apiKey: apiKey,
conversationSoFar: List<Map<String, dynamic>>.from(updatedMessages),
systemPrompt: systemPrompt,
toolDefinitions: toolDefs,
effortLevel: getSettings().advisorEffortLevel,
onToolCall: onToolCall,
onToolResult: onToolResult,
);
if (advisorResult.response != null) {
final r = advisorResult.response!;
final rawUsage = r.usage;
advisorUsages.add(AdvisorUsage(
model: r.model,
inputTokens: r.inputTokens ?? 0,
outputTokens: r.outputTokens ?? 0,
costUsd: (rawUsage?["cost"] as num?)?.toDouble() ?? 0.0,
));
}
updatedMessages.add(<String, dynamic>{
"role": "tool",
"tool_call_id": toolUse.id,
"content": advisorResult,
"content": advisorResult.text,
});
continue;
}
@@ -228,12 +292,19 @@ class ToolLoopService {
final toolResult = await _executeTool(
toolUse: toolUse,
normalizedInput: normalizedInput,
onChunk: onToolOutputChunk != null
? (chunk) => onToolOutputChunk(toolUse.name, chunk)
: null,
shouldStop: shouldStop,
);
onToolResult?.call(toolUse.name, toolResult);
// IMAGE_BLOCK results need structured content blocks, not plain text
final toolResultContent = _buildToolResultContent(toolResult);
updatedMessages.add(<String, dynamic>{
"role": "tool",
"tool_call_id": toolUse.id,
"content": toolResult,
"content": toolResultContent,
});
}
}
@@ -255,6 +326,8 @@ class ToolLoopService {
Future<String> _executeTool({
required ToolUse toolUse,
required Map<String, dynamic> normalizedInput,
void Function(String chunk)? onChunk,
bool Function()? shouldStop,
}) async {
final stopwatch = Stopwatch()..start();
print(
@@ -262,7 +335,23 @@ class ToolLoopService {
);
try {
final result = await _toolRegistry.execute(toolUse.name, normalizedInput);
String result;
final tool = _toolRegistry.getTool(toolUse.name);
if (tool is BashTool) {
tool.shouldStop = shouldStop;
}
if (onChunk != null && tool is StreamingTool) {
result = await (tool as StreamingTool).executeStreaming(
normalizedInput,
onChunk: onChunk,
);
} else {
result = await _toolRegistry.execute(toolUse.name, normalizedInput);
}
if (tool is BashTool) tool.shouldStop = null;
final success = !result.startsWith("Error");
await _toolTelemetryClient.recordToolCall(
toolName: toolUse.name,
@@ -466,9 +555,9 @@ class ToolLoopService {
_functionTool(
name: "Advisor",
description:
"Call the advisor model for a second opinion on your current approach. "
"Takes no parameters — your full conversation history is forwarded automatically. "
"Call BEFORE committing to a significant approach, BEFORE declaring done, or when stuck.",
"A second-opinion and planning tool — NOT an investigative tool. "
"Call when you need to validate an implementation approach, get a plan reviewed, or break out of a stuck state. "
"Do NOT call to answer questions, look things up, search the codebase, or understand the project — just do those yourself.",
properties: <String, dynamic>{},
required: const <String>[],
),
@@ -708,4 +797,48 @@ class ToolLoopService {
return "The model completed the turn without returning visible text.";
}
// Converts a tool result string to the appropriate API content format.
// IMAGE_BLOCK:<mediaType>:<base64> → image block list
// anything else → plain string
dynamic _buildToolResultContent(String result) {
const prefix = "IMAGE_BLOCK:";
if (!result.startsWith(prefix)) return result;
// may contain multiple IMAGE_BLOCK lines (e.g. notebook with output images)
final lines = result.split("\n");
final blocks = <Map<String, dynamic>>[];
final textBuf = StringBuffer();
for (final line in lines) {
if (line.startsWith(prefix)) {
if (textBuf.isNotEmpty) {
blocks.add({"type": "text", "text": textBuf.toString().trim()});
textBuf.clear();
}
final parts = line.substring(prefix.length).split(":");
if (parts.length >= 2) {
final mediaType = parts[0];
final b64 = parts.sublist(1).join(":");
blocks.add({
"type": "image_url",
"image_url": {"url": "data:$mediaType;base64,$b64"},
});
}
} else {
if (textBuf.isNotEmpty) textBuf.write("\n");
textBuf.write(line);
}
}
if (textBuf.isNotEmpty) {
blocks.add({"type": "text", "text": textBuf.toString().trim()});
}
if (blocks.isEmpty) return result;
if (blocks.length == 1 && blocks[0]["type"] == "image_url") {
return blocks;
}
return blocks;
}
}
+130
View File
@@ -0,0 +1,130 @@
import 'dart:io';
import '../command.dart';
import '../local_state.dart';
import '../session/conversation_history.dart';
import '../local_state.dart' show joinPath;
import '../utils/uuid_utils.dart';
// shared singleton history for the current run
final history = ConversationHistory();
String makeSessionId() => generateUuid();
const commonHelpArgs = <String>['help', '-h', '--help'];
const commonInfoArgs = <String>['current', 'info', 'show', 'status'];
const defaultStatuslinePrompt =
'Configure my statusLine from my shell PS1 configuration';
const max20xTier = 'default_claude_max_20x';
const modelAliases = <String>[
'best',
'haiku',
'opus',
'opus[1m]',
'opusplan',
'sonnet',
'sonnet[1m]',
];
String formatDuration(Duration duration) {
String twoDigits(int value) => value.toString().padLeft(2, '0');
final hours = twoDigits(duration.inHours);
final minutes = twoDigits(duration.inMinutes.remainder(60));
final seconds = twoDigits(duration.inSeconds.remainder(60));
return '$hours:$minutes:$seconds';
}
String renderModelSetting(String rawModel) {
switch (rawModel.toLowerCase()) {
case 'best':
return 'Best available';
case 'haiku':
return 'Claude Haiku';
case 'opus':
return 'Claude Opus';
case 'opus[1m]':
return 'Claude Opus [1m]';
case 'opusplan':
return 'Opus plan mode';
case 'sonnet':
return 'Claude Sonnet';
case 'sonnet[1m]':
return 'Claude Sonnet [1m]';
default:
return rawModel;
}
}
String resolveCurrentModelSetting(CommandContext context) {
final configured = context.settingsStore.settings.model;
if (configured != null && configured.trim().isNotEmpty) {
return configured.trim();
}
return "anthropic/claude-3.5-sonnet";
}
String showCurrentEffort(CommandContext context) {
final envLevel = getEffortEnvLevelOverride();
final effectiveValue = isEffortEnvClearOverride()
? null
: envLevel ?? context.sessionState.effortValue;
if (effectiveValue == null || effectiveValue.isEmpty) {
return 'Effort level: auto (currently high)';
}
return 'Current effort level: $effectiveValue (${getEffortDescription(effectiveValue)})';
}
String getEffortDescription(String effort) {
switch (effort) {
case 'low':
return 'Quick, straightforward implementation with minimal overhead';
case 'medium':
return 'Balanced approach with standard implementation and testing';
case 'high':
return 'Comprehensive implementation with extensive testing and documentation';
case 'max':
return 'Maximum capability with deepest reasoning (Opus 4.6 only)';
default:
return 'Balanced approach with standard implementation and testing';
}
}
String? getApplicableEffortEnvRaw() {
final raw = Platform.environment['CLAUDE_CODE_EFFORT_LEVEL'];
if (raw == null || raw.trim().isEmpty) return null;
final normalized = raw.trim().toLowerCase();
if (isEffortEnvClearOverride() || supportedEffortLevels.contains(normalized)) {
return raw.trim();
}
return null;
}
String? getEffortEnvLevelOverride() {
final raw = getApplicableEffortEnvRaw();
if (raw == null) return null;
final normalized = raw.toLowerCase();
if (supportedEffortLevels.contains(normalized)) return normalized;
return null;
}
bool isEffortEnvClearOverride() {
final raw = Platform.environment['CLAUDE_CODE_EFFORT_LEVEL'];
if (raw == null || raw.trim().isEmpty) return false;
final normalized = raw.trim().toLowerCase();
return normalized == 'auto' || normalized == 'unset';
}
String buildStatuslineAgentInstruction(String prompt) =>
'Create an Agent with subagent_type "statusline-setup" and the prompt "$prompt"';
String homeDir() =>
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '';
String theAgencyHome() => joinPath(homeDir(), '.the_agency');
+43
View File
@@ -0,0 +1,43 @@
import 'dart:io';
import '../command.dart';
import '../local_state.dart' show joinPath;
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final dirArg = args.join(" ").trim();
if (dirArg.isEmpty || commonHelpArgs.contains(dirArg.toLowerCase())) {
context.writeLine(
'Usage: /add-dir <path>\n\n'
'Add a directory to the current session workspace.\n'
'Claude will be able to read and edit files in the added directory.',
);
return const CommandResult();
}
final resolved = dirArg.startsWith('/')
? dirArg
: joinPath(context.workingDirectory, dirArg);
final dir = Directory(resolved);
if (!await dir.exists()) {
context.writeError('Directory does not exist: $resolved');
return const CommandResult(exitCode: 1);
}
if (context.sessionState.additionalDirectories.contains(resolved)) {
context.writeLine('Directory already in workspace: $resolved');
return const CommandResult();
}
context.sessionState.additionalDirectories.add(resolved);
context.writeLine('Added directory to workspace: $resolved');
context.writeLine('Active workspace directories:');
context.writeLine(' ${context.workingDirectory} (primary)');
for (final d in context.sessionState.additionalDirectories) {
context.writeLine(' $d');
}
return const CommandResult();
}
+40
View File
@@ -0,0 +1,40 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final arg = args.join(' ').trim().toLowerCase();
if (arg.isEmpty) {
final current = context.sessionState.advisorModel
?? context.settingsStore.settings.advisorModel;
if (current == null) {
context.writeLine(
'Advisor: not set\nUse "/advisor <model>" to enable (e.g. "/advisor opus").',
);
} else {
context.writeLine(
'Advisor: $current\nUse "/advisor unset" to disable or "/advisor <model>" to change.',
);
}
return const CommandResult();
}
if (arg == 'unset' || arg == 'off') {
final prev = context.sessionState.advisorModel
?? context.settingsStore.settings.advisorModel;
context.sessionState.advisorModel = null;
await context.settingsStore.update((s) => s.copyWith(advisorModel: null));
context.writeLine(prev != null ? 'Advisor disabled (was $prev).' : 'Advisor already unset.');
return const CommandResult();
}
if (commonHelpArgs.contains(arg)) {
context.writeLine('Usage: /advisor [<model>|off]\n\nSet the advisor model for the session.');
return const CommandResult();
}
context.sessionState.advisorModel = arg;
await context.settingsStore.update((s) => s.copyWith(advisorModel: arg));
context.writeLine('Advisor set to $arg.');
return const CommandResult();
}
+14
View File
@@ -0,0 +1,14 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine("Agents");
context.writeLine("");
context.writeLine("The interactive agents manager is not ported to the Dart CLI.");
context.writeLine(
"In the legacy CLI this shows a menu to configure which tools agents can use.",
);
context.writeLine("");
context.writeLine("Available agent tools are determined by your permission settings.");
context.writeLine("Use /permissions to manage tool access rules.");
return const CommandResult(exitCode: 2);
}
+33
View File
@@ -0,0 +1,33 @@
import 'dart:io';
import '../command.dart';
import '../daemon/daemon_manager.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
if (args.isEmpty) {
context.writeLine("Usage: /attach <session-id>");
return const CommandResult(exitCode: 1);
}
final id = args[0];
final mgr = DaemonManager();
final rec = await mgr.loadRecord(id);
if (rec == null) {
context.writeLine("Session not found: $id");
return const CommandResult(exitCode: 1);
}
final desc = await mgr.describeSession(id);
if (desc != null) {
context.writeLine(desc);
context.writeLine("--- streaming logs (Ctrl-C to stop) ---");
context.writeLine("");
}
await for (final chunk in mgr.streamLogs(id)) {
stdout.write(chunk);
}
return const CommandResult(exitCode: 0);
}
+41
View File
@@ -0,0 +1,41 @@
import '../command.dart';
import '../session/session_store.dart';
import '../session/session_types.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final customTitle = args.join(" ").trim();
if (!history.hasSession) {
context.writeLine("No active session to branch from.");
return const CommandResult(exitCode: 1);
}
final src = history.session!;
final now = DateTime.now().toUtc();
final newId = makeSessionId();
final branchName = customTitle.isNotEmpty ? customTitle : "${src.name} (branch)";
final forked = ConversationSession(
id: newId,
name: branchName,
created: now,
updated: now,
messages: src.messages.map((m) => Message(
role: m.role,
content: m.content,
timestamp: m.timestamp,
tokens: m.tokens,
)).toList(),
model: src.model,
);
await SessionStore.instance.saveSession(forked);
history.setSession(forked);
context.sessionState.sessionName = branchName;
context.writeLine('Branched into new session: "$branchName"');
context.writeLine("New session ID: $newId");
return const CommandResult();
}
+8
View File
@@ -0,0 +1,8 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final newState = !context.sessionState.briefModeEnabled;
context.sessionState.briefModeEnabled = newState;
context.writeLine(newState ? 'Brief-only mode enabled' : 'Brief-only mode disabled');
return const CommandResult();
}
+27
View File
@@ -0,0 +1,27 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final question = args.join(" ").trim();
context.writeLine("Side Question (btw)");
context.writeLine("");
if (question.isEmpty) {
context.writeLine("Usage: /btw <question>");
context.writeLine("");
context.writeLine(
"Ask a quick side question without affecting the main conversation context.",
);
} else {
context.writeLine("Question: $question");
context.writeLine("");
context.writeLine(
"Side question mode is not fully ported - this requires a live model session.",
);
context.writeLine(
"The question would normally be answered without adding to the main context.",
);
}
return const CommandResult(exitCode: 2);
}
+17
View File
@@ -0,0 +1,17 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArg = args.join(' ').trim().toLowerCase();
if (rawArg == 'status' || rawArg == 'current') {
context.writeLine(
context.sessionState.bughunterMode ? 'Bug hunter mode: ON' : 'Bug hunter mode: OFF',
);
return const CommandResult();
}
final newState = !context.sessionState.bughunterMode;
context.sessionState.bughunterMode = newState;
context.writeLine(newState ? 'Bug hunter mode: ON' : 'Bug hunter mode: OFF');
return const CommandResult();
}
+17
View File
@@ -0,0 +1,17 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
const extensionUrl = 'https://claude.ai/chrome';
const permissionsUrl = 'https://clau.de/chrome/permissions';
context.writeLine('Claude in Chrome (Beta)');
context.writeLine('');
context.writeLine('Lets Claude access your browser context when you\'re on claude.ai.');
context.writeLine('');
context.writeLine('Extension: $extensionUrl');
context.writeLine('Permissions: $permissionsUrl');
context.writeLine('');
context.writeLine('The interactive Chrome extension settings panel is not ported to the Dart runtime.');
return const CommandResult();
}
+6
View File
@@ -0,0 +1,6 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.out.write('\x1B[2J\x1B[H');
return const CommandResult();
}
+29
View File
@@ -0,0 +1,29 @@
import '../command.dart';
import '../local_state.dart';
const _resetAliases = <String>['default', 'reset', 'none', 'gray', 'grey'];
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim().toLowerCase();
if (rawArgs.isEmpty) {
final colorList = supportedAgentColors.join(', ');
context.writeLine('Please provide a color. Available colors: $colorList, default');
return const CommandResult();
}
if (_resetAliases.contains(rawArgs)) {
context.sessionState.sessionColor = null;
context.writeLine('Session color reset to default');
return const CommandResult();
}
if (!supportedAgentColors.contains(rawArgs)) {
final colorList = supportedAgentColors.join(', ');
context.writeLine('Invalid color "$rawArgs". Available colors: $colorList, default');
return const CommandResult();
}
context.sessionState.sessionColor = rawArgs;
context.writeLine('Session color set to: $rawArgs');
return const CommandResult();
}
+37
View File
@@ -0,0 +1,37 @@
import 'dart:io';
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine('Create git commit');
context.writeLine('');
context.writeLine(
'This is a prompt-type command. In the legacy CLI it sends the current git diff'
' to the model and asks it to stage and create a commit.',
);
context.writeLine('');
try {
final statusResult = await Process.run(
'git',
['status', '--short'],
workingDirectory: context.workingDirectory,
);
final statusOut = (statusResult.stdout as String).trim();
if (statusOut.isEmpty) {
context.writeLine('git status: nothing to commit, working tree clean');
} else {
context.writeLine('Current changes:');
context.writeLine(statusOut);
}
} on ProcessException {
context.writeLine('(could not run git status)');
}
context.writeLine('');
context.writeLine(
'Run `git add` and `git commit` manually, or use the legacy CLI for AI-assisted commits.',
);
return const CommandResult(exitCode: 2);
}
+46
View File
@@ -0,0 +1,46 @@
import 'dart:io';
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine("Commit, push, and open a PR");
context.writeLine("");
context.writeLine(
"This is a prompt-type command. In the legacy CLI it uses the AI model to:\n"
" 1. Create a branch (if on main)\n"
" 2. Stage and commit all changes\n"
" 3. Push to origin\n"
" 4. Create or update a GitHub PR via gh",
);
context.writeLine("");
try {
final branchResult = await Process.run(
"git", ["branch", "--show-current"],
workingDirectory: context.workingDirectory,
);
final branch = (branchResult.stdout as String).trim();
if (branch.isNotEmpty) context.writeLine("Current branch: $branch");
final statusResult = await Process.run(
"git", ["status", "--short"],
workingDirectory: context.workingDirectory,
);
final status = (statusResult.stdout as String).trim();
if (status.isEmpty) {
context.writeLine("Nothing to commit (working tree clean).");
} else {
context.writeLine("Uncommitted changes:");
context.writeLine(status);
}
} on ProcessException {
context.writeLine("(could not run git commands)");
}
context.writeLine("");
context.writeLine(
"Run `git commit && git push && gh pr create` manually, or use the legacy CLI for AI-assisted PR creation.",
);
return const CommandResult(exitCode: 2);
}
+16
View File
@@ -0,0 +1,16 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final instructions = args.join(' ').trim();
context.writeLine(
'Compact conversation: message history is not available in the Dart CLI runtime yet.',
);
if (instructions.isNotEmpty) {
context.writeLine('Custom instructions provided: "$instructions"');
}
context.writeLine(
'\nIn the legacy CLI this summarizes all messages and replaces them with a summary,'
' keeping the context window fresh.',
);
return const CommandResult(exitCode: 2);
}
+36
View File
@@ -0,0 +1,36 @@
import 'dart:convert';
import '../command.dart';
const _jsonEncoder = JsonEncoder.withIndent(' ');
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim().toLowerCase();
if (rawArgs == 'path' || rawArgs == 'open') {
context.writeLine(context.settingsStore.path);
return const CommandResult();
}
if (rawArgs.isNotEmpty && rawArgs != 'show') {
context.writeLine('Usage: /config [show|path]');
return const CommandResult(exitCode: 64);
}
context.writeLine('Config file: ${context.settingsStore.path}');
context.writeLine('Runtime state: ${context.runtimeStateStore.path}');
context.writeLine('');
context.writeLine('Settings:');
context.writeLine(_jsonEncoder.convert(context.settingsStore.settings.toJson()));
context.writeLine('');
context.writeLine('Runtime state:');
context.writeLine(_jsonEncoder.convert(context.runtimeStateStore.state.toJson()));
context.writeLine('');
context.writeLine('Session state:');
context.writeLine(' planModeEnabled: ${context.sessionState.planModeEnabled}');
context.writeLine(' sessionColor: ${context.sessionState.sessionColor ?? 'default'}');
context.writeLine(' effortValue: ${context.sessionState.effortValue ?? 'auto'}');
context.writeLine(' planFilePath: ${context.sessionState.planFilePath}');
context.writeLine(' commandsExecuted: ${context.sessionState.commandsExecuted}');
return const CommandResult();
}
+20
View File
@@ -0,0 +1,20 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final elapsed = DateTime.now().toUtc().difference(context.sessionState.startedAt);
context.writeLine('Context window usage');
context.writeLine('');
context.writeLine(' Token accounting is not ported to the Dart runtime yet.');
context.writeLine(' In the legacy CLI this shows a colored grid of used vs available context.');
context.writeLine('');
context.writeLine(' Session uptime: ${formatDuration(elapsed)}');
context.writeLine(' Commands run: ${context.sessionState.commandsExecuted}');
context.writeLine(' Working dir: ${context.workingDirectory}');
if (context.sessionState.additionalDirectories.isNotEmpty) {
context.writeLine(' Extra dirs: ${context.sessionState.additionalDirectories.length}');
}
return const CommandResult();
}
+33
View File
@@ -0,0 +1,33 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
if (!history.hasSession) {
context.writeLine("No active session - nothing to copy.");
return const CommandResult(exitCode: 1);
}
final msgs = history.getMessages();
final assistantMsgs = msgs.where((m) => m.role == "assistant").toList();
if (assistantMsgs.isEmpty) {
context.writeLine("No assistant messages in the current session.");
return const CommandResult(exitCode: 1);
}
int idx = assistantMsgs.length - 1;
if (args.isNotEmpty) {
final parsed = int.tryParse(args.first.trim());
if (parsed != null && parsed > 0 && parsed <= assistantMsgs.length) {
idx = assistantMsgs.length - parsed;
}
}
final msg = assistantMsgs[idx];
context.writeLine(msg.content);
context.writeLine(
"\n(Note: clipboard copy via OSC 52 is not wired in the Dart runtime - text printed above)",
);
return const CommandResult();
}
+7
View File
@@ -0,0 +1,7 @@
import '../command.dart';
import '../services/cost_tracker.dart' as costTracker;
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine(costTracker.formatTotalCost());
return const CommandResult();
}
+19
View File
@@ -0,0 +1,19 @@
import 'dart:io';
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
if (!Platform.isMacOS && !Platform.isWindows) {
context.writeLine('Claude Desktop is only available on macOS and Windows.');
return const CommandResult(exitCode: 1);
}
context.writeLine('Claude Desktop');
context.writeLine('');
context.writeLine('Opens the current session in the Claude Desktop app.');
context.writeLine('');
context.writeLine('Session handoff to Claude Desktop is not ported to the Dart CLI runtime.');
context.writeLine('Download Claude Desktop: https://claude.ai/download');
return const CommandResult(exitCode: 2);
}
+32
View File
@@ -0,0 +1,32 @@
import 'dart:io';
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(" ").trim();
try {
final result = await Process.run(
'git',
rawArgs.isEmpty ? ['diff'] : ['diff', ...args],
workingDirectory: context.workingDirectory,
);
if (result.exitCode != 0 && (result.stderr as String).isNotEmpty) {
context.writeError((result.stderr as String).trim());
return CommandResult(exitCode: result.exitCode);
}
final out = (result.stdout as String).trim();
if (out.isEmpty) {
context.writeLine("No changes (clean working tree)");
} else {
context.writeLine(out);
}
} on ProcessException catch (e) {
context.writeError("Could not run git diff: ${e.message}");
return const CommandResult(exitCode: 1);
}
return const CommandResult();
}
+72
View File
@@ -0,0 +1,72 @@
import 'dart:io';
import '../command.dart';
import '../local_state.dart' show joinPath;
const _largeClaudeMdWarningChars = 40000;
Future<CommandResult> run(CommandContext context, List<String> args) async {
final workingDirectory = Directory(context.workingDirectory);
final legacyRoot = Directory(joinPath(context.workingDirectory, 'old_repo'));
final claudeMdFile = File(
joinPath(joinPath(context.workingDirectory, '.the_agency'), 'THE_AGENCY.md'),
);
final hasGit = await Directory(joinPath(context.workingDirectory, '.git')).exists();
final hasLegacyPackageManifest =
await File(joinPath(legacyRoot.path, 'package.json')).exists() ||
await File(joinPath(legacyRoot.path, 'tsconfig.json')).exists();
final configFile = File(context.settingsStore.path);
final runtimeFile = File(context.runtimeStateStore.path);
context.writeLine('Doctor');
context.writeLine(
'Runtime: Dart ${Platform.version.split(' ').first} on ${Platform.operatingSystem}',
);
context.writeLine('Working directory: ${workingDirectory.path}');
context.writeLine('');
context.writeLine(
'[ok] settings: ${await configFile.exists() ? context.settingsStore.path : 'missing'}',
);
context.writeLine(
'[ok] runtime state: ${await runtimeFile.exists() ? context.runtimeStateStore.path : 'missing'}',
);
context.writeLine(
'[${await legacyRoot.exists() ? 'ok' : 'warn'}] legacy source root: ${legacyRoot.path}',
);
context.writeLine(
'[${hasGit ? 'ok' : 'warn'}] git repository: ${hasGit ? 'detected' : 'not detected'}',
);
if (await claudeMdFile.exists()) {
final length = await claudeMdFile.length();
final level = length > _largeClaudeMdWarningChars ? 'warn' : 'ok';
context.writeLine(
'[$level] THE_AGENCY.md: ${claudeMdFile.path} (${length.toString()} bytes)',
);
} else {
context.writeLine('[warn] THE_AGENCY.md: not found');
}
context.writeLine(
'[${hasLegacyPackageManifest ? 'ok' : 'warn'}] legacy manifests: ${hasLegacyPackageManifest ? 'detected' : 'old_repo has no package.json or tsconfig.json at its root'}',
);
context.writeLine('');
context.writeLine('Notes:');
if (!hasGit) {
context.writeLine(' - This workspace is not currently inside a git repository.');
}
if (!hasLegacyPackageManifest) {
context.writeLine(
' - Exact legacy runtime reproduction is harder because old_repo lacks a checked-in package manifest.',
);
}
if (!await legacyRoot.exists()) {
context.writeLine(' - old_repo is missing, so legacy source parity checks cannot run.');
}
if (hasGit && hasLegacyPackageManifest && await legacyRoot.exists()) {
context.writeLine(' - No obvious environment blockers detected.');
}
return const CommandResult();
}
+84
View File
@@ -0,0 +1,84 @@
import '../command.dart';
import '../local_state.dart';
import '_shared.dart';
const _helpText =
'Usage: /effort [low|medium|high|max|auto]\n\n'
'Effort levels:\n'
'- low: Quick, straightforward implementation\n'
'- medium: Balanced approach with standard testing\n'
'- high: Comprehensive implementation with extensive testing\n'
'- max: Maximum capability with deepest reasoning (Opus 4.6 only)\n'
'- auto: Use the default effort level for your model';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim();
if (commonHelpArgs.contains(rawArgs)) {
context.writeLine(_helpText);
return const CommandResult();
}
if (rawArgs.isEmpty || rawArgs == 'current' || rawArgs == 'status') {
context.writeLine(showCurrentEffort(context));
return const CommandResult();
}
final normalized = rawArgs.toLowerCase();
if (normalized == 'auto' || normalized == 'unset') {
context.sessionState.effortValue = null;
await context.settingsStore.update(
(settings) => settings.copyWith(effortLevel: null),
);
final applicableEnvRaw = getApplicableEffortEnvRaw();
if (applicableEnvRaw != null && !isEffortEnvClearOverride()) {
context.writeLine(
'Cleared effort from settings, but CLAUDE_CODE_EFFORT_LEVEL=$applicableEnvRaw still controls this session',
);
return const CommandResult();
}
context.writeLine('Effort level set to auto');
return const CommandResult();
}
if (!supportedEffortLevels.contains(normalized)) {
context.writeLine(
'Invalid argument: $rawArgs. Valid options are: low, medium, high, max, auto',
);
return const CommandResult(exitCode: 64);
}
context.sessionState.effortValue = normalized;
if (normalized == 'max') {
final applicableEnvRaw = getApplicableEffortEnvRaw();
if (applicableEnvRaw != null &&
getEffortEnvLevelOverride() != normalized) {
context.writeLine(
'Not applied: CLAUDE_CODE_EFFORT_LEVEL=$applicableEnvRaw overrides effort this session, and $normalized is session-only (nothing saved)',
);
return const CommandResult();
}
context.writeLine(
'Set effort level to $normalized (this session only): ${getEffortDescription(normalized)}',
);
return const CommandResult();
}
await context.settingsStore.update(
(settings) => settings.copyWith(effortLevel: normalized),
);
final applicableEnvRaw = getApplicableEffortEnvRaw();
if (applicableEnvRaw != null && getEffortEnvLevelOverride() != normalized) {
context.writeLine(
'CLAUDE_CODE_EFFORT_LEVEL=$applicableEnvRaw overrides this session — clear it and $normalized takes over',
);
return const CommandResult();
}
context.writeLine(
'Set effort level to $normalized: ${getEffortDescription(normalized)}',
);
return const CommandResult();
}
+32
View File
@@ -0,0 +1,32 @@
import 'dart:io';
import '../command.dart';
const _relevantKeys = <String>[
'OPENROUTER_API_KEY',
'CLAUDE_CODE_EFFORT_LEVEL',
'CLAUDE_CODE_SKIP_PERMISSIONS_CHECK',
'EDITOR',
'VISUAL',
'HOME',
'PATH',
'SHELL',
'USER',
'USER_TYPE',
];
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine("Environment variables:");
for (final key in _relevantKeys) {
final val = Platform.environment[key];
if (val != null) {
final display = key.contains('KEY') && val.length > 8
? '${val.substring(0, 4)}...${val.substring(val.length - 4)}'
: val;
context.writeLine(" $key=$display");
} else {
context.writeLine(" $key=(unset)");
}
}
return const CommandResult();
}
+5
View File
@@ -0,0 +1,5 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
return const CommandResult(exitRepl: true);
}
+34
View File
@@ -0,0 +1,34 @@
import 'dart:io';
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final filename = args.join(" ").trim();
if (!history.hasSession) {
context.writeLine("No active session to export.");
return const CommandResult(exitCode: 1);
}
final sess = history.session!;
final isJson = filename.endsWith(".json");
final content = isJson ? history.exportToJson() : history.exportToText();
if (filename.isEmpty) {
context.writeLine(content);
return const CommandResult();
}
try {
final file = File(filename);
await file.parent.create(recursive: true);
await file.writeAsString(content);
context.writeLine('Exported ${sess.messageCount} messages to: $filename');
} catch (e) {
context.writeError("Failed to write export file: $e");
return const CommandResult(exitCode: 1);
}
return const CommandResult();
}
+31
View File
@@ -0,0 +1,31 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim().toLowerCase();
if (commonHelpArgs.contains(rawArgs)) {
context.writeLine('Usage: /fast [on|off|status]');
return const CommandResult();
}
if (rawArgs.isEmpty || rawArgs == 'status' || rawArgs == 'current') {
context.writeLine(
context.settingsStore.settings.fastMode ? 'Fast mode ON' : 'Fast mode OFF',
);
return const CommandResult();
}
if (rawArgs != 'on' && rawArgs != 'off') {
context.writeLine(
'Invalid argument: $rawArgs. Valid options are: on, off, status',
);
return const CommandResult(exitCode: 64);
}
final enabled = rawArgs == 'on';
await context.settingsStore.update(
(settings) => settings.copyWith(fastMode: enabled),
);
context.writeLine(enabled ? 'Fast mode ON' : 'Fast mode OFF');
return const CommandResult();
}
+19
View File
@@ -0,0 +1,19 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final report = args.join(' ').trim();
const feedbackUrl = 'https://github.com/anthropics/claude-code/issues/new';
context.writeLine('Submit Feedback / Bug Report');
context.writeLine('');
if (report.isNotEmpty) {
context.writeLine('Your report: "$report"');
context.writeLine('');
}
context.writeLine('Interactive feedback submission is not ported to the Dart CLI runtime yet.');
context.writeLine('Please open an issue at: $feedbackUrl');
return const CommandResult(exitCode: 2);
}
+9
View File
@@ -0,0 +1,9 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine("No files in context");
context.writeLine(
"(Note: file context tracking is not yet ported to the Dart CLI runtime)",
);
return const CommandResult();
}
+73
View File
@@ -0,0 +1,73 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final requestedCommand = args.isEmpty
? null
: args.first.startsWith('/')
? args.first.substring(1)
: args.first;
if (requestedCommand != null) {
final ported = context.catalog.findPorted(requestedCommand, InvocationSurface.both);
final legacy = context.catalog.findLegacy(requestedCommand, InvocationSurface.both);
final reserved = context.catalog.findReservedTopLevel(requestedCommand);
final descriptor = ported ?? legacy ?? reserved;
if (descriptor == null) {
context.writeError('No known command named "$requestedCommand".');
return const CommandResult(exitCode: 64);
}
_writeCommandDetails(context, descriptor);
return const CommandResult();
}
context.writeLine('Usage:');
context.writeLine(' clawd_code Start the interactive CLI');
context.writeLine(' clawd_code --help Show help');
context.writeLine(' clawd_code --version Print version');
context.writeLine(' clawd_code <entrypoint> Run a known top-level legacy entrypoint');
context.writeLine('');
context.writeLine('Ported commands:');
for (final command in context.catalog.portedCommands) {
final aliases = command.aliases.isEmpty
? ''
: ' (aliases: ${command.aliases.join(', ')})';
context.writeLine(' /${command.name}$aliases');
}
context.writeLine('');
context.writeLine(
'Known legacy slash commands: ${context.catalog.totalKnownSlashCommands}',
);
context.writeLine(
'Reserved top-level legacy entrypoints: ${context.catalog.totalReservedTopLevelEntryPoints}',
);
context.writeLine(
'Remaining unported slash commands: ${context.catalog.unportedSlashCommands.length}',
);
context.writeLine('');
context.writeLine('Examples:');
context.writeLine(' /status');
context.writeLine(' /model opus');
context.writeLine(' /permissions allow Bash(npm test)');
context.writeLine(' /init preview');
context.writeLine(' remote-control');
return const CommandResult();
}
void _writeCommandDetails(CommandContext context, LegacyCommandDescriptor descriptor) {
context.writeLine('Command: ${descriptor.name}');
context.writeLine('Surface: ${descriptor.surface.label}');
context.writeLine('Kind: ${descriptor.kind.name}');
if (descriptor.aliases.isNotEmpty) {
context.writeLine('Aliases: ${descriptor.aliases.join(', ')}');
}
if (descriptor.description != null && descriptor.description!.isNotEmpty) {
context.writeLine('Description: ${descriptor.description!}');
}
context.writeLine('Legacy source: ${descriptor.legacySourcePath}');
if (descriptor.isInferred) {
context.writeLine('Metadata note: name inferred from legacy file path.');
}
}
+36
View File
@@ -0,0 +1,36 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim().toLowerCase();
if (commonHelpArgs.contains(rawArgs)) {
context.writeLine(
'Usage: /hooks\n\n'
'View and manage hook configurations for tool events.\n'
'Hooks are defined in your settings file:\n'
' ${context.settingsStore.path}',
);
return const CommandResult();
}
final hooks = context.settingsStore.settings.hooks;
context.writeLine('Hook configurations');
context.writeLine('Settings file: ${context.settingsStore.path}');
context.writeLine('');
if (hooks == null || hooks.isEmpty) {
context.writeLine('No hooks configured.');
context.writeLine('');
context.writeLine('Hooks allow you to run scripts when tools are used.');
context.writeLine('Add them to your settings.json under the "hooks" key.');
} else {
context.writeLine('Configured hooks:');
for (final entry in hooks.entries) {
context.writeLine(' ${entry.key}: ${entry.value}');
}
}
return const CommandResult();
}
+35
View File
@@ -0,0 +1,35 @@
import 'dart:io';
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final termProgram = Platform.environment['TERM_PROGRAM'] ?? '';
final askpassMain = Platform.environment['VSCODE_GIT_ASKPASS_MAIN'] ?? '';
final path = Platform.environment['PATH'] ?? '';
String? detectedIde;
if (termProgram == 'vscode') detectedIde = 'VSCode';
else if (termProgram == 'cursor') detectedIde = 'Cursor';
else if (termProgram == 'windsurf') detectedIde = 'Windsurf';
else if (askpassMain.contains('cursor-server') || path.contains('cursor-server')) detectedIde = 'Cursor (remote)';
else if (askpassMain.contains('windsurf-server') || path.contains('windsurf-server')) detectedIde = 'Windsurf (remote)';
else if (askpassMain.contains('vscode-server') || path.contains('vscode-server')) detectedIde = 'VSCode (remote)';
context.writeLine('IDE Integration');
context.writeLine('');
if (detectedIde != null) {
context.writeLine('Detected IDE: $detectedIde');
} else {
context.writeLine('No supported IDE detected from environment.');
}
context.writeLine('');
context.writeLine('Supported integrations: VSCode, Cursor, Windsurf (via the Claude extension)');
context.writeLine('');
context.writeLine('The interactive IDE management panel is not ported to the Dart CLI runtime.');
context.writeLine('Install the Claude extension from the marketplace in your IDE.');
return const CommandResult();
}
+155
View File
@@ -0,0 +1,155 @@
import 'dart:convert';
import 'dart:io';
import '../command.dart';
import '../local_state.dart' show joinPath;
const _initHeader =
'# THE_AGENCY.md\n\n'
'This file provides guidance to The Agency when working with code in this repository.\n';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final command = args.isEmpty ? 'write' : args.first.toLowerCase();
final force = args.any((arg) => arg == '--force' || arg == 'force');
final agencyDir = joinPath(context.workingDirectory, '.the_agency');
final theAgencyMdPath = joinPath(agencyDir, 'THE_AGENCY.md');
final targetFile = File(theAgencyMdPath);
final draft = await _buildDraft(context.workingDirectory);
if (command == 'preview' || command == 'show') {
context.writeLine(draft);
return const CommandResult();
}
if (!force && await targetFile.exists()) {
context.writeLine('THE_AGENCY.md already exists at $theAgencyMdPath');
context.writeLine(
'Run /init preview to inspect the regenerated draft or /init force to overwrite it.',
);
return const CommandResult();
}
await Directory(agencyDir).create(recursive: true);
await targetFile.writeAsString('$draft\n');
context.writeLine('Wrote $theAgencyMdPath');
return const CommandResult();
}
Future<String> _buildDraft(String workingDirectory) async {
final commands = await _collectDetectedCommands(workingDirectory);
final architecture = await _collectArchitectureNotes(workingDirectory);
final buffer = StringBuffer()..write(_initHeader);
if (commands.isNotEmpty) {
buffer.writeln();
buffer.writeln('## Common Commands');
for (final command in commands) {
buffer.writeln('- `$command`');
}
}
if (architecture.isNotEmpty) {
buffer.writeln();
buffer.writeln('## Architecture');
for (final note in architecture) {
buffer.writeln('- $note');
}
}
buffer.writeln();
buffer.writeln('## Notes');
buffer.writeln(
'- Preserve the Dart CLI surface while using `old_repo/` as the legacy behavior reference during migration work.',
);
buffer.writeln(
'- Prefer concise, targeted changes over broad rewrites unless a command or runtime subsystem is being ported intentionally.',
);
return buffer.toString().trimRight();
}
Future<List<String>> _collectArchitectureNotes(String workingDirectory) async {
final notes = <String>[];
final binDir = Directory(joinPath(workingDirectory, 'bin'));
final libDir = Directory(joinPath(workingDirectory, 'lib'));
final oldRepoDir = Directory(joinPath(workingDirectory, 'old_repo'));
final testDir = Directory(joinPath(workingDirectory, 'test'));
if (await binDir.exists()) {
notes.add('`bin/` contains the executable entrypoints for the Dart CLI.');
}
if (await libDir.exists()) {
notes.add('`lib/src/` contains the migrated Dart command/runtime implementation.');
}
if (await oldRepoDir.exists()) {
notes.add('`old_repo/` is the legacy TypeScript reference implementation being ported 1:1.');
}
if (await testDir.exists()) {
notes.add('`test/` holds Dart validation coverage for the migrated runtime.');
}
return notes;
}
Future<List<String>> _collectDetectedCommands(String workingDirectory) async {
final commands = <String>[];
final pubspecFile = File(joinPath(workingDirectory, 'pubspec.yaml'));
final packageJsonFile = File(joinPath(workingDirectory, 'package.json'));
final cargoFile = File(joinPath(workingDirectory, 'Cargo.toml'));
final goModFile = File(joinPath(workingDirectory, 'go.mod'));
final makeFile = File(joinPath(workingDirectory, 'Makefile'));
final pomFile = File(joinPath(workingDirectory, 'pom.xml'));
final testDir = Directory(joinPath(workingDirectory, 'test'));
final binDir = Directory(joinPath(workingDirectory, 'bin'));
if (await pubspecFile.exists()) {
commands.add('dart pub get');
commands.add('dart analyze');
if (await testDir.exists()) {
commands.add('dart test');
}
if (await binDir.exists()) {
final binEntries = await binDir
.list()
.where((entity) => entity is File)
.cast<File>()
.toList();
if (binEntries.isNotEmpty) {
final firstFile = binEntries.first.uri.pathSegments.last;
commands.add('dart run bin/$firstFile');
}
}
}
if (await packageJsonFile.exists()) {
commands.addAll(await _extractPackageJsonCommands(packageJsonFile));
}
if (await cargoFile.exists()) commands.addAll(['cargo build', 'cargo test']);
if (await goModFile.exists()) commands.add('go test ./...');
if (await pomFile.exists()) commands.add('mvn test');
if (await makeFile.exists()) commands.add('make');
return commands.toSet().toList(growable: false);
}
Future<List<String>> _extractPackageJsonCommands(File packageJsonFile) async {
try {
final raw = await packageJsonFile.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! Map) return const [];
final scripts = decoded['scripts'];
if (scripts is! Map) return const [];
final commands = <String>[];
for (final entry in scripts.entries) {
final key = entry.key.toString();
if (key == 'build' || key == 'lint' || key == 'test' || key == 'dev') {
commands.add('npm run $key');
}
}
return commands;
} catch (_) {
return const [];
}
}
+21
View File
@@ -0,0 +1,21 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine("Init verifiers");
context.writeLine("");
context.writeLine(
"This command analyzes your project and creates verifier skills in .claude/skills/.\n"
"Verifier skills are used by the Verify agent to automatically verify code changes.",
);
context.writeLine("");
context.writeLine("Supported verifier types:");
context.writeLine(" verifier-playwright - for web UIs (Playwright)");
context.writeLine(" verifier-cli - for CLI tools (Tmux)");
context.writeLine(" verifier-api - for HTTP API services");
context.writeLine("");
context.writeLine(
"In the legacy CLI this runs an AI prompt that detects your project type\n"
"and generates the skill file interactively. Use the legacy CLI for full support.",
);
return const CommandResult(exitCode: 2);
}
+39
View File
@@ -0,0 +1,39 @@
import 'dart:io';
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
const docsUrl = 'https://docs.anthropic.com/en/docs/claude-code/github-actions';
context.writeLine('Install GitHub App');
context.writeLine('');
context.writeLine(
'Sets up Claude GitHub Actions for a repository so Claude can review PRs\n'
'and respond to issues automatically.',
);
context.writeLine('');
context.writeLine(
'The interactive setup wizard (OAuth, repo selection, workflow creation)\n'
'is not ported to the Dart CLI runtime yet.',
);
context.writeLine('');
context.writeLine('Documentation: $docsUrl');
context.writeLine('');
try {
final ghResult = await Process.run(
'gh',
['repo', 'view', '--json', 'name,owner', '--jq', '.owner.login + "/" + .name'],
workingDirectory: context.workingDirectory,
);
final repo = (ghResult.stdout as String).trim();
if (repo.isNotEmpty) {
context.writeLine('Current repo: $repo');
context.writeLine('Run: gh workflow list (to see existing workflows)');
}
} on ProcessException {
// gh not installed, thats fine
}
return const CommandResult(exitCode: 2);
}
+55
View File
@@ -0,0 +1,55 @@
import 'dart:io';
import '../command.dart';
import '../local_state.dart' show joinPath;
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final keybindingsPath = joinPath(joinPath(homeDir(), '.claude'), 'keybindings.json');
final rawArgs = args.join(" ").trim().toLowerCase();
if (commonHelpArgs.contains(rawArgs)) {
context.writeLine(
'Usage: /keybindings\n\n'
'Opens your keybindings config file in \$EDITOR or \$VISUAL.\n'
'File location: $keybindingsPath',
);
return const CommandResult();
}
final f = File(keybindingsPath);
final existed = await f.exists();
if (!existed) {
await Directory(joinPath(homeDir(), '.claude')).create(recursive: true);
await f.writeAsString(
'// Claude Code keybindings\n'
'// See docs for available actions.\n'
'[\n'
' // { "key": "ctrl+shift+r", "action": "clearHistory" }\n'
']\n',
);
}
final editor = Platform.environment['VISUAL'] ?? Platform.environment['EDITOR'];
if (editor == null) {
context.writeLine(
'${existed ? 'Keybindings file' : 'Created keybindings file'}: $keybindingsPath',
);
context.writeLine('Set \$EDITOR or \$VISUAL to open it automatically.');
return const CommandResult();
}
try {
final proc = await Process.start(editor, [keybindingsPath], mode: ProcessStartMode.inheritStdio);
await proc.exitCode;
context.writeLine('${existed ? 'Opened' : 'Created and opened'} $keybindingsPath');
} on ProcessException catch (e) {
context.writeError('Could not open editor ($editor): ${e.message}');
context.writeLine('File is at: $keybindingsPath');
return const CommandResult(exitCode: 1);
}
return const CommandResult();
}
+28
View File
@@ -0,0 +1,28 @@
import '../command.dart';
import '../daemon/daemon_manager.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
if (args.isEmpty) {
context.writeLine("Usage: /kill <session-id> [--force]");
return const CommandResult(exitCode: 1);
}
final id = args[0];
final force = args.contains("--force") || args.contains("-f");
final mgr = DaemonManager();
final ok = await mgr.killSession(id, force: force);
if (ok) {
context.writeLine("Killed session: $id");
return const CommandResult(exitCode: 0);
}
final rec = await mgr.loadRecord(id);
if (rec == null) {
context.writeLine("Session not found: $id");
} else {
context.writeLine("Could not kill session $id (status=${rec.status.name})");
}
return const CommandResult(exitCode: 1);
}
+59
View File
@@ -0,0 +1,59 @@
import 'dart:io';
import '../command.dart';
import '../local_state.dart' show joinPath;
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim();
if (commonHelpArgs.contains(rawArgs.toLowerCase())) {
context.writeLine('Usage: /lint\n\nRun the project linter.');
return const CommandResult();
}
final pubspecFile = File(joinPath(context.workingDirectory, 'pubspec.yaml'));
final packageJsonFile = File(joinPath(context.workingDirectory, 'package.json'));
List<String> lintCmd;
String label;
if (await pubspecFile.exists()) {
lintCmd = ['dart', 'analyze'];
label = 'dart analyze';
} else if (await packageJsonFile.exists()) {
lintCmd = ['npm', 'run', 'lint'];
label = 'npm run lint';
} else {
context.writeLine('Could not detect project type. No pubspec.yaml or package.json found.');
context.writeLine('Run your linter manually.');
return const CommandResult(exitCode: 1);
}
context.writeLine('Running: $label');
context.writeLine('');
try {
final result = await Process.run(
lintCmd.first,
lintCmd.sublist(1),
workingDirectory: context.workingDirectory,
);
final out = (result.stdout as String).trim();
final err = (result.stderr as String).trim();
if (out.isNotEmpty) context.writeLine(out);
if (err.isNotEmpty) context.writeError(err);
if (result.exitCode == 0) {
context.writeLine('');
context.writeLine('No issues found.');
}
return CommandResult(exitCode: result.exitCode);
} on ProcessException catch (e) {
context.writeError('Could not run $label: ${e.message}');
return const CommandResult(exitCode: 1);
}
}
+9
View File
@@ -0,0 +1,9 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine('OpenRouter API key configuration has moved to settings.');
context.writeLine(
'Set your API key in the Settings panel to authenticate with OpenRouter.',
);
return const CommandResult();
}
+6
View File
@@ -0,0 +1,6 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine('To remove your OpenRouter API key, clear it in Settings.');
return const CommandResult();
}
+29
View File
@@ -0,0 +1,29 @@
import '../command.dart';
import '../daemon/daemon_manager.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
if (args.isEmpty) {
context.writeLine("Usage: /logs <session-id> [--tail N]");
return const CommandResult(exitCode: 1);
}
final id = args[0];
int? tail;
for (var i = 1; i < args.length - 1; i++) {
if (args[i] == "--tail" || args[i] == "-n") {
tail = int.tryParse(args[i + 1]);
}
}
final mgr = DaemonManager();
final contents = await mgr.readLogs(id, tail: tail);
if (contents == null) {
context.writeLine("No logs found for session: $id");
return const CommandResult(exitCode: 1);
}
context.writeLine(contents);
return const CommandResult(exitCode: 0);
}
+111
View File
@@ -0,0 +1,111 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim();
final parts = rawArgs.split(RegExp(r'\s+'));
final sub = parts.isNotEmpty ? parts.first.toLowerCase() : '';
if (sub == 'help' || rawArgs == '--help' || rawArgs == '-h') {
context.writeLine(
'Usage: /mcp [list|add <name> <command> [args...]|remove <name>|enable <name>|disable <name>]\n\n'
'Manage MCP (Model Context Protocol) servers.',
);
return const CommandResult();
}
final servers = Map<String, Map<String, dynamic>>.from(
context.settingsStore.settings.mcpServers ?? {},
);
if (sub.isEmpty || sub == 'list') {
context.writeLine('MCP servers');
context.writeLine('Settings file: ${context.settingsStore.path}');
context.writeLine('');
if (servers.isEmpty) {
context.writeLine('No MCP servers configured.');
context.writeLine('');
context.writeLine('Use /mcp add <name> <command> to add a server.');
} else {
for (final entry in servers.entries) {
final cfg = entry.value;
final cmd = cfg['command'] ?? '(no command)';
final disabled = cfg['disabled'] == true;
context.writeLine(' ${entry.key}: $cmd${disabled ? ' [disabled]' : ''}');
}
}
return const CommandResult();
}
if (sub == 'add') {
if (parts.length < 3) {
context.writeLine('Usage: /mcp add <name> <command> [args...]');
return const CommandResult(exitCode: 64);
}
final name = parts[1];
final command = parts[2];
final cmdArgs = parts.length > 3 ? parts.sublist(3) : <String>[];
servers[name] = <String, dynamic>{
'command': command,
if (cmdArgs.isNotEmpty) 'args': cmdArgs,
};
await context.settingsStore.update((s) => s.copyWith(mcpServers: servers));
context.writeLine('Added MCP server "$name" ($command)');
return const CommandResult();
}
if (sub == 'remove') {
if (parts.length < 2) {
context.writeLine('Usage: /mcp remove <name>');
return const CommandResult(exitCode: 64);
}
final name = parts[1];
if (!servers.containsKey(name)) {
context.writeLine('MCP server "$name" not found.');
return const CommandResult(exitCode: 1);
}
servers.remove(name);
await context.settingsStore.update(
(s) => s.copyWith(mcpServers: servers.isEmpty ? null : servers),
);
context.writeLine('Removed MCP server "$name"');
return const CommandResult();
}
if (sub == 'enable' || sub == 'disable') {
final isEnable = sub == 'enable';
final target = parts.length > 1 ? parts.sublist(1).join(' ') : 'all';
if (target == 'all') {
for (final name in servers.keys) {
servers[name] = Map<String, dynamic>.from(servers[name]!)..remove('disabled');
if (!isEnable) servers[name]!['disabled'] = true;
}
await context.settingsStore.update((s) => s.copyWith(mcpServers: servers));
context.writeLine(
'${isEnable ? 'Enabled' : 'Disabled'} ${servers.length} MCP server(s)',
);
return const CommandResult();
}
if (!servers.containsKey(target)) {
context.writeLine('MCP server "$target" not found.');
return const CommandResult(exitCode: 1);
}
servers[target] = Map<String, dynamic>.from(servers[target]!)..remove('disabled');
if (!isEnable) servers[target]!['disabled'] = true;
await context.settingsStore.update((s) => s.copyWith(mcpServers: servers));
context.writeLine('MCP server "$target" ${isEnable ? 'enabled' : 'disabled'}');
return const CommandResult();
}
context.writeLine('Unknown subcommand "$sub". Run /mcp help for usage.');
return const CommandResult(exitCode: 64);
}
+61
View File
@@ -0,0 +1,61 @@
import 'dart:io';
import '../command.dart';
import '../local_state.dart' show joinPath;
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final theAgencyHomeDir = theAgencyHome();
final globalMemoryPath = joinPath(theAgencyHomeDir, 'THE_AGENCY.md');
final localMemoryPath = joinPath(
joinPath(context.workingDirectory, '.the_agency'),
'THE_AGENCY.md',
);
final rawArgs = args.join(" ").trim().toLowerCase();
if (commonHelpArgs.contains(rawArgs)) {
context.writeLine(
'Usage: /memory [global|local]\n\n'
'Edit memory files.\n\n'
'Files:\n'
' global $globalMemoryPath\n'
' local $localMemoryPath\n\n'
'Without an argument, lists the available memory files.',
);
return const CommandResult();
}
if (rawArgs == 'global') {
context.writeLine("Global memory file: $globalMemoryPath");
final f = File(globalMemoryPath);
if (await f.exists()) {
final len = await f.length();
context.writeLine(" Size: $len bytes");
} else {
context.writeLine(" (does not exist yet)");
}
context.writeLine("\nTo edit, open the file in your editor:\n \$EDITOR $globalMemoryPath");
return const CommandResult();
}
if (rawArgs == 'local' || rawArgs.isEmpty) {
context.writeLine("Local memory file: $localMemoryPath");
final f = File(localMemoryPath);
if (await f.exists()) {
final len = await f.length();
context.writeLine(" Size: $len bytes");
} else {
context.writeLine(" (does not exist yet)");
}
context.writeLine("Global memory file: $globalMemoryPath");
context.writeLine(
"\nTo edit, open a file in your editor:\n \$EDITOR $localMemoryPath",
);
return const CommandResult();
}
context.writeLine("Usage: /memory [global|local]");
return const CommandResult(exitCode: 64);
}
+22
View File
@@ -0,0 +1,22 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
const iosUrl = 'https://apps.apple.com/app/claude-by-anthropic/id6473753684';
const androidUrl = 'https://play.google.com/store/apps/details?id=com.anthropic.claude';
final arg = args.join(' ').trim().toLowerCase();
final isAndroid = arg == 'android';
final url = isAndroid ? androidUrl : iosUrl;
final platform = isAndroid ? 'Android' : 'iOS';
context.writeLine('Download Claude on $platform');
context.writeLine('');
context.writeLine(' iOS: $iosUrl');
context.writeLine(' Android: $androidUrl');
context.writeLine('');
context.writeLine('QR code rendering is not ported to the Dart CLI runtime.');
context.writeLine('Open this link on your phone: $url');
return const CommandResult();
}
+66
View File
@@ -0,0 +1,66 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim();
if (commonHelpArgs.contains(rawArgs.toLowerCase())) {
context.writeLine('Usage: /model [default|current|status|<model>]');
context.writeLine('Known aliases: ${modelAliases.join(', ')}');
return const CommandResult();
}
if (rawArgs.isEmpty || commonInfoArgs.contains(rawArgs.toLowerCase())) {
final current = resolveCurrentModelSetting(context);
context.writeLine(
'Current model: ${renderModelSetting(current)}${context.settingsStore.settings.model == null ? ' (default)' : ''}',
);
if (context.settingsStore.settings.model != null) {
context.writeLine('Saved model override: ${context.settingsStore.settings.model}');
}
if (context.settingsStore.settings.fastMode) {
context.writeLine('Fast mode: ON');
}
return const CommandResult();
}
final normalized = rawArgs.toLowerCase();
if (normalized == 'default' || normalized == 'auto' || normalized == 'unset') {
await context.settingsStore.update((settings) => settings.copyWith(model: null));
context.writeLine(
'Set model to ${renderModelSetting(resolveCurrentModelSetting(context))}',
);
return const CommandResult();
}
final requestedModel = _normalizeModelInput(rawArgs);
var message = 'Set model to ${renderModelSetting(requestedModel)}';
final fastSupported = _supportsFastMode(requestedModel);
if (!fastSupported && context.settingsStore.settings.fastMode) {
await context.settingsStore.update(
(settings) => settings.copyWith(model: requestedModel, fastMode: false),
);
message += ' · Fast mode OFF';
context.writeLine(message);
return const CommandResult();
}
await context.settingsStore.update((settings) => settings.copyWith(model: requestedModel));
if (context.settingsStore.settings.fastMode) {
message += ' · Fast mode ON';
}
context.writeLine(message);
return const CommandResult();
}
String _normalizeModelInput(String rawModel) {
final trimmed = rawModel.trim();
final lowered = trimmed.toLowerCase();
if (modelAliases.contains(lowered)) return lowered;
return trimmed;
}
bool _supportsFastMode(String model) {
final normalized = model.toLowerCase();
return normalized.contains('opus') || normalized.contains('sonnet');
}
+8
View File
@@ -0,0 +1,8 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine(
'/output-style has been deprecated. Use /config to change your output style, or set it in your settings file. Changes take effect on the next session.',
);
return const CommandResult();
}
+235
View File
@@ -0,0 +1,235 @@
import '../command.dart';
import '../local_state.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
if (args.isEmpty) {
_writeSummary(context);
return const CommandResult();
}
final subcommand = args.first.toLowerCase();
if (commonHelpArgs.contains(subcommand)) {
context.writeLine(
'Usage: /permissions [show|mode <mode>|allow <rule>|deny <rule>|ask <rule>|remove <index|rule>|clear [allow|deny|ask|all]]',
);
context.writeLine('Modes: ${supportedPermissionModes.join(', ')}');
return const CommandResult();
}
if (subcommand == 'show' ||
subcommand == 'list' ||
commonInfoArgs.contains(subcommand)) {
_writeSummary(context);
return const CommandResult();
}
if (subcommand == 'mode') {
if (args.length == 1) {
context.writeLine(
'Current permission mode: ${context.settingsStore.settings.permissionMode}',
);
return const CommandResult();
}
final requestedMode = args[1];
if (!supportedPermissionModes.contains(requestedMode)) {
context.writeLine(
'Invalid permission mode "$requestedMode". Valid options: ${supportedPermissionModes.join(', ')}',
);
return const CommandResult(exitCode: 64);
}
await context.settingsStore.update(
(settings) => settings.copyWith(permissionMode: requestedMode),
);
context.writeLine('Permission mode set to $requestedMode');
return const CommandResult();
}
if (subcommand == 'allow' || subcommand == 'deny' || subcommand == 'ask') {
final rule = args.skip(1).join(' ').trim();
if (rule.isEmpty) {
context.writeLine('Please provide a permission rule.');
return const CommandResult(exitCode: 64);
}
await context.settingsStore.update(
(settings) => _applyRule(settings, subcommand, rule),
);
context.writeLine('Added $subcommand rule: $rule');
return const CommandResult();
}
if (subcommand == 'clear') {
final target = args.length > 1 ? args[1].toLowerCase() : 'all';
if (!<String>['all', 'allow', 'deny', 'ask'].contains(target)) {
context.writeLine('Usage: /permissions clear [allow|deny|ask|all]');
return const CommandResult(exitCode: 64);
}
await context.settingsStore.update((settings) => _clearRules(settings, target));
context.writeLine(
target == 'all' ? 'Cleared all permission rules' : 'Cleared $target rules',
);
return const CommandResult();
}
if (subcommand == 'remove') {
final target = args.skip(1).join(' ').trim();
if (target.isEmpty) {
context.writeLine('Usage: /permissions remove <index|rule>');
return const CommandResult(exitCode: 64);
}
final removal = _removeRule(context.settingsStore.settings, target);
if (!removal.removed) {
context.writeLine('No permission rule matched "$target".');
return const CommandResult(exitCode: 64);
}
await context.settingsStore.update((settings) => removal.settings);
context.writeLine('Removed permission rule: ${removal.label}');
return const CommandResult();
}
context.writeLine('Unknown /permissions subcommand "$subcommand".');
return const CommandResult(exitCode: 64);
}
void _writeSummary(CommandContext context) {
final settings = context.settingsStore.settings;
final flattened = _flatten(settings);
context.writeLine('Permission mode: ${settings.permissionMode}');
if (flattened.isEmpty) {
context.writeLine('No permission rules configured.');
return;
}
context.writeLine('Permission rules:');
for (var i = 0; i < flattened.length; i++) {
final entry = flattened[i];
context.writeLine(' ${i + 1}. ${entry.behavior}: ${entry.rule}');
}
}
LocalSettings _applyRule(LocalSettings settings, String behavior, String rule) {
final allowRules = settings.alwaysAllowRules.where((item) => item != rule).toList();
final denyRules = settings.alwaysDenyRules.where((item) => item != rule).toList();
final askRules = settings.alwaysAskRules.where((item) => item != rule).toList();
switch (behavior) {
case 'allow':
allowRules.add(rule);
break;
case 'deny':
denyRules.add(rule);
break;
case 'ask':
askRules.add(rule);
break;
}
return settings.copyWith(
alwaysAllowRules: allowRules,
alwaysAskRules: askRules,
alwaysDenyRules: denyRules,
);
}
LocalSettings _clearRules(LocalSettings settings, String target) {
switch (target) {
case 'allow':
return settings.copyWith(alwaysAllowRules: const []);
case 'deny':
return settings.copyWith(alwaysDenyRules: const []);
case 'ask':
return settings.copyWith(alwaysAskRules: const []);
case 'all':
return settings.copyWith(
alwaysAllowRules: const [],
alwaysAskRules: const [],
alwaysDenyRules: const [],
);
default:
return settings;
}
}
_RemovalResult _removeRule(LocalSettings settings, String target) {
final flattened = _flatten(settings);
final index = int.tryParse(target);
if (index != null) {
final entryIndex = index - 1;
if (entryIndex < 0 || entryIndex >= flattened.length) {
return _RemovalResult(removed: false, settings: settings, label: target);
}
final entry = flattened[entryIndex];
return _RemovalResult(
removed: true,
settings: _removeByLabel(settings, entry.behavior, entry.rule),
label: '${entry.behavior} ${entry.rule}',
);
}
for (final entry in flattened) {
if (entry.rule == target) {
return _RemovalResult(
removed: true,
settings: _removeByLabel(settings, entry.behavior, entry.rule),
label: '${entry.behavior} ${entry.rule}',
);
}
}
return _RemovalResult(removed: false, settings: settings, label: target);
}
LocalSettings _removeByLabel(LocalSettings settings, String behavior, String rule) {
switch (behavior) {
case 'allow':
return settings.copyWith(
alwaysAllowRules: settings.alwaysAllowRules
.where((item) => item != rule)
.toList(growable: false),
);
case 'deny':
return settings.copyWith(
alwaysDenyRules: settings.alwaysDenyRules
.where((item) => item != rule)
.toList(growable: false),
);
case 'ask':
return settings.copyWith(
alwaysAskRules: settings.alwaysAskRules
.where((item) => item != rule)
.toList(growable: false),
);
default:
return settings;
}
}
List<_PermissionEntry> _flatten(LocalSettings settings) => [
...settings.alwaysAllowRules.map((r) => _PermissionEntry(behavior: 'allow', rule: r)),
...settings.alwaysAskRules.map((r) => _PermissionEntry(behavior: 'ask', rule: r)),
...settings.alwaysDenyRules.map((r) => _PermissionEntry(behavior: 'deny', rule: r)),
];
int totalRuleCount(LocalSettings settings) =>
settings.alwaysAllowRules.length +
settings.alwaysAskRules.length +
settings.alwaysDenyRules.length;
class _PermissionEntry {
const _PermissionEntry({required this.behavior, required this.rule});
final String behavior;
final String rule;
}
class _RemovalResult {
const _RemovalResult({required this.label, required this.removed, required this.settings});
final String label;
final bool removed;
final LocalSettings settings;
}
+36
View File
@@ -0,0 +1,36 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim();
if (!context.sessionState.planModeEnabled) {
context.sessionState.planModeEnabled = true;
if (rawArgs.isNotEmpty && rawArgs != 'open') {
final existingPlan = await context.sessionState.readPlan();
if (existingPlan == null || existingPlan.trim().isEmpty) {
await context.sessionState.writePlan('# Plan\n\nGoal:\n- $rawArgs\n');
}
}
context.writeLine('Enabled plan mode');
return const CommandResult();
}
final planContent = await context.sessionState.readPlan();
final planPath = context.sessionState.planFilePath;
final argList = rawArgs.isEmpty ? const <String>[] : rawArgs.split(RegExp(r'\s+'));
if (argList.isNotEmpty && argList.first == 'open') {
context.writeLine('Plan file: $planPath');
return const CommandResult();
}
if (planContent == null || planContent.trim().isEmpty) {
context.writeLine('Already in plan mode. No plan written yet.');
return const CommandResult();
}
context.writeLine('Current Plan');
context.writeLine(planPath);
context.writeLine('');
context.writeLine(planContent);
return const CommandResult();
}
+72
View File
@@ -0,0 +1,72 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final subcmd = args.isEmpty ? "" : args.first.toLowerCase();
context.writeLine("Plugin Manager");
context.writeLine("");
switch (subcmd) {
case "help":
case "--help":
case "-h":
context.writeLine("Usage: /plugin [subcommand]");
context.writeLine("");
context.writeLine("Subcommands:");
context.writeLine(" install [plugin] Install a plugin");
context.writeLine(" uninstall [plugin] Uninstall a plugin");
context.writeLine(" enable [plugin] Enable a plugin");
context.writeLine(" disable [plugin] Disable a plugin");
context.writeLine(" validate [path] Validate a plugin");
context.writeLine(" marketplace Manage marketplaces");
context.writeLine(" manage Manage installed plugins");
break;
case "install":
case "i":
final target = args.length > 1 ? args.sublist(1).join(" ") : "";
if (target.isEmpty) {
context.writeLine("Usage: /plugin install <plugin-name>");
} else {
context.writeLine("Install target: $target");
context.writeLine("");
context.writeLine("Interactive plugin installation is not ported to the Dart CLI.");
}
break;
case "uninstall":
final target = args.length > 1 ? args[1] : "";
context.writeLine("Uninstall plugin: ${target.isEmpty ? "(interactive)" : target}");
context.writeLine("Interactive plugin management is not ported to the Dart CLI.");
break;
case "enable":
final target = args.length > 1 ? args[1] : "";
context.writeLine("Enable plugin: ${target.isEmpty ? "(interactive)" : target}");
context.writeLine("Interactive plugin management is not ported to the Dart CLI.");
break;
case "disable":
final target = args.length > 1 ? args[1] : "";
context.writeLine("Disable plugin: ${target.isEmpty ? "(interactive)" : target}");
context.writeLine("Interactive plugin management is not ported to the Dart CLI.");
break;
case "validate":
final path = args.length > 1 ? args.sublist(1).join(" ") : "";
context.writeLine("Validate plugin${path.isEmpty ? "" : " at: $path"}");
context.writeLine("Interactive plugin validation is not ported to the Dart CLI.");
break;
case "marketplace":
case "market":
context.writeLine("Marketplace management is not ported to the Dart CLI.");
break;
default:
context.writeLine("The interactive plugin browser is not ported to the Dart CLI runtime.");
context.writeLine("Run /plugin help to see available subcommands.");
}
return const CommandResult(exitCode: 2);
}
+28
View File
@@ -0,0 +1,28 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final prArg = args.join(' ').trim();
context.writeLine('PR Comments');
context.writeLine('');
if (prArg.isEmpty) {
context.writeLine('Usage: /pr-comments [pr-number]');
context.writeLine('');
context.writeLine('Fetches and displays comments from a GitHub pull request.');
context.writeLine('Requires the `gh` CLI to be installed and authenticated.');
} else {
context.writeLine(
'This is a prompt-type command. In the legacy CLI it sends a prompt to the model'
' asking it to fetch and format PR comments via the gh CLI.',
);
context.writeLine('PR: $prArg');
}
context.writeLine('');
context.writeLine(
'Hint: run `gh pr view $prArg` and `gh api /repos/.../pulls/$prArg/comments` manually.',
);
return const CommandResult(exitCode: 2);
}
+27
View File
@@ -0,0 +1,27 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim().toLowerCase();
if (commonHelpArgs.contains(rawArgs)) {
context.writeLine(
'Usage: /privacy-settings\n\n'
'View and update your privacy settings.\n'
'Controls things like telemetry and data retention.',
);
return const CommandResult();
}
final settings = context.settingsStore.settings;
context.writeLine('Privacy settings');
context.writeLine('');
context.writeLine(' telemetry: ${settings.telemetry ?? 'default (on)'}');
context.writeLine(' privacyLevel: ${settings.privacyLevel ?? 'standard'}');
context.writeLine('');
context.writeLine('To change, edit your settings file: ${context.settingsStore.path}');
context.writeLine('Or use /config to inspect the full settings object.');
return const CommandResult();
}
+27
View File
@@ -0,0 +1,27 @@
import '../command.dart';
import '../daemon/daemon_manager.dart';
import '../daemon/daemon_types.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final mgr = DaemonManager();
final sessions = await mgr.listSessions(refreshStatus: true);
if (sessions.isEmpty) {
context.writeLine("No background sessions found.");
return const CommandResult(exitCode: 0);
}
context.writeLine("Background Sessions:");
context.writeLine("");
for (final s in sessions) {
final alive = s.status == SessionStatus.running ? " (running)" : " (${s.status.name})";
final title = s.title != null ? " ${s.title}" : "";
context.writeLine(" ${s.id} pid=${s.pid}$alive$title");
context.writeLine(" dir: ${s.workingDirectory}");
context.writeLine(" started: ${s.startedAt}");
if (s.endedAt != null) context.writeLine(" ended: ${s.endedAt}");
}
return const CommandResult(exitCode: 0);
}
+15
View File
@@ -0,0 +1,15 @@
import '../build_info.dart';
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
const changelogUrl = 'https://github.com/anthropics/claude-code/releases';
context.writeLine('Release Notes');
context.writeLine('');
context.writeLine('Current version: ${BuildInfo.versionDisplay}');
context.writeLine('');
context.writeLine('Fetching the remote changelog is not wired up in the Dart CLI runtime yet.');
context.writeLine('See the full changelog at: $changelogUrl');
return const CommandResult();
}
+26
View File
@@ -0,0 +1,26 @@
import '../command.dart';
import '../session/session_store.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final newName = args.join(" ").trim();
if (newName.isEmpty || commonHelpArgs.contains(newName.toLowerCase())) {
context.writeLine(
'Usage: /rename <name>\n\n'
'Rename the current conversation session.\n'
'If no name is given in the legacy CLI, one is auto-generated from context.',
);
return const CommandResult();
}
context.sessionState.sessionName = newName;
if (history.hasSession) {
history.session!.name = newName;
await SessionStore.instance.saveSession(history.session!);
}
context.writeLine('Session renamed to: "$newName"');
return const CommandResult();
}
+52
View File
@@ -0,0 +1,52 @@
import '../command.dart';
import '../session/session_store.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final query = args.join(' ').trim();
final sessions = await SessionStore.instance.listSessionsForProject(context.workingDirectory);
if (sessions.isEmpty) {
context.writeLine("No saved sessions found.");
return const CommandResult();
}
final filtered = query.isEmpty
? sessions
: sessions.where((s) {
final lower = query.toLowerCase();
return s.name.toLowerCase().contains(lower) || s.id.startsWith(lower);
}).toList();
if (filtered.isEmpty) {
context.writeLine('No sessions matching "$query".');
return const CommandResult(exitCode: 1);
}
context.writeLine("Saved sessions (newest first):");
context.writeLine("");
for (int i = 0; i < filtered.length; i++) {
final s = filtered[i];
final ts = s.updated.toLocal().toString().substring(0, 16);
final costStr = s.cost != null ? " \$${s.cost!.toStringAsFixed(4)}" : "";
context.writeLine(" [${i + 1}] ${s.name}$costStr");
context.writeLine(" id=${s.id} msgs=${s.messageCount} updated=$ts");
}
context.writeLine("\nTo load a session, use: /resume <name or id>");
if (filtered.length == 1 && query.isNotEmpty) {
final loaded = await SessionStore.instance.loadSession(
filtered.first.id,
workingDirectory: context.workingDirectory,
);
if (loaded != null) {
history.setSession(loaded);
context.sessionState.sessionName = loaded.name;
context.writeLine('\nResumed session: "${loaded.name}"');
}
}
return const CommandResult();
}
+25
View File
@@ -0,0 +1,25 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final prArg = args.join(' ').trim();
context.writeLine('Review pull request');
context.writeLine('');
if (prArg.isEmpty) {
context.writeLine('Usage: /review [pr-number]');
context.writeLine('');
context.writeLine('No PR number given. In the legacy CLI this would run `gh pr list` first.');
} else {
context.writeLine('PR: $prArg');
context.writeLine('');
context.writeLine(
'This is a prompt-type command. In the legacy CLI it sends a review prompt to the model'
' with the gh pr diff output embedded.',
);
}
context.writeLine('');
context.writeLine('Hint: run `gh pr diff $prArg` to see the diff manually.');
return const CommandResult(exitCode: 2);
}
+13
View File
@@ -0,0 +1,13 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine("Rewind / Checkpoint");
context.writeLine("");
context.writeLine("Restore the code and conversation to a previous checkpoint.");
context.writeLine("");
context.writeLine("This command requires an active REPL session with checkpoint history.");
context.writeLine(
"The interactive checkpoint selector is not ported to the Dart CLI runtime.",
);
return const CommandResult(exitCode: 2);
}
+43
View File
@@ -0,0 +1,43 @@
import 'dart:io';
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine("Security review");
context.writeLine("");
context.writeLine(
"This is a prompt-type command. In the legacy CLI it sends the current\n"
"git diff to the AI model for a focused security analysis.",
);
context.writeLine("");
context.writeLine("Security categories examined:");
context.writeLine(" - Input validation (SQLi, CMDi, path traversal, etc.)");
context.writeLine(" - Authentication & authorization issues");
context.writeLine(" - Crypto & secrets management");
context.writeLine(" - Injection & code execution");
context.writeLine(" - Data exposure");
context.writeLine("");
try {
final diffResult = await Process.run(
"git", ["diff", "--stat", "origin/HEAD..."],
workingDirectory: context.workingDirectory,
);
final stat = (diffResult.stdout as String).trim();
if (stat.isNotEmpty) {
context.writeLine("Changes vs origin/HEAD:");
context.writeLine(stat);
} else {
context.writeLine("(no diff vs origin/HEAD detected)");
}
} on ProcessException {
context.writeLine("(could not run git diff)");
}
context.writeLine("");
context.writeLine(
"Run `git diff origin/HEAD...` to view the full diff, then review manually or use the legacy CLI.",
);
return const CommandResult(exitCode: 2);
}
+11
View File
@@ -0,0 +1,11 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine("Remote Session");
context.writeLine("");
context.writeLine("Remote session mode is not available in the Dart CLI port.");
context.writeLine(
"This command shows a QR code and URL when Claude Code is running in remote mode.",
);
return const CommandResult(exitCode: 2);
}
+15
View File
@@ -0,0 +1,15 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine("Skills");
context.writeLine("");
context.writeLine(
"Skills are reusable prompt templates that can be invoked as slash commands.",
);
context.writeLine("The interactive skills browser is not ported to the Dart CLI runtime.");
context.writeLine("");
context.writeLine(
"In the legacy CLI, skills are loaded from ~/.claude/skills/ or project .claude/skills/.",
);
return const CommandResult(exitCode: 2);
}
+31
View File
@@ -0,0 +1,31 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final stats = context.runtimeStateStore.state.stats;
final sortedCounts = stats.commandCounts.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
context.writeLine('CLI stats');
context.writeLine('Sessions started: ${stats.sessionsStarted}');
context.writeLine('Interactive sessions: ${stats.interactiveSessionsStarted}');
context.writeLine('Commands executed: ${stats.commandsExecuted}');
context.writeLine('Commands this session: ${context.sessionState.commandsExecuted}');
context.writeLine(
'Session duration: ${formatDuration(DateTime.now().toUtc().difference(context.sessionState.startedAt))}',
);
if (stats.lastCommandName != null) {
context.writeLine(
'Last command: ${stats.lastCommandName} (${stats.lastCommandAt ?? 'unknown'})',
);
}
if (sortedCounts.isNotEmpty) {
context.writeLine('');
context.writeLine('Top commands:');
for (final entry in sortedCounts.take(5)) {
context.writeLine(' ${entry.key}: ${entry.value}');
}
}
return const CommandResult();
}
+54
View File
@@ -0,0 +1,54 @@
import '../build_info.dart';
import '../command.dart';
import '../legacy_inventory.dart' show legacySourceFileCount;
import '../migration_assessment.dart';
import 'permissions.dart' as permissionsCmd;
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final unportedCommands = context.catalog.unportedSlashCommands;
final sample = unportedCommands.take(10).map((c) => c.name).join(', ');
final auth = context.runtimeStateStore.state.auth;
context.writeLine('Claude Code status');
context.writeLine('Version: ${BuildInfo.versionDisplay}');
context.writeLine('Working directory: ${context.workingDirectory}');
context.writeLine(
'Account: ${auth == null ? 'not logged in' : '${auth.email} (${auth.subscriptionType}${auth.rateLimitTier == null ? '' : ', ${auth.rateLimitTier}'})'}',
);
context.writeLine('Model: ${renderModelSetting(resolveCurrentModelSetting(context))}');
context.writeLine('Permission mode: ${context.settingsStore.settings.permissionMode}');
context.writeLine(
'Permission rules: ${permissionsCmd.totalRuleCount(context.settingsStore.settings)}',
);
context.writeLine(
'Fast mode: ${context.settingsStore.settings.fastMode ? 'on' : 'off'}',
);
context.writeLine(
'Effort: ${showCurrentEffort(context).replaceFirst('Current ', '').replaceFirst('Effort ', 'effort ')}',
);
context.writeLine(
'Statusline prompt: ${context.settingsStore.settings.statusLinePrompt ?? defaultStatuslinePrompt}',
);
context.writeLine('');
context.writeLine('Migration status');
context.writeLine('Legacy source root: old_repo/');
context.writeLine('Legacy source files: $legacySourceFileCount');
context.writeLine('Known slash commands: ${context.catalog.totalKnownSlashCommands}');
context.writeLine('Ported commands: ${context.catalog.portedCommands.length}');
context.writeLine(
'Reserved top-level entrypoints: ${context.catalog.totalReservedTopLevelEntryPoints}',
);
context.writeLine('Remaining slash commands: ${unportedCommands.length}');
if (sample.isNotEmpty) context.writeLine('Next unported commands: $sample');
context.writeLine(
'Largest legacy areas: ${legacySubsystemStats.take(5).map((stat) => '${stat.name} ${stat.fileCount}').join(', ')}',
);
context.writeLine('High-friction import matches: $legacyHotspotImportMatches');
context.writeLine('Primary blockers:');
for (final blocker in migrationBlockers.take(3)) {
context.writeLine(' - $blocker');
}
return const CommandResult();
}
+31
View File
@@ -0,0 +1,31 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim();
if (rawArgs.isEmpty || commonInfoArgs.contains(rawArgs.toLowerCase())) {
final prompt = context.settingsStore.settings.statusLinePrompt ?? defaultStatuslinePrompt;
context.writeLine('Status line prompt: $prompt');
context.writeLine(buildStatuslineAgentInstruction(prompt));
return const CommandResult();
}
if (commonHelpArgs.contains(rawArgs.toLowerCase())) {
context.writeLine('Usage: /statusline [show|clear|<prompt>]');
return const CommandResult();
}
if (rawArgs.toLowerCase() == 'clear') {
await context.settingsStore.update(
(settings) => settings.copyWith(statusLinePrompt: null),
);
context.writeLine('Cleared saved status line prompt.');
return const CommandResult();
}
await context.settingsStore.update(
(settings) => settings.copyWith(statusLinePrompt: rawArgs),
);
context.writeLine(buildStatuslineAgentInstruction(rawArgs));
return const CommandResult();
}
+32
View File
@@ -0,0 +1,32 @@
import 'dart:io';
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
const url = "https://www.stickermule.com/claudecode";
String? browserCmd;
if (Platform.isMacOS) {
browserCmd = "open";
} else if (Platform.isLinux) {
browserCmd = "xdg-open";
} else if (Platform.isWindows) {
browserCmd = "start";
}
bool opened = false;
if (browserCmd != null) {
try {
final result = await Process.run(browserCmd, [url]);
opened = result.exitCode == 0;
} catch (_) {}
}
if (opened) {
context.writeLine("Opening sticker page in browser...");
} else {
context.writeLine("Order Claude Code stickers at: $url");
}
return const CommandResult();
}
+42
View File
@@ -0,0 +1,42 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final tagArg = args.join(" ").trim();
if (tagArg.isEmpty || commonHelpArgs.contains(tagArg) || commonInfoArgs.contains(tagArg)) {
context.writeLine(
'Usage: /tag <tag-name>\n\n'
'Toggle a searchable tag on the current session.\n'
'Run the same command again to remove the tag.\n'
'Tags are displayed after the branch name in /resume and can be searched with /.\n\n'
'Examples:\n'
' /tag bugfix # Add tag\n'
' /tag bugfix # Remove tag (toggle)\n'
' /tag feature-auth\n'
' /tag wip',
);
return const CommandResult();
}
final normalizedTag = tagArg.replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '').trim();
if (normalizedTag.isEmpty) {
context.writeLine("Tag name cannot be empty");
return const CommandResult(exitCode: 64);
}
final currentTag = context.sessionState.sessionTag;
if (currentTag == normalizedTag) {
context.sessionState.sessionTag = null;
context.writeLine("Removed tag #$normalizedTag");
} else {
context.sessionState.sessionTag = normalizedTag;
if (currentTag != null) {
context.writeLine("Replaced tag #$currentTag with #$normalizedTag");
} else {
context.writeLine("Tagged session with #$normalizedTag");
}
}
return const CommandResult();
}
+9
View File
@@ -0,0 +1,9 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine("Background Tasks");
context.writeLine("");
context.writeLine("The interactive task manager is not ported to the Dart CLI runtime.");
context.writeLine("Background task tracking requires a running REPL session.");
return const CommandResult(exitCode: 2);
}
+52
View File
@@ -0,0 +1,52 @@
import 'dart:io';
import '../command.dart';
const _nativeCsiuTerminals = <String>[
'ghostty', 'kitty', 'iTerm.app', 'WezTerm', 'WarpTerminal',
];
Future<CommandResult> run(CommandContext context, List<String> args) async {
final term = Platform.environment['TERM_PROGRAM']
?? Platform.environment['TERMINAL_EMULATOR']
?? '';
if (_nativeCsiuTerminals.contains(term)) {
context.writeLine(
'Terminal-setup: your terminal ($term) natively supports the Kitty keyboard protocol.',
);
context.writeLine('No additional setup is needed for Shift+Enter / newlines.');
return const CommandResult();
}
context.writeLine('Terminal setup');
context.writeLine('');
if (Platform.isMacOS && term == 'Apple_Terminal') {
context.writeLine('Detected: Apple Terminal (macOS)');
context.writeLine('');
context.writeLine(
'To enable Option+Enter for newlines:\n'
' 1. Open Terminal > Settings > Profiles > Keyboard\n'
' 2. Add a key binding: Option+Return → sends \\033\\012\n'
' 3. Alternatively run the legacy CLI interactively: /terminal-setup',
);
} else if (term == 'vscode' || term == 'cursor' || term == 'windsurf') {
context.writeLine('Detected: $term terminal');
context.writeLine('');
context.writeLine(
'To enable Shift+Enter for newlines, add this to your $term keybindings.json:\n'
' { "key": "shift+enter", "command": "workbench.action.terminal.sendSequence",\n'
' "args": { "text": "\\\\n" }, "when": "terminalFocus" }',
);
} else {
context.writeLine('Detected terminal: ${term.isEmpty ? '(unknown)' : term}');
context.writeLine('');
context.writeLine(
'Interactive terminal setup (key binding installation) is not ported to the Dart runtime.',
);
context.writeLine('Run the legacy CLI to use the full interactive setup wizard.');
}
return const CommandResult();
}
+22
View File
@@ -0,0 +1,22 @@
import '../command.dart';
import '../local_state.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final rawArgs = args.join(' ').trim().toLowerCase();
if (rawArgs.isEmpty || rawArgs == 'current' || rawArgs == 'status') {
context.writeLine('Current theme: ${context.settingsStore.settings.theme}');
context.writeLine('Available themes: ${supportedThemeSettings.join(', ')}');
return const CommandResult();
}
if (!supportedThemeSettings.contains(rawArgs)) {
context.writeLine(
'Invalid theme "$rawArgs". Available themes: ${supportedThemeSettings.join(', ')}',
);
return const CommandResult(exitCode: 64);
}
await context.settingsStore.update((settings) => settings.copyWith(theme: rawArgs));
context.writeLine('Theme set to $rawArgs');
return const CommandResult();
}
+18
View File
@@ -0,0 +1,18 @@
import '../command.dart';
import '../tools/tool_registry.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final registry = ToolRegistry();
context.writeLine('Available tools:');
context.writeLine('');
for (final tool in registry.allTools) {
context.writeLine(' ${tool.name}');
context.writeLine(' ${tool.description}');
context.writeLine('');
}
context.writeLine('Usage: toolname: <args> (e.g. bash: echo hello)');
return const CommandResult();
}
+22
View File
@@ -0,0 +1,22 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final auth = context.runtimeStateStore.state.auth;
if (auth != null &&
auth.subscriptionType == 'max' &&
auth.rateLimitTier == max20xTier) {
context.writeLine(
'You are already on the highest Max subscription plan. For additional usage, run /login to switch to an API usage-billed account.',
);
return const CommandResult();
}
context.writeLine('Upgrade URL: https://claude.ai/upgrade/max');
if (auth != null) {
context.writeLine(
'After upgrading, refresh the local profile with /login ${auth.email} max $max20xTier',
);
}
return const CommandResult();
}
+22
View File
@@ -0,0 +1,22 @@
import '../command.dart';
import '_shared.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final auth = context.runtimeStateStore.state.auth;
context.writeLine('Plan usage');
if (auth == null) {
context.writeLine('Account: not logged in');
} else {
context.writeLine('Account: ${auth.email}');
context.writeLine('Subscription: ${auth.subscriptionType}');
context.writeLine('Rate limit tier: ${auth.rateLimitTier ?? 'not recorded'}');
context.writeLine('Logged in at: ${auth.loggedInAt}');
}
context.writeLine('Model: ${renderModelSetting(resolveCurrentModelSetting(context))}');
context.writeLine('Fast mode: ${context.settingsStore.settings.fastMode ? 'on' : 'off'}');
context.writeLine(showCurrentEffort(context));
context.writeLine(
'Remote quota sync is not available in the Dart CLI yet, so this view shows saved account metadata only.',
);
return const CommandResult();
}
+7
View File
@@ -0,0 +1,7 @@
import '../build_info.dart';
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine(BuildInfo.versionDisplay);
return const CommandResult();
}
+16
View File
@@ -0,0 +1,16 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
final currentMode = context.settingsStore.settings.editorMode == 'vim' ? 'vim' : 'normal';
final newMode = currentMode == 'normal' ? 'vim' : 'normal';
await context.settingsStore.update((settings) => settings.copyWith(editorMode: newMode));
if (newMode == 'vim') {
context.writeLine(
'Editor mode set to vim. Use Escape key to toggle between INSERT and NORMAL modes.',
);
} else {
context.writeLine('Editor mode set to normal. Using standard (readline) keyboard bindings.');
}
return const CommandResult();
}
+11
View File
@@ -0,0 +1,11 @@
import '../command.dart';
Future<CommandResult> run(CommandContext context, List<String> args) async {
context.writeLine("Voice Mode");
context.writeLine("");
context.writeLine("Voice mode requires a Claude.ai account and is only available");
context.writeLine("in the interactive REPL session, not the Dart CLI port.");
context.writeLine("");
context.writeLine("Sign in at https://claude.ai to access voice features.");
return const CommandResult(exitCode: 2);
}
+12 -5
View File
@@ -71,11 +71,12 @@ String joinPath(String base, String child) {
class LocalSettings {
const LocalSettings({
this.advisorModel,
this.advisorEffortLevel,
this.alwaysAllowRules = const <String>[],
this.alwaysAskRules = const <String>[],
this.alwaysDenyRules = const <String>[],
this.editorMode = 'normal',
this.effortLevel,
this.effortLevel = 'medium',
this.fastMode = false,
this.hooks,
this.mcpServers,
@@ -92,11 +93,12 @@ class LocalSettings {
factory LocalSettings.fromJson(Map<String, dynamic> json) {
return LocalSettings(
advisorModel: _readString(json, 'advisorModel'),
advisorEffortLevel: _readString(json, 'advisorEffortLevel'),
alwaysAllowRules: _readStringList(json, 'alwaysAllowRules'),
alwaysAskRules: _readStringList(json, 'alwaysAskRules'),
alwaysDenyRules: _readStringList(json, 'alwaysDenyRules'),
editorMode: _readString(json, 'editorMode') ?? 'normal',
effortLevel: _readString(json, 'effortLevel'),
effortLevel: _readString(json, 'effortLevel') ?? 'medium',
fastMode: _readBool(json, 'fastMode') ?? false,
hooks: _readStringMap(json, 'hooks'),
mcpServers: _readMcpServers(json),
@@ -113,11 +115,12 @@ class LocalSettings {
// advisor model name - optional
final String? advisorModel;
final String? advisorEffortLevel;
final List<String> alwaysAllowRules;
final List<String> alwaysAskRules;
final List<String> alwaysDenyRules;
final String editorMode;
final String? effortLevel;
final String effortLevel;
final bool fastMode;
// hook configs keyed by event name
@@ -136,6 +139,7 @@ class LocalSettings {
LocalSettings copyWith({
Object? advisorModel = _sentinel,
Object? advisorEffortLevel = _sentinel,
List<String>? alwaysAllowRules,
List<String>? alwaysAskRules,
List<String>? alwaysDenyRules,
@@ -155,13 +159,14 @@ class LocalSettings {
}) {
return LocalSettings(
advisorModel: identical(advisorModel, _sentinel) ? this.advisorModel : advisorModel as String?,
advisorEffortLevel: identical(advisorEffortLevel, _sentinel) ? this.advisorEffortLevel : advisorEffortLevel as String?,
alwaysAllowRules: alwaysAllowRules ?? this.alwaysAllowRules,
alwaysAskRules: alwaysAskRules ?? this.alwaysAskRules,
alwaysDenyRules: alwaysDenyRules ?? this.alwaysDenyRules,
editorMode: editorMode ?? this.editorMode,
effortLevel: identical(effortLevel, _sentinel)
? this.effortLevel
: effortLevel as String?,
: (effortLevel as String?) ?? 'medium',
fastMode: fastMode ?? this.fastMode,
hooks: identical(hooks, _sentinel) ? this.hooks : hooks as Map<String, String>?,
mcpServers: identical(mcpServers, _sentinel) ? this.mcpServers : mcpServers as Map<String, Map<String, dynamic>>?,
@@ -189,11 +194,12 @@ class LocalSettings {
return LocalSettings(
advisorModel: override.advisorModel ?? advisorModel,
advisorEffortLevel: override.advisorEffortLevel ?? advisorEffortLevel,
alwaysAllowRules: override.alwaysAllowRules.isNotEmpty ? override.alwaysAllowRules : alwaysAllowRules,
alwaysAskRules: override.alwaysAskRules.isNotEmpty ? override.alwaysAskRules : alwaysAskRules,
alwaysDenyRules: override.alwaysDenyRules.isNotEmpty ? override.alwaysDenyRules : alwaysDenyRules,
editorMode: override.editorMode != 'normal' ? override.editorMode : editorMode,
effortLevel: override.effortLevel ?? effortLevel,
effortLevel: override.effortLevel != 'medium' ? override.effortLevel : effortLevel,
fastMode: override.fastMode ? true : fastMode,
hooks: override.hooks ?? hooks,
mcpServers: override.mcpServers ?? mcpServers,
@@ -211,6 +217,7 @@ class LocalSettings {
Map<String, dynamic> toJson() {
return <String, dynamic>{
'advisorModel': advisorModel,
'advisorEffortLevel': advisorEffortLevel,
'alwaysAllowRules': alwaysAllowRules,
'alwaysAskRules': alwaysAskRules,
'alwaysDenyRules': alwaysDenyRules,
+18 -4
View File
@@ -21,10 +21,10 @@ class ConversationHistory {
_session = s;
}
void addMessage(String role, String content, {int? tokens, int? contextTokens, List<MessageAttachment>? attachments}) {
void addMessage(String role, String content, {int? tokens, int? contextTokens, List<MessageAttachment>? attachments, double? cost}) {
if (_session == null) return;
final msg = Message(role: role, content: content, tokens: tokens, contextTokens: contextTokens, attachments: attachments);
final msg = Message(role: role, content: content, tokens: tokens, contextTokens: contextTokens, attachments: attachments, cost: cost);
_session!.messages.add(msg);
_session!.updated = DateTime.now().toUtc();
@@ -46,7 +46,7 @@ class ConversationHistory {
_session!.updated = DateTime.now().toUtc();
}
void setLastMessageContextTokens(int contextTokens) {
void setLastMessageContextTokens(int? contextTokens) {
if (_session == null || _session!.messages.isEmpty) return;
final last = _session!.messages.last;
_session!.messages[_session!.messages.length - 1] = Message(
@@ -54,7 +54,21 @@ class ConversationHistory {
content: last.content,
timestamp: last.timestamp,
tokens: last.tokens,
contextTokens: contextTokens,
contextTokens: contextTokens ?? last.contextTokens,
cost: last.cost,
);
}
void setLastMessageCost(double? cost) {
if (_session == null || _session!.messages.isEmpty) return;
final last = _session!.messages.last;
_session!.messages[_session!.messages.length - 1] = Message(
role: last.role,
content: last.content,
timestamp: last.timestamp,
tokens: last.tokens,
contextTokens: last.contextTokens,
cost: cost,
);
}
+202 -28
View File
@@ -3,6 +3,7 @@ import "dart:convert";
import "package:flutter/foundation.dart";
import "../api/openrouter_client.dart";
import "../api/response_parser.dart";
import "../compact/compact_service.dart";
import "../hooks/hook_runner.dart";
import "../hooks/hook_types.dart";
@@ -32,11 +33,19 @@ class SessionRuntime {
required LocalSettings Function() getSettings,
required String Function(String?) normalizeModelId,
required VoidCallback onChanged,
void Function(double costDelta)? onCostAdded,
bool Function()? isActive,
void Function(String sessionId, String name)? onNameGenerated,
Future<void> Function(String rule)? onPersistAllowRule,
}) : _toolLoopService = toolLoopService,
_hookRunner = hookRunner,
_getSettings = getSettings,
_normalizeModelId = normalizeModelId,
_onChanged = onChanged {
_onChanged = onChanged,
_onCostAdded = onCostAdded,
_isActive = isActive,
_onNameGenerated = onNameGenerated,
_onPersistAllowRule = onPersistAllowRule {
_conversationHistory = ConversationHistory(session: session);
_apiMessages = _buildApiMessages(session.messages);
// restore persisted per-thread mode override
@@ -46,8 +55,14 @@ class SessionRuntime {
final ToolLoopService _toolLoopService;
final HookRunner? _hookRunner;
final VoidCallback _onChanged;
final void Function(double costDelta)? _onCostAdded;
final LocalSettings Function() _getSettings;
final String Function(String?) _normalizeModelId;
final bool Function()? _isActive;
final void Function(String sessionId, String name)? _onNameGenerated;
final Future<void> Function(String rule)? _onPersistAllowRule;
bool _nameGenerated = false;
late final ConversationHistory _conversationHistory;
late List<Map<String, dynamic>> _apiMessages;
@@ -79,6 +94,10 @@ class SessionRuntime {
// set when a turn finishes while the user is viewing a different thread
bool _hasUnreadResult = false;
// true while a streaming tool is actively pushing chunks — prevents onToolResult
// from double-adding the content that was already appended chunk by chunk
bool _streamingToolOutput = false;
// compact state
String? _lastCompactSummary;
bool _suppressCompactWarning = false;
@@ -102,6 +121,11 @@ class SessionRuntime {
PendingPermission? get pendingPermission => _pendingPermission;
bool get hasUnreadResult => _hasUnreadResult;
void setUnreadResult(bool value) {
_hasUnreadResult = value;
_onChanged();
}
void markRead() {
if (!_hasUnreadResult) return;
_hasUnreadResult = false;
@@ -204,6 +228,9 @@ class SessionRuntime {
bool hasStreamingAssistantMessage = false;
_client = await OpenRouterClientFactory.create(apiKey: apiKey);
// detect first turn before the user message is added
final bool isFirstTurn = _apiMessages.isEmpty && !_nameGenerated;
final session = _conversationHistory.session;
if (session != null) {
session.model = model;
@@ -282,13 +309,28 @@ class SessionRuntime {
);
_onChanged();
},
onToolResult: (toolName, result) {
_conversationHistory.addMessage(
"tool",
_formatToolResult(toolName, result),
);
onToolOutputChunk: (toolName, chunk) {
// append live chunk to the last tool message (which onToolCall just added)
_streamingToolOutput = true;
_conversationHistory.appendToLastMessage(chunk);
_onChanged();
},
onToolResult: (toolName, result) {
if (_streamingToolOutput) {
// content already in the message from live chunks — dont double-add
_streamingToolOutput = false;
} else {
_conversationHistory.addMessage(
"tool",
_formatToolResult(toolName, result),
);
}
_onChanged();
// save after each tool result so progress isnt lost if app dies mid-turn
final s = _conversationHistory.session;
if (s != null) SessionStore.instance.saveSession(s);
},
onAssistantTextDelta: (delta) {
if (!hasStreamingAssistantMessage) {
_conversationHistory.addMessage("assistant", "");
@@ -300,6 +342,10 @@ class SessionRuntime {
onAssistantMessageComplete: () {
hasStreamingAssistantMessage = false;
_onChanged();
// save after each complete assistant message (streaming done)
final s = _conversationHistory.session;
if (s != null) SessionStore.instance.saveSession(s);
},
onPermissionRequired: (toolName, input, {String? suggestionRule}) async {
final pending = PendingPermission(
@@ -325,27 +371,58 @@ class SessionRuntime {
final ct = toolLoopResult.response.contextTokens;
final rawUsage = toolLoopResult.response.usage;
final responseCost = (rawUsage?["cost"] as num?)?.toDouble() ?? 0.0;
double advisorCostTotal = 0;
for (final au in toolLoopResult.advisorUsages) {
cost_tracker.addToTotalSessionCost(
cost: au.costUsd,
inputTokens: au.inputTokens,
outputTokens: au.outputTokens,
cacheReadTokens: 0,
cacheCreationTokens: 0,
model: au.model,
);
advisorCostTotal += au.costUsd;
}
final totalCostThisTurn = responseCost + advisorCostTotal;
cost_tracker.addToTotalSessionCost(
cost: responseCost,
inputTokens: toolLoopResult.response.inputTokens ?? 0,
outputTokens: toolLoopResult.response.outputTokens ?? 0,
cacheReadTokens: toolLoopResult.response.cacheReadInputTokens ?? 0,
cacheCreationTokens: toolLoopResult.response.cacheCreationInputTokens ?? 0,
webSearchRequests: toolLoopResult.webSearchRequests,
webFetchRequests: toolLoopResult.webFetchRequests,
model: toolLoopResult.response.model,
);
if (!toolLoopResult.finalResponseWasStreamed) {
_conversationHistory.addMessage(
"assistant",
toolLoopResult.responseText,
tokens: toolLoopResult.response.outputTokens,
contextTokens: ct,
cost: totalCostThisTurn > 0 ? totalCostThisTurn : null,
);
} else {
_conversationHistory.setLastMessageContextTokens(ct);
_conversationHistory.setLastMessageCost(
totalCostThisTurn > 0 ? totalCostThisTurn : null,
);
}
cost_tracker.addToTotalSessionCost(
cost: 0.0,
inputTokens: toolLoopResult.response.inputTokens ?? 0,
outputTokens: toolLoopResult.response.outputTokens ?? 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
webSearchRequests: toolLoopResult.webSearchRequests,
webFetchRequests: toolLoopResult.webFetchRequests,
model: toolLoopResult.response.model,
);
if (totalCostThisTurn > 0) _onCostAdded?.call(totalCostThisTurn);
_onChanged();
// generate an AI name for the thread after the first turn
if (isFirstTurn && session != null && _onNameGenerated != null) {
_nameGenerated = true;
_generateThreadName(session, text, apiKey, model);
}
// auto-compact
if (ct > 0) {
@@ -405,7 +482,9 @@ class SessionRuntime {
_client = null;
_stopRequested = false;
_isLoading = false;
_hasUnreadResult = true;
if (_conversationHistory.session?.id != null && !(_isActive?.call() ?? false)) {
_hasUnreadResult = true;
}
_onChanged();
}
@@ -485,30 +564,58 @@ class SessionRuntime {
_suppressCompactWarning = true;
_consecutiveCompactFailures = 0;
// store a boundary marker so that on session restore we know where to
// cut the history for the api call. content = the summary string the
// model will see; full message history before this marker is kept for
// the user to scroll back through.
_conversationHistory.addMessage("compact_boundary", result.messages.first["content"] as String);
_conversationHistory.addMessage(
"assistant",
"✦ Conversation compacted (${result.preCompactMessageCount} messages → summary). "
"Context has been reset.",
);
final session = _conversationHistory.session;
if (session != null) {
await SessionStore.instance.saveSession(session);
}
// re-name the thread using the compact summary
if (session != null && _onNameGenerated != null) {
final settings = _getSettings();
final apiKey = settings.openRouterApiKey;
final model = _normalizeModelId(settings.model);
if (apiKey != null && apiKey.isNotEmpty) {
_generateThreadName(session, result.summaryText, apiKey, model);
}
}
_onChanged();
}
// ─── permission ─────────────────────────────────────────────────────────────
Future<void> resolvePermission(PermissionDecision decision) async {
Future<void> resolvePermission(PermissionDecision decision, {String? persistRule}) async {
final pending = _pendingPermission;
if (pending == null) return;
if (decision == PermissionDecision.allowAlways) {
final session = _conversationHistory.session;
if (session != null) {
final rule = _buildRuleString(pending.toolName, pending.input);
if (!session.alwaysAllowRules.contains(rule)) {
session.alwaysAllowRules.add(rule);
await SessionStore.instance.saveSession(session);
if (persistRule != null && _onPersistAllowRule != null) {
// persist to localSettings — survives session switches
await _onPersistAllowRule!(persistRule);
} else {
// session-scoped only (file tools)
final session = _conversationHistory.session;
if (session != null) {
final rule = pending.suggestionRule ?? _buildRuleString(pending.toolName, pending.input);
if (!session.alwaysAllowRules.contains(rule)) {
session.alwaysAllowRules.add(rule);
await SessionStore.instance.saveSession(session);
}
}
}
}
pending.resolve(decision);
@@ -526,10 +633,77 @@ class SessionRuntime {
// ─── helpers ────────────────────────────────────────────────────────────────
List<Map<String, dynamic>> _buildApiMessages(List<Message> messages) {
return messages
.where((m) => m.role == "user" || m.role == "assistant")
.map((m) => <String, dynamic>{"role": m.role, "content": m.content})
.toList(growable: true);
// find the last compact boundary (if any) — everything before it belongs
// to the old pre-compact history that the model shouldnt see again.
// the boundary's content is the summary string we send as the first user msg.
int lastBoundary = -1;
for (var i = messages.length - 1; i >= 0; i--) {
if (messages[i].role == "compact_boundary") {
lastBoundary = i;
break;
}
}
if (lastBoundary == -1) {
// no compaction yet — send everything
return messages
.where((m) => m.role == "user" || m.role == "assistant")
.map((m) => <String, dynamic>{"role": m.role, "content": m.content})
.toList(growable: true);
}
// start with the summary as a user message, then all user/assistant
// messages that came after the boundary
final result = <Map<String, dynamic>>[
{"role": "user", "content": messages[lastBoundary].content},
];
for (var i = lastBoundary + 1; i < messages.length; i++) {
final m = messages[i];
if (m.role == "user" || m.role == "assistant") {
result.add({"role": m.role, "content": m.content});
}
}
return result;
}
// fires async — does not block the caller. context is a short snippet
// (first user msg or compact summary) — NOT the full conversation history.
void _generateThreadName(ConversationSession session, String context, String apiKey, String model) {
() async {
try {
final client = await OpenRouterClientFactory.create(apiKey: apiKey);
try {
final snippet = context.length > 600 ? "${context.substring(0, 600)}..." : context;
final resp = await client.createMessage(
model: model,
maxTokens: 20,
messages: [
{"role": "user", "content": snippet},
],
system: "Generate a very short title (3-6 words) for this conversation. Reply with ONLY the title text — no quotes, no period at the end, nothing else.",
temperature: 0.3,
);
final name = ResponseParser.extractTextContent(resp)
.replaceAll(RegExp(r'["\n\r`]'), "")
.trim();
if (name.isEmpty || name.length > 80) return;
session.name = name;
await SessionStore.instance.saveSession(session);
_onNameGenerated?.call(session.id, name);
_onChanged();
} finally {
client.close();
}
} catch (e) {
print("[thread name] generation failed: $e");
}
}();
}
String _buildSessionName(String text) {
+19 -8
View File
@@ -1,5 +1,8 @@
// message roles - same as what the API uses
const validRoles = <String>["user", "assistant", "system", "tool"];
// "compact_boundary" is our internal marker - never sent to the API directly.
// Its content is the API summary string; _buildApiMessages uses it to
// reconstruct the right context window on session restore.
const validRoles = <String>["user", "assistant", "system", "tool", "compact_boundary"];
class MessageAttachment {
final String name;
@@ -25,6 +28,7 @@ class Message {
this.tokens,
this.contextTokens,
this.attachments,
this.cost,
}) : timestamp = timestamp ?? DateTime.now().toUtc();
factory Message.fromJson(Map<String, dynamic> json) {
@@ -36,6 +40,7 @@ class Message {
: null,
tokens: json["tokens"] as int?,
contextTokens: json["contextTokens"] as int?,
cost: (json["cost"] as num?)?.toDouble(),
);
}
@@ -53,6 +58,9 @@ class Message {
// display-only attachments — not serialized, only live in memory for current session
final List<MessageAttachment>? attachments;
// cost in USD for this turn (main model + any advisor calls), null for non-assistant messages
final double? cost;
Map<String, dynamic> toJson() {
return <String, dynamic>{
"role": role,
@@ -60,6 +68,7 @@ class Message {
"timestamp": timestamp.toIso8601String(),
if (tokens != null) "tokens": tokens,
if (contextTokens != null) "contextTokens": contextTokens,
if (cost != null) "cost": cost,
};
}
@@ -74,7 +83,6 @@ class ConversationSession {
required this.created,
required this.updated,
List<Message>? messages,
this.cost,
this.model,
this.workingDirectory,
List<String>? alwaysAllowRules,
@@ -103,7 +111,6 @@ class ConversationSession {
DateTime.tryParse(json["updated"] as String? ?? "") ??
DateTime.now().toUtc(),
messages: msgs,
cost: (json["cost"] as num?)?.toDouble(),
model: json["model"] as String?,
workingDirectory: json["workingDirectory"] as String?,
alwaysAllowRules: (json["alwaysAllowRules"] as List<dynamic>?)
@@ -119,8 +126,6 @@ class ConversationSession {
DateTime updated;
final List<Message> messages;
// total cost in USD - optional
double? cost;
String? model;
String? workingDirectory;
List<String> alwaysAllowRules;
@@ -128,6 +133,15 @@ class ConversationSession {
int get messageCount => messages.length;
// total cost derived from per-message cost fields — the ledger sum
double get cost {
double total = 0;
for (final m in messages) {
total += m.cost ?? 0;
}
return total;
}
// rough token total from tracked messages
int get totalTokens {
int t = 0;
@@ -141,7 +155,6 @@ class ConversationSession {
String? name,
DateTime? updated,
List<Message>? messages,
double? cost,
String? model,
Object? workingDirectory = _sessionSentinel,
}) {
@@ -151,7 +164,6 @@ class ConversationSession {
created: created,
updated: updated ?? this.updated,
messages: messages ?? this.messages,
cost: cost ?? this.cost,
model: model ?? this.model,
workingDirectory: identical(workingDirectory, _sessionSentinel)
? this.workingDirectory
@@ -166,7 +178,6 @@ class ConversationSession {
"created": created.toIso8601String(),
"updated": updated.toIso8601String(),
"messages": messages.map((m) => m.toJson()).toList(),
if (cost != null) "cost": cost,
if (model != null) "model": model,
if (workingDirectory != null) "workingDirectory": workingDirectory,
if (alwaysAllowRules.isNotEmpty) "alwaysAllowRules": alwaysAllowRules,
+4 -5
View File
@@ -187,18 +187,17 @@ Review the user's memory landscape and produce a clear report of proposed change
## Steps
### 1. Gather all memory layers
Read CLAUDE.md and CLAUDE.local.md from the project root (if they exist). Your auto-memory content is already in your system prompt — review it there.
Read `.the_agency/THE_AGENCY.md` from the project root (if it exists). Your auto-memory content is already in your system prompt — review it there.
### 2. Classify each auto-memory entry
| Destination | What belongs there |
|---|---|
| **CLAUDE.md** | Project conventions for all contributors |
| **CLAUDE.local.md** | Personal instructions for this user only |
| **THE_AGENCY.md** | Project conventions for all contributors |
| **Stay in auto-memory** | Working notes, temporary context |
### 3. Identify cleanup opportunities
- **Duplicates**: entries already in CLAUDE.md or CLAUDE.local.md
- **Duplicates**: entries already in THE_AGENCY.md
- **Outdated**: entries contradicted by newer entries
- **Conflicts**: contradictions between any two layers
@@ -396,7 +395,7 @@ void registerBundledSkills() {
reg.register(const Skill(
name: "remember",
description: "Review auto-memory entries and propose promotions to CLAUDE.md, CLAUDE.local.md, or shared memory. Also detects outdated, conflicting, and duplicate entries across memory layers.",
description: "Review auto-memory entries and propose promotions to .the_agency/THE_AGENCY.md or shared memory. Also detects outdated, conflicting, and duplicate entries across memory layers.",
source: SkillSource.bundled,
promptTemplate: _rememberPrompt,
whenToUse: "Use when the user wants to review, organize, or promote their auto-memory entries.",
+10 -51
View File
@@ -81,6 +81,11 @@ String _getClaudeConfigHomeDir() {
return p.join(home, ".claude");
}
String _getTheAgencyConfigHomeDir() {
final home = Platform.environment["HOME"] ?? Platform.environment["USERPROFILE"] ?? "";
return p.join(home, ".the_agency");
}
// ──────────────────────────────────────────────────────────────────────────────
// HTML comment stripping
// ──────────────────────────────────────────────────────────────────────────────
@@ -540,8 +545,8 @@ Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
));
// 2. User memory
final userConfigDir = _getClaudeConfigHomeDir();
final userMd = p.join(userConfigDir, "CLAUDE.md");
final userConfigDir = _getTheAgencyConfigHomeDir();
final userMd = p.join(userConfigDir, "THE_AGENCY.md");
result.addAll(await processMemoryFile(
userMd,
@@ -551,14 +556,6 @@ Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
originalCwd: originalCwd,
));
result.addAll(await processMdRules(
p.join(userConfigDir, "rules"),
MemoryType.user,
processedPaths,
includeExternal: true,
originalCwd: originalCwd,
));
if (originalCwd != null) {
// 3 & 4. Project + Local memory — walk up from cwd to root
final dirs = _buildAncestorChain(originalCwd);
@@ -578,38 +575,14 @@ Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
!_pathIsUnder(dir, gitRoot!);
if (!skipProject) {
// CLAUDE.md
// .the_agency/THE_AGENCY.md
result.addAll(await processMemoryFile(
p.join(dir, "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
// .claude/CLAUDE.md
result.addAll(await processMemoryFile(
p.join(dir, ".claude", "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
// .claude/rules/*.md
result.addAll(await processMdRules(
p.join(dir, ".claude", "rules"),
p.join(dir, ".the_agency", "THE_AGENCY.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
}
// CLAUDE.local.md (not skipped even in nested worktrees)
result.addAll(await processMemoryFile(
p.join(dir, "CLAUDE.local.md"),
MemoryType.local,
processedPaths,
originalCwd: originalCwd,
));
}
// env var for additional directories
@@ -621,21 +594,7 @@ Future<List<MemoryFileInfo>> getMemoryFiles(String? workingDirectory) async {
for (final dir in additionalDirs) {
result.addAll(await processMemoryFile(
p.join(dir, "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
result.addAll(await processMemoryFile(
p.join(dir, ".claude", "CLAUDE.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
));
result.addAll(await processMdRules(
p.join(dir, ".claude", "rules"),
p.join(dir, ".the_agency", "THE_AGENCY.md"),
MemoryType.project,
processedPaths,
originalCwd: originalCwd,
@@ -129,8 +129,18 @@ String _getIntroSection() {
// PARITY GAP: Claude Code does not have this instruction. Added because the model
// investigates correctly but then stalls — recapping findings and asking if it should
// proceed with what the user already asked. Investigation is fine; re-asking is not.
// Also covers the pattern of applying a shallow fix and offering the real fix as optional.
"When the user gives you an instruction, execute it. Investigate and use tools as needed, "
"but do not stop after investigating to ask if you should proceed — just proceed.";
"but do not stop after investigating to ask if you should proceed — just proceed. "
"All user messages are in the context of the working directory. If you lack context to "
"answer or act, look for it in the project first — never say you don't know when the "
"answer is findable. Only ask the user if it genuinely cannot be found. "
"Always fix the root cause, not the nearest symptom. Don't patch over a problem in one place "
"when the source is somewhere else. When a task involves a bug or unexpected behaviour, "
"trace the call chain to where the value originates — don't stop at the first file that "
"mentions it. "
"Do not end responses with offers like \"If you want, I can also...\" — if something is the "
"obvious next step, do it; if it isn't, leave it unsaid.";
}
@@ -180,6 +190,11 @@ String _getDoingTasksSection() {
"(user input, external APIs). Don't use feature flags or backwards-compatibility "
"shims when you can just change the code.",
"Never silently swallow errors. If you catch an exception, always print or log it to "
"the console — even if you also show a UI error dialog. A bare catch block that "
"discards the error makes debugging impossible. The only exception is when the user "
"explicitly asks for silent suppression.",
"Don't create helpers, utilities, or abstractions for one-time operations. Don't design "
"for hypothetical future requirements. The right amount of complexity is what the task "
"actually requires—no speculative abstractions, but no half-finished implementations "
+41 -8
View File
@@ -2,21 +2,34 @@ import "dart:async";
import "dart:io";
import "dart:convert";
import "../api/openrouter_client.dart";
import "base_tool.dart";
import "streaming_tool.dart";
// default timeouts (ms)
const int _defaultTimeoutMs = 120000;
const int _maxTimeoutMs = 600000;
class BashTool extends BaseTool {
class BashTool extends BaseTool with StreamingTool {
@override
final String name = "Bash";
@override
final String description = "Execute a bash command and return its output.";
// set by tool_loop_service before each execution so we can abort mid-run
bool Function()? shouldStop;
@override
Future<String> execute(Map<String, dynamic> input) async {
return executeStreaming(input, onChunk: (_) {});
}
@override
Future<String> executeStreaming(
Map<String, dynamic> input, {
required void Function(String chunk) onChunk,
}) async {
final command = requireString(input, "command");
final timeoutMs = optionalInt(input, "timeout") ?? _defaultTimeoutMs;
final workingDirectory = optionalString(input, "cwd");
@@ -25,21 +38,21 @@ class BashTool extends BaseTool {
throw ArgumentError("command must not be empty");
}
// clamp timeout to max
final effectiveTimeout = timeoutMs.clamp(1, _maxTimeoutMs);
final result = await _runCommand(
return _runCommand(
command,
Duration(milliseconds: effectiveTimeout),
workingDirectory: workingDirectory,
onChunk: onChunk,
);
return result;
}
Future<String> _runCommand(
String command,
Duration timeout, {
String? workingDirectory,
required void Function(String chunk) onChunk,
}) async {
Process proc;
@@ -59,11 +72,30 @@ class BashTool extends BaseTool {
final stdoutDone = proc.stdout
.transform(utf8.decoder)
.listen((chunk) => stdoutBuf.write(chunk));
.listen((chunk) {
stdoutBuf.write(chunk);
onChunk(chunk);
});
final stderrDone = proc.stderr
.transform(utf8.decoder)
.listen((chunk) => stderrBuf.write(chunk));
.listen((chunk) {
stderrBuf.write(chunk);
onChunk(chunk);
});
// poll for stop signal every 200ms and kill the process if requested
Timer? stopPoller;
var stopped = false;
if (shouldStop != null) {
stopPoller = Timer.periodic(const Duration(milliseconds: 200), (_) {
if (shouldStop != null && shouldStop!()) {
stopped = true;
proc.kill(ProcessSignal.sigterm);
stopPoller?.cancel();
}
});
}
int exitCode;
try {
@@ -78,20 +110,21 @@ class BashTool extends BaseTool {
},
);
} finally {
stopPoller?.cancel();
await stdoutDone.cancel();
await stderrDone.cancel();
}
if (stopped) throw RequestCancelledException();
final stdout = stdoutBuf.toString();
final stderr = stderrBuf.toString();
if (exitCode != 0) {
final errPart = stderr.isNotEmpty ? "\n$stderr" : "";
// match original behaviour - include exit code and stderr
return "${stdout}${errPart}\nExit code: $exitCode";
}
// combine stdout + stderr like the original tool
final combined = StringBuffer();
combined.write(stdout);
if (stderr.isNotEmpty) combined.write(stderr);
+12 -1
View File
@@ -2,9 +2,10 @@ import 'dart:io';
import '../services/task_executor.dart';
import 'base_tool.dart';
import 'streaming_tool.dart';
/// Tool for executing background tasks with real process management
class ExecuteTaskTool extends BaseTool {
class ExecuteTaskTool extends BaseTool with StreamingTool {
@override
final String name = 'ExecuteTask';
@@ -16,6 +17,14 @@ class ExecuteTaskTool extends BaseTool {
@override
Future<String> execute(Map<String, dynamic> input) async {
return executeStreaming(input, onChunk: (_) {});
}
@override
Future<String> executeStreaming(
Map<String, dynamic> input, {
required void Function(String chunk) onChunk,
}) async {
final action = input['action'] as String? ?? 'execute';
final taskId = input['task_id'] as String?;
final command = input['command'] as String?;
@@ -29,6 +38,8 @@ class ExecuteTaskTool extends BaseTool {
if (taskId == null || command == null) {
return 'Error: task_id and command are required for execute action';
}
// onChunk not used here - ExecuteTask spawns a background process
// and returns immediately; theres no live output to stream
return await _executeTask(
taskId: taskId,
command: command,
+496 -29
View File
@@ -1,60 +1,218 @@
import "dart:io";
import "dart:convert";
import "dart:io";
import "dart:math" as math;
import "dart:typed_data";
import "package:image/image.dart" as img;
import "base_tool.dart";
// blocked paths that would hang or make no sense to read
const _blockedPaths = {
// device files that would hang or produce infinite output.
// /dev/null is intentionally left out - its safe
const _blockedDevicePaths = {
"/dev/zero", "/dev/random", "/dev/urandom", "/dev/full",
"/dev/stdin", "/dev/tty", "/dev/console",
"/dev/stdout", "/dev/stderr",
"/dev/fd/0", "/dev/fd/1", "/dev/fd/2",
};
// max lines we'll add numbers to before giving up
const int _defaultLineLimit = 2000;
// binary extensions — mirrors old_repo/constants/files.ts BINARY_EXTENSIONS.
// pdf, png, jpg etc are excluded here because this tool handles them natively
const _binaryExtensions = {
// images handled natively — excluded
// ".png", ".jpg", ".jpeg", ".gif", ".webp",
".bmp", ".ico", ".tiff", ".tif",
// video
".mp4", ".mov", ".avi", ".mkv", ".webm", ".wmv", ".flv",
".m4v", ".mpeg", ".mpg",
// audio
".mp3", ".wav", ".ogg", ".flac", ".aac", ".m4a", ".wma", ".aiff", ".opus",
// archives
".zip", ".tar", ".gz", ".bz2", ".7z", ".rar", ".xz", ".z", ".tgz", ".iso",
// executables
".exe", ".dll", ".so", ".dylib", ".bin", ".o", ".a",
".obj", ".lib", ".app", ".msi", ".deb", ".rpm",
// docs — pdf excluded (handled natively)
".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".odt", ".ods", ".odp",
// fonts
".ttf", ".otf", ".woff", ".woff2", ".eot",
// bytecode / vm
".pyc", ".pyo", ".class", ".jar", ".war", ".ear",
".node", ".wasm", ".rlib",
// databases
".sqlite", ".sqlite3", ".db", ".mdb", ".idx",
// design / 3d
".psd", ".ai", ".eps", ".sketch", ".fig", ".xd",
".blend", ".3ds", ".max",
// flash
".swf", ".fla",
// misc binary
".lockb", ".dat", ".data",
};
const _imageExtensions = {"png", "jpg", "jpeg", "gif", "webp"};
// limits — matches Claude Code defaults
const _maxSizeBytes = 256 * 1024; // 256 KB
const _maxOutputTokens = 25000;
const _maxImageWidth = 2000;
const _maxImageHeight = 2000;
const _imageTargetRawSize = 5 * 1024 * 1024 * 3 ~/ 4; // 3.75 MB
const _pdfAtMentionThreshold = 10; // pages before we require page range
const _pdfMaxPagesPerRead = 20;
// stub returned when file hasn't changed since last read
const _fileUnchangedStub =
"File unchanged since last read. The content from the earlier Read tool_result "
"in this conversation is still current — refer to that instead of re-reading.";
// appended to text file reads to discourage helping with malware
const _cyberRiskReminder =
"\n\n<system-reminder>\nWhenever you read a file, you should consider whether "
"it would be considered malware. You CAN and SHOULD provide analysis of malware, "
"what it is doing. But you MUST refuse to improve or augment the code. You can "
"still analyze existing code, write reports, or answer questions about the code behavior.\n"
"</system-reminder>\n";
// macOS screenshot thin-space char (U+202F)
const _thinSpace = "\u202F";
// per-file read state for dedup — keyed by absolute path
class _FileReadState {
final String content;
final int timestamp; // mtime in milliseconds
final int offset;
final int? limit;
const _FileReadState({
required this.content,
required this.timestamp,
required this.offset,
this.limit,
});
}
class FileReadTool extends BaseTool {
// dedup state: tracks files we've already read this session
final Map<String, _FileReadState> _readFileState = {};
@override
final String name = "Read";
@override
final String description =
"Reads a file from the local filesystem. "
"Supports offset and limit params to read specific portions. "
"Returns content with line numbers in cat -n format.";
"Reads a file from the local filesystem. You can access any file directly by using this tool.\n"
"Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned.\n\n"
"Usage:\n"
"- The file_path parameter must be an absolute path, not a relative path\n"
"- By default, it reads up to 2000 lines starting from the beginning of the file\n"
"- When you already know which part of the file you need, only read that part. This can be important for larger files.\n"
"- Results are returned using cat -n format, with line numbers starting at 1\n"
"- This tool allows The Agency to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as The Agency is a multimodal LLM.\n"
"- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations.\n"
"- This tool can only read files, not directories. To read a directory, use an ls command via the Bash tool.\n"
"- You will regularly be asked to read screenshots. If the user provides a path to a screenshot, ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths.\n"
"- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents.";
@override
Future<String> execute(Map<String, dynamic> input) async {
final filePath = requireString(input, "file_path");
String filePath = requireString(input, "file_path");
final offset = optionalInt(input, "offset") ?? 0;
final limit = optionalInt(input, "limit");
final pages = optionalString(input, "pages");
// check blocked device paths
if (_blockedPaths.contains(filePath)) {
return "Error: Reading from $filePath is not allowed.";
// expand ~ paths
if (filePath.startsWith("~")) {
final home = Platform.environment["HOME"] ?? "";
filePath = home + filePath.substring(1);
}
// block dangerous device paths
if (_isBlockedDevicePath(filePath)) {
return "Error: Cannot read '$filePath': this device file would block or produce infinite output.";
}
final ext = _ext(filePath).toLowerCase();
// block binary files (but not images/pdf — handled natively)
if (_binaryExtensions.contains(".$ext")) {
return "Error: This tool cannot read binary files. "
"The file appears to be a binary .$ext file. "
"Please use appropriate tools for binary file analysis.";
}
// --- notebook ---
if (ext == "ipynb") {
return await _readNotebook(filePath);
}
// --- image ---
if (_imageExtensions.contains(ext)) {
return await _readImage(filePath);
}
// PARITY GAP: Claude Code reads PDFs natively via the Anthropic API's document block support
// and falls back to poppler-utils for page extraction. Neither is available here —
// the Anthropic SDK PDF path requires direct API access (not OpenRouter), and bundling
// poppler is impractical for a desktop app. To be implemented when The Agency moves to
// a SaaS model with a dedicated backend API that can handle PDF processing server-side.
if (ext == "pdf") {
if (pages != null) {
return "Error: PDF reading is not yet supported. "
"This will be available in a future version of The Agency.";
}
return "Error: PDF reading is not yet supported. "
"This will be available in a future version of The Agency.";
}
// --- text file ---
return await _readTextFile(filePath, offset, limit);
}
// ---- text file reading ----
Future<String> _readTextFile(String filePath, int offset, int? limit) async {
// dedup check
final existing = _readFileState[filePath];
if (existing != null && existing.offset == offset && existing.limit == limit) {
try {
final stat = await FileStat.stat(filePath);
final mtime = stat.modified.millisecondsSinceEpoch;
if (mtime == existing.timestamp) {
return _fileUnchangedStub;
}
} catch (_) {
// stat failed — fall through to full read
}
}
final file = File(filePath);
if (!await file.exists()) {
return "Error: File not found: $filePath";
return await _notFoundError(filePath);
}
// check if its a directory
final stat = await file.stat();
if (stat.type == FileSystemEntityType.directory) {
return "Error: Path is a directory, not a file: $filePath";
}
// size check - only enforce when no explicit limit (matches Claude Code behaviour)
if (limit == null && stat.size > _maxSizeBytes) {
return "Error: File content (${_formatBytes(stat.size)}) exceeds maximum allowed size "
"(${_formatBytes(_maxSizeBytes)}). Use offset and limit to read a specific portion.";
}
String content;
try {
content = await file.readAsString(encoding: utf8);
} catch (e) {
// maybe its latin1 or something
} catch (_) {
try {
final bytes = await file.readAsBytes();
content = latin1.decode(bytes);
@@ -66,29 +224,338 @@ class FileReadTool extends BaseTool {
// normalise line endings
content = content.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
final lines = content.split("\n");
final allLines = content.split("\n");
final totalLines = allLines.length;
// apply offset + limit
final effectiveOffset = offset.clamp(0, lines.length);
final effectiveEnd = limit != null
? (effectiveOffset + limit).clamp(0, lines.length)
: lines.length;
final effectiveOffset = offset.clamp(0, totalLines);
final end = limit != null
? (effectiveOffset + limit).clamp(0, totalLines)
: math.min(effectiveOffset + 2000, totalLines);
final sliced = lines.sublist(effectiveOffset, effectiveEnd);
final sliced = allLines.sublist(effectiveOffset, end);
final startLine = effectiveOffset + 1; // 1-indexed
// rough token estimate (1 token ≈ 4 chars)
final slicedContent = sliced.join("\n");
final estimatedTokens = slicedContent.length ~/ 4;
if (estimatedTokens > _maxOutputTokens) {
return "Error: File content ($estimatedTokens tokens) exceeds maximum allowed tokens ($_maxOutputTokens). "
"Use offset and limit parameters to read specific portions of the file, "
"or search for specific content instead of reading the whole file.";
}
// store read state for dedup
final mtimeMs = stat.modified.millisecondsSinceEpoch;
_readFileState[filePath] = _FileReadState(
content: slicedContent,
timestamp: mtimeMs,
offset: offset,
limit: limit,
);
if (sliced.isEmpty) {
if (totalLines == 0) {
return "<system-reminder>Warning: the file exists but the contents are empty.</system-reminder>";
}
return "<system-reminder>Warning: the file exists but is shorter than the provided offset ($startLine). The file has $totalLines lines.</system-reminder>";
}
// add line numbers like cat -n (1-indexed, based on original file position)
final buf = StringBuffer();
for (var i = 0; i < sliced.length; i++) {
final lineNum = effectiveOffset + i + 1;
buf.writeln("${lineNum.toString().padLeft(6)}\t${sliced[i]}");
buf.write("$lineNum\t${sliced[i]}");
if (i < sliced.length - 1) buf.write("\n");
}
final result = buf.toString();
return buf.toString() + _cyberRiskReminder;
}
if (result.isEmpty) {
return "(empty file)";
// ---- image reading ----
Future<String> _readImage(String filePath) async {
final file = File(filePath);
if (!await file.exists()) {
return await _notFoundError(filePath);
}
return result;
Uint8List bytes;
try {
bytes = await file.readAsBytes();
} catch (e) {
return "Error: Could not read image file: $e";
}
if (bytes.isEmpty) {
return "Error: Image file is empty: $filePath";
}
final ext = _ext(filePath).toLowerCase();
String mediaType = _extToMediaType(ext);
// decode and maybe resize
Uint8List outputBytes = bytes;
String outputMediaType = mediaType;
try {
final decoded = img.decodeImage(bytes);
if (decoded != null) {
var w = decoded.width;
var h = decoded.height;
final needsResize = w > _maxImageWidth || h > _maxImageHeight ||
bytes.length > _imageTargetRawSize;
if (needsResize) {
// scale to fit within max dimensions
double scale = 1.0;
if (w > _maxImageWidth) scale = math.min(scale, _maxImageWidth / w);
if (h > _maxImageHeight) scale = math.min(scale, _maxImageHeight / h);
final resized = img.copyResize(
decoded,
width: (w * scale).round(),
height: (h * scale).round(),
interpolation: img.Interpolation.linear,
);
outputBytes = Uint8List.fromList(img.encodeJpg(resized, quality: 85));
outputMediaType = "image/jpeg";
}
}
} catch (_) {
// decoding failed — just send raw bytes (API will handle or reject)
}
final base64Data = base64Encode(outputBytes);
// return as a structured string the tool loop can detect and convert
// to an image block — format: IMAGE_BLOCK:<mediaType>:<base64>
return "IMAGE_BLOCK:$outputMediaType:$base64Data";
}
// ---- notebook reading ----
Future<String> _readNotebook(String filePath) async {
final file = File(filePath);
if (!await file.exists()) {
return await _notFoundError(filePath);
}
String raw;
try {
raw = await file.readAsString(encoding: utf8);
} catch (e) {
return "Error: Could not read notebook file: $e";
}
Map<String, dynamic> notebook;
try {
notebook = jsonDecode(raw) as Map<String, dynamic>;
} catch (e) {
return "Error: Failed to parse notebook JSON: $e";
}
final meta = notebook["metadata"] as Map<String, dynamic>? ?? {};
final langInfo = meta["language_info"] as Map<String, dynamic>? ?? {};
final language = langInfo["name"] as String? ?? "python";
final rawCells = notebook["cells"] as List<dynamic>? ?? [];
final buf = StringBuffer();
for (var i = 0; i < rawCells.length; i++) {
final cell = rawCells[i] as Map<String, dynamic>;
final cellType = cell["cell_type"] as String? ?? "code";
final cellId = cell["id"] as String? ?? "cell-$i";
final rawSource = cell["source"];
final source = rawSource is List
? rawSource.join("")
: (rawSource as String? ?? "");
buf.write("<cell id=\"$cellId\">");
if (cellType != "code") {
buf.write("<cell_type>$cellType</cell_type>");
}
if (cellType == "code" && language != "python") {
buf.write("<language>$language</language>");
}
buf.write(source);
buf.write("</cell id=\"$cellId\">");
// outputs for code cells
if (cellType == "code") {
final outputs = cell["outputs"] as List<dynamic>? ?? [];
for (final output in outputs) {
final o = output as Map<String, dynamic>;
final oType = o["output_type"] as String? ?? "";
switch (oType) {
case "stream":
final text = _notebookText(o["text"]);
if (text.isNotEmpty) buf.write("\n$text");
break;
case "execute_result":
case "display_data":
final data = o["data"] as Map<String, dynamic>? ?? {};
final text = _notebookText(data["text/plain"]);
if (text.isNotEmpty) buf.write("\n$text");
// images in notebook outputs — inline as base64 marker
final pngData = data["image/png"] as String?;
final jpgData = data["image/jpeg"] as String?;
if (pngData != null) {
buf.write("\nIMAGE_BLOCK:image/png:${pngData.replaceAll(RegExp(r"\s"), "")}");
} else if (jpgData != null) {
buf.write("\nIMAGE_BLOCK:image/jpeg:${jpgData.replaceAll(RegExp(r"\s"), "")}");
}
break;
case "error":
final ename = o["ename"] as String? ?? "";
final evalue = o["evalue"] as String? ?? "";
final tb = (o["traceback"] as List<dynamic>? ?? []).join("\n");
buf.write("\n$ename: $evalue\n$tb");
break;
}
}
}
buf.write("\n");
}
return buf.toString();
}
// ---- ENOENT helpers ----
Future<String> _notFoundError(String filePath) async {
// try macOS screenshot thin-space alternate path first
final alt = _getAlternateScreenshotPath(filePath);
if (alt != null && await File(alt).exists()) {
return await _readTextFile(alt, 0, null);
}
final similar = _findSimilarFile(filePath);
final cwd = Directory.current.path;
// check for "dropped repo folder" pattern
final suggestion = await _suggestPathUnderCwd(filePath, cwd);
var msg = "File does not exist. Note: your current working directory is $cwd.";
if (suggestion != null) {
msg += " Did you mean $suggestion?";
} else if (similar != null) {
msg += " Did you mean $similar?";
}
return "Error: $msg";
}
String? _findSimilarFile(String filePath) {
try {
final dir = Directory(filePath.substring(0, filePath.lastIndexOf("/")));
final baseName = _basename(filePath);
final baseNameNoExt = baseName.contains(".")
? baseName.substring(0, baseName.lastIndexOf("."))
: baseName;
final entities = dir.listSync();
for (final e in entities) {
if (e is! File) continue;
final name = e.path.split("/").last;
final nameNoExt = name.contains(".")
? name.substring(0, name.lastIndexOf("."))
: name;
if (nameNoExt == baseNameNoExt && e.path != filePath) {
return name;
}
}
} catch (_) {}
return null;
}
Future<String?> _suggestPathUnderCwd(String requestedPath, String cwd) async {
try {
final cwdParent = cwd.substring(0, cwd.lastIndexOf("/"));
final cwdParentPrefix = cwdParent == "/" ? "/" : "$cwdParent/";
if (!requestedPath.startsWith(cwdParentPrefix) ||
requestedPath.startsWith("$cwd/") ||
requestedPath == cwd) {
return null;
}
final relFromParent = requestedPath.substring(cwdParentPrefix.length);
final corrected = "$cwd/$relFromParent";
if (await File(corrected).exists() || await Directory(corrected).exists()) {
return corrected;
}
} catch (_) {}
return null;
}
// for macOS screenshots — AM/PM may use regular space or U+202F thin space
String? _getAlternateScreenshotPath(String filePath) {
final filename = _basename(filePath);
final amPmRe = RegExp(r"^(.+)([ \u202F])(AM|PM)(\.png)$");
final match = amPmRe.firstMatch(filename);
if (match == null) return null;
final currentSpace = match.group(2)!;
final altSpace = currentSpace == " " ? _thinSpace : " ";
final altFilename = "${match.group(1)!}$altSpace${match.group(3)!}${match.group(4)!}";
final dir = filePath.substring(0, filePath.length - filename.length);
return "$dir$altFilename";
}
// ---- utilities ----
bool _isBlockedDevicePath(String path) {
if (_blockedDevicePaths.contains(path)) return true;
// Linux /proc/self/fd/0-2 and /proc/<pid>/fd/0-2
if (path.startsWith("/proc/") &&
(path.endsWith("/fd/0") || path.endsWith("/fd/1") || path.endsWith("/fd/2"))) {
return true;
}
return false;
}
String _ext(String path) {
final dot = path.lastIndexOf(".");
if (dot < 0 || dot == path.length - 1) return "";
return path.substring(dot + 1);
}
String _basename(String path) {
final idx = path.lastIndexOf("/");
return idx < 0 ? path : path.substring(idx + 1);
}
String _extToMediaType(String ext) {
switch (ext) {
case "jpg":
case "jpeg": return "image/jpeg";
case "gif": return "image/gif";
case "webp": return "image/webp";
default: return "image/png";
}
}
String _notebookText(dynamic value) {
if (value == null) return "";
if (value is List) return value.join("");
return value.toString();
}
String _formatBytes(int bytes) {
if (bytes < 1024) return "${bytes}B";
if (bytes < 1024 * 1024) return "${(bytes / 1024).toStringAsFixed(1)}KB";
return "${(bytes / 1024 / 1024).toStringAsFixed(1)}MB";
}
}
+331 -245
View File
@@ -1,13 +1,45 @@
import "dart:io";
import "dart:convert";
import "base_tool.dart";
// directories to skip during grep searches
// directories to exclude from all searches
const _vcsSkip = {".git", ".svn", ".hg", ".bzr", ".jj", ".sl"};
const int _defaultHeadLimit = 250;
const int _maxColumns = 500;
// maps rg --type names to file extensions
// lifted from ripgrep's types.rs — just the ones we care about
const _typeExtensions = <String, List<String>>{
"dart": ["dart"],
"js": ["js", "mjs", "cjs"],
"ts": ["ts", "mts", "cts"],
"tsx": ["tsx"],
"jsx": ["jsx"],
"py": ["py", "pyi"],
"rust": ["rs"],
"go": ["go"],
"java": ["java"],
"kotlin": ["kt", "kts"],
"swift": ["swift"],
"c": ["c", "h"],
"cpp": ["cpp", "cc", "cxx", "c++", "hpp", "hh"],
"cs": ["cs"],
"rb": ["rb"],
"sh": ["sh", "bash", "zsh"],
"json": ["json"],
"yaml": ["yaml", "yml"],
"toml": ["toml"],
"xml": ["xml"],
"html": ["html", "htm"],
"css": ["css"],
"scss": ["scss"],
"md": ["md", "markdown"],
"sql": ["sql"],
"txt": ["txt"],
};
class GrepTool extends BaseTool {
@override
@@ -15,9 +47,14 @@ class GrepTool extends BaseTool {
@override
final String description =
"A powerful search tool. Supports full regex syntax. "
"Filter files with glob parameter. "
"Output modes: content, files_with_matches, count.";
"A powerful search tool built on ripgrep\n\n"
"Usage:\n"
"- Supports full regex syntax (e.g., \"log.*Error\", \"function\\s+\\w+\")\n"
"- Filter files with glob parameter (e.g., \"*.js\", \"**/*.tsx\") or type parameter (e.g., \"js\", \"py\", \"rust\")\n"
"- Output modes: \"content\" shows matching lines, \"files_with_matches\" shows only file paths (default), \"count\" shows match counts\n"
"- Pattern syntax: Uses ripgrep — literal braces need escaping\n"
"- Multiline matching: By default patterns match within single lines only. "
"For cross-line patterns use multiline: true";
@override
@@ -32,290 +69,339 @@ class GrepTool extends BaseTool {
final fileType = optionalString(input, "type");
final headLimit = optionalInt(input, "head_limit");
final offset = optionalInt(input, "offset") ?? 0;
final contextLines = optionalInt(input, "context") ?? optionalInt(input, "-C");
final contextBefore = optionalInt(input, "-B");
final contextAfter = optionalInt(input, "-A");
// try ripgrep first since thats what the original does
final rgPath = await _findRipgrep();
if (rgPath != null) {
return _runWithRipgrep(
rgPath: rgPath,
pattern: pattern,
path: pathArg,
glob: glob,
outputMode: outputMode,
caseInsensitive: caseInsensitive,
showLineNumbers: showLineNumbers,
multiline: multiline,
fileType: fileType,
headLimit: headLimit,
offset: offset,
contextLines: contextLines,
contextBefore: contextBefore,
contextAfter: contextAfter,
final searchRoot = pathArg ?? Directory.current.path;
// build regex
RegExp regex;
try {
regex = RegExp(
pattern,
caseSensitive: !caseInsensitive,
multiLine: multiline,
dotAll: multiline,
);
} catch (e) {
return "Error: invalid regex pattern: $e";
}
// fallback - pure dart implementation
return _runPureDart(
pattern: pattern,
path: pathArg,
glob: glob,
outputMode: outputMode,
caseInsensitive: caseInsensitive,
// resolve glob patterns
final globPatterns = _parseGlob(glob);
// resolve type extensions
final typeExts = fileType != null ? (_typeExtensions[fileType] ?? []) : <String>[];
// walk the tree and collect matching files
final allFiles = await _walkDir(Directory(searchRoot));
// filter by glob / type
final filtered = allFiles.where((f) {
final rel = _toRelative(f.path, searchRoot);
if (typeExts.isNotEmpty) {
final ext = _ext(f.path);
if (!typeExts.contains(ext)) return false;
}
if (globPatterns.isNotEmpty) {
final matched = globPatterns.any((g) => _globMatch(g, rel, f.path));
if (!matched) return false;
}
return true;
}).toList();
if (outputMode == "files_with_matches") {
return await _modeFilesWithMatches(
filtered, regex, searchRoot, headLimit, offset);
}
if (outputMode == "count") {
return await _modeCount(filtered, regex, searchRoot, headLimit, offset);
}
// content
return await _modeContent(
filtered, regex, searchRoot,
showLineNumbers: showLineNumbers,
contextBefore: contextLines ?? contextBefore ?? 0,
contextAfter: contextLines ?? contextAfter ?? 0,
headLimit: headLimit,
offset: offset,
);
}
Future<String?> _findRipgrep() async {
for (final candidate in ["rg", "/usr/bin/rg", "/usr/local/bin/rg"]) {
// --- output modes ---
Future<String> _modeFilesWithMatches(
List<File> files,
RegExp regex,
String root,
int? headLimit,
int offset,
) async {
final hits = <({String path, int mtime})>[];
for (final f in files) {
try {
final res = await Process.run("which", [candidate]);
if ((res.exitCode == 0) && (res.stdout as String).trim().isNotEmpty) {
return (res.stdout as String).trim();
final content = await f.readAsString();
if (regex.hasMatch(content)) {
final stat = await f.stat();
hits.add((path: f.path, mtime: stat.modified.millisecondsSinceEpoch));
}
} catch (_) {}
}
return null;
hits.sort((a, b) => b.mtime - a.mtime);
final paths = hits.map((h) => _toRelative(h.path, root)).toList();
final sliced = _applyHeadLimit(paths, headLimit, offset);
if (sliced.isEmpty) return "No files found";
return "Found ${sliced.length} files\n${sliced.join("\n")}";
}
Future<String> _runWithRipgrep({
required String rgPath,
required String pattern,
String? path,
String? glob,
required String outputMode,
required bool caseInsensitive,
required bool showLineNumbers,
required bool multiline,
String? fileType,
Future<String> _modeCount(
List<File> files,
RegExp regex,
String root,
int? headLimit,
required int offset,
int? contextLines,
int? contextBefore,
int? contextAfter,
}) async {
final searchPath = path ?? Directory.current.path;
final searchPathType = FileSystemEntity.typeSync(searchPath, followLinks: true);
int offset,
) async {
final results = <String>[];
final args = <String>["--hidden"];
for (final dir in _vcsSkip) {
args.addAll(["--glob", "!$dir"]);
for (final f in files) {
try {
final lines = await f.readAsLines();
final count = lines.where((l) => regex.hasMatch(l)).length;
if (count > 0) {
results.add("${_toRelative(f.path, root)}:$count");
}
} catch (_) {}
}
args.addAll(["--max-columns", "500"]);
final sliced = _applyHeadLimit(results, headLimit, offset);
if (sliced.isEmpty) return "No matches found";
if (multiline) args.addAll(["-U", "--multiline-dotall"]);
if (caseInsensitive) args.add("-i");
if (outputMode == "files_with_matches") {
args.add("-l");
} else if (outputMode == "count") {
args.add("-c");
var total = 0;
for (final r in sliced) {
final ci = r.lastIndexOf(":");
if (ci >= 0) total += int.tryParse(r.substring(ci + 1)) ?? 0;
}
if (showLineNumbers && outputMode == "content") args.add("-n");
if (searchPathType == FileSystemEntityType.file &&
outputMode != "files_with_matches") {
args.add("--with-filename");
}
if (outputMode == "content") {
if (contextLines != null) {
args.addAll(["-C", "$contextLines"]);
} else {
if (contextBefore != null) args.addAll(["-B", "$contextBefore"]);
if (contextAfter != null) args.addAll(["-A", "$contextAfter"]);
}
}
// pattern starting with dash needs -e flag
if (pattern.startsWith("-")) {
args.addAll(["-e", pattern]);
} else {
args.add(pattern);
}
if (fileType != null) args.addAll(["--type", fileType]);
if (glob != null && glob.isNotEmpty) {
final parts = glob.split(RegExp(r"\s+"));
for (final p in parts) {
if (p.isEmpty) continue;
args.addAll(["--glob", p]);
}
}
final result = await Process.run(rgPath, [...args, searchPath],
stdoutEncoding: utf8, stderrEncoding: utf8);
// exit code 1 = no matches (normal), 2 = error
if (result.exitCode == 2) {
return "Error: ${result.stderr}";
}
final lines = (result.stdout as String)
.split("\n")
.where((l) => l.isNotEmpty)
.toList();
return _formatResults(lines, outputMode, headLimit, offset);
return "${sliced.join("\n")}\n\nFound $total total occurrences across ${sliced.length} files.";
}
Future<String> _runPureDart({
required String pattern,
String? path,
String? glob,
required String outputMode,
required bool caseInsensitive,
Future<String> _modeContent(
List<File> files,
RegExp regex,
String root, {
required bool showLineNumbers,
required int contextBefore,
required int contextAfter,
int? headLimit,
required int offset,
int offset = 0,
}) async {
final searchPath = path ?? Directory.current.path;
final entityType = FileSystemEntity.typeSync(searchPath, followLinks: true);
if (entityType == FileSystemEntityType.notFound) {
return "Error: Path does not exist: $searchPath";
}
final outputLines = <String>[];
final regex = RegExp(pattern, caseSensitive: !caseInsensitive, multiLine: true);
for (final f in files) {
List<String> lines;
try {
lines = await f.readAsLines();
} catch (_) {
continue;
}
final matchedFiles = <String>[];
final contentLines = <String>[];
final baseDir = entityType == FileSystemEntityType.directory
? searchPath
: File(searchPath).parent.path;
final rel = _toRelative(f.path, root);
if (entityType == FileSystemEntityType.file) {
await _searchFile(
file: File(searchPath),
regex: regex,
glob: glob,
outputMode: outputMode,
showLineNumbers: showLineNumbers,
baseDir: baseDir,
matchedFiles: matchedFiles,
contentLines: contentLines,
);
} else {
final searchDir = Directory(searchPath);
await for (final entity
in searchDir.list(recursive: true, followLinks: false)) {
if (entity is! File) continue;
// collect matching line indexes
final matchIdxs = <int>[];
for (var i = 0; i < lines.length; i++) {
if (regex.hasMatch(lines[i])) matchIdxs.add(i);
}
await _searchFile(
file: entity,
regex: regex,
glob: glob,
outputMode: outputMode,
showLineNumbers: showLineNumbers,
baseDir: baseDir,
matchedFiles: matchedFiles,
contentLines: contentLines,
);
if (matchIdxs.isEmpty) continue;
// expand context windows, merge overlapping ranges
final ranges = <(int, int)>[];
for (final idx in matchIdxs) {
final from = (idx - contextBefore).clamp(0, lines.length - 1);
final to = (idx + contextAfter).clamp(0, lines.length - 1);
if (ranges.isNotEmpty && from <= ranges.last.$2 + 1) {
// merge
ranges[ranges.length - 1] = (ranges.last.$1, to);
} else {
ranges.add((from, to));
}
}
var prevEnd = -1;
for (final (from, to) in ranges) {
if (prevEnd >= 0 && from > prevEnd + 1) outputLines.add("--");
prevEnd = to;
for (var i = from; i <= to; i++) {
final lineText = lines[i].length > _maxColumns
? lines[i].substring(0, _maxColumns)
: lines[i];
if (showLineNumbers) {
outputLines.add("$rel:${i + 1}:$lineText");
} else {
outputLines.add("$rel:$lineText");
}
}
}
}
if (outputMode == "files_with_matches") {
return _formatResults(matchedFiles, outputMode, headLimit, offset);
}
return _formatResults(contentLines, outputMode, headLimit, offset);
}
Future<void> _searchFile({
required File file,
required RegExp regex,
required String? glob,
required String outputMode,
required bool showLineNumbers,
required String baseDir,
required List<String> matchedFiles,
required List<String> contentLines,
}) async {
final parts = file.path.split("/");
if (parts.any((p) => _vcsSkip.contains(p))) return;
if (glob != null) {
final filename = file.path.split("/").last;
if (!_simpleGlobMatch(glob, filename)) return;
}
String content;
try {
content = await file.readAsString(encoding: utf8);
} catch (_) {
return;
}
final fileMatches = regex.allMatches(content).length;
if (fileMatches == 0) return;
final relPath = _displayPath(file.path, baseDir);
if (outputMode == "files_with_matches") {
matchedFiles.add(relPath);
return;
}
if (outputMode == "count") {
contentLines.add("$relPath:$fileMatches");
return;
}
final fileLines = content.split("\n");
for (var i = 0; i < fileLines.length; i++) {
if (regex.hasMatch(fileLines[i])) {
final prefix = showLineNumbers ? "$relPath:${i + 1}:" : "$relPath:";
contentLines.add("$prefix${fileLines[i]}");
}
}
}
String _displayPath(String filePath, String baseDir) {
if (filePath == baseDir) {
return filePath.split("/").last;
}
if (filePath.startsWith("$baseDir/")) {
return filePath.substring(baseDir.length + 1);
}
return filePath;
}
String _formatResults(List<String> lines, String outputMode, int? headLimit, int offset) {
// apply offset + head_limit
final effectiveLimit = (headLimit == 0) ? null : (headLimit ?? _defaultHeadLimit);
List<String> sliced;
if (effectiveLimit == null) {
sliced = lines.sublist(offset.clamp(0, lines.length));
} else {
final start = offset.clamp(0, lines.length);
final end = (start + effectiveLimit).clamp(0, lines.length);
sliced = lines.sublist(start, end);
}
if (sliced.isEmpty) {
return outputMode == "files_with_matches" ? "No files found" : "No matches found";
}
final sliced = _applyHeadLimit(outputLines, headLimit, offset);
if (sliced.isEmpty) return "No matches found";
return sliced.join("\n");
}
bool _simpleGlobMatch(String pattern, String text) {
final regex = RegExp(
"^${pattern.replaceAll(".", "\\.").replaceAll("*", ".*").replaceAll("?", ".")}\$",
);
return regex.hasMatch(text);
// --- file walking ---
Future<List<File>> _walkDir(Directory dir) async {
final results = <File>[];
try {
await for (final entity in dir.list(recursive: false)) {
if (entity is Directory) {
final name = entity.path.split(Platform.pathSeparator).last;
if (_vcsSkip.contains(name)) continue;
results.addAll(await _walkDir(entity));
} else if (entity is File) {
results.add(entity);
}
// symlinks ignored — rg also ignores by default
}
} catch (_) {}
return results;
}
// --- glob handling ---
// parses the glob param — brace groups stay whole, others split on comma
List<String> _parseGlob(String? glob) {
if (glob == null || glob.isEmpty) return [];
final rawPatterns = glob.trim().split(RegExp(r"\s+"));
final out = <String>[];
for (final raw in rawPatterns) {
if (raw.contains("{") && raw.contains("}")) {
out.add(raw);
} else {
out.addAll(raw.split(",").where((p) => p.isNotEmpty));
}
}
return out;
}
// checks whether a file matches a single glob pattern
// handles **, *, ?, brace expansion like {ts,tsx}
bool _globMatch(String pattern, String relPath, String absPath) {
// expand brace alternatives: *.{ts,tsx} → ["*.ts", "*.tsx"]
final expanded = _expandBraces(pattern);
return expanded.any((p) => _singleGlobMatch(p, relPath));
}
List<String> _expandBraces(String pattern) {
final open = pattern.indexOf("{");
final close = pattern.indexOf("}");
if (open < 0 || close < 0 || close < open) return [pattern];
final prefix = pattern.substring(0, open);
final suffix = pattern.substring(close + 1);
final alts = pattern.substring(open + 1, close).split(",");
final results = <String>[];
for (final alt in alts) {
results.addAll(_expandBraces("$prefix$alt$suffix"));
}
return results;
}
bool _singleGlobMatch(String pattern, String path) {
// convert glob to regex
final regexStr = _globToRegex(pattern);
try {
return RegExp(regexStr).hasMatch(path);
} catch (_) {
return false;
}
}
String _globToRegex(String glob) {
final buf = StringBuffer("^");
var i = 0;
while (i < glob.length) {
final ch = glob[i];
if (ch == "*") {
if (i + 1 < glob.length && glob[i + 1] == "*") {
// ** matches anything including path separators
// handle /**/ /** **/
buf.write(".*");
i += 2;
// skip surrounding slashes so **/ works
if (i < glob.length && glob[i] == "/") i++;
} else {
// * matches anything except /
buf.write("[^/]*");
i++;
}
} else if (ch == "?") {
buf.write("[^/]");
i++;
} else if (ch == ".") {
buf.write(r"\.");
i++;
} else if (RegExp(r"[+^${}()|[\]\\]").hasMatch(ch)) {
buf.write(RegExp.escape(ch));
i++;
} else {
buf.write(ch);
i++;
}
}
buf.write(r"$");
return buf.toString();
}
// --- utilities ---
List<String> _applyHeadLimit(List<String> items, int? limit, int offset) {
if (limit == 0) return items.skip(offset).toList();
final effective = limit ?? _defaultHeadLimit;
final start = offset.clamp(0, items.length);
final end = (start + effective).clamp(0, items.length);
return items.sublist(start, end);
}
String _toRelative(String filePath, String basePath) {
final base = basePath.endsWith("/") ? basePath : "$basePath/";
if (filePath.startsWith(base)) return filePath.substring(base.length);
return filePath;
}
String _ext(String path) {
final dot = path.lastIndexOf(".");
if (dot < 0) return "";
return path.substring(dot + 1).toLowerCase();
}
}
+9
View File
@@ -0,0 +1,9 @@
// Opt-in interface for tools that can stream output chunks as they run.
// Only BashTool and ExecuteTaskTool implement this — everything else
// goes through the normal execute() path untouched.
mixin StreamingTool {
Future<String> executeStreaming(
Map<String, dynamic> input, {
required void Function(String chunk) onChunk,
});
}
+1 -1
View File
@@ -17,7 +17,7 @@ class ClawdApp extends StatelessWidget {
routerConfig: AppRouter.router,
scaling: const AdaptiveScaling(0.9),
theme: ThemeData(
colorScheme: ColorSchemes.darkGray,
colorScheme: ColorSchemes.darkStone,
density: Density.spaciousDensity,
radius: 0.5,
),
+10
View File
@@ -27,6 +27,11 @@ const List<SelectableAiModel> selectableAiModels = [
id: "openai/gpt-5.4-mini",
label: "GPT-5.4 Mini",
),
SelectableAiModel(
group: "Recommended",
id: "openai/gpt-5.4",
label: "GPT-5.4",
),
SelectableAiModel(
group: "Recommended",
id: "moonshotai/kimi-k2.5",
@@ -37,6 +42,11 @@ const List<SelectableAiModel> selectableAiModels = [
id: "google/gemini-3-flash-preview",
label: "Gemini 3 Flash Preview",
),
SelectableAiModel(
group: "Recommended",
id: "anthropic/claude-sonnet-4.6",
label: "Claude Sonnet 4.6",
),
];
+84 -62
View File
@@ -10,6 +10,7 @@ import "../../widgets/chat/chat_box.dart";
import "../../widgets/chat/chat_view.dart";
import "../../widgets/common/footer_bar.dart";
import "../../widgets/common/app_header.dart";
import "../../widgets/common/button.dart";
import "../../widgets/sidebar/sidebar.dart";
import "../../widgets/sidebar/sidebar_v2.dart";
@@ -137,19 +138,38 @@ class _ChatArea extends StatelessWidget {
return Container(
alignment: Alignment.center,
padding: const EdgeInsets.all(16),
// padding: const EdgeInsets.all(16),
padding: EdgeInsets.only(
left: 16,
right: 16
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
constraints: const BoxConstraints(maxWidth: 700),
child: Column(
children: [
Expanded(
child: chatProvider.messages.isEmpty
? _EmptyChatState()
: ChatView(scrollController: scrollController),
),
if (chatProvider.messages.isEmpty)...[
Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_EmptyChatState(),
ChatBox(),
],
),
)
),
] else ...[
Expanded(child: ChatView(scrollController: scrollController)),
ChatBox(),
Gap(12),
],
ChatBox(),
],
),
@@ -177,58 +197,15 @@ class _EmptyChatState extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(LucideIcons.messagesSquare, size: 28),
const Gap(16),
Text(
"Ask the agency anything",
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700),
"Lets burn some braincells",
textAlign: TextAlign.center,
),
const Gap(8),
).x4Large.extraBold,
Text(
"Select a project and thread from the sidebar, or start a new chat.",
textAlign: TextAlign.center,
).textSmall.muted,
const Gap(24),
Select<ProjectRecord>(
itemBuilder: (context, item) => Text(item.name),
popup: SelectPopup.builder(
searchPlaceholder: const Text("Search projects"),
builder: (context, searchQuery) {
final filtered = searchQuery == null || searchQuery.isEmpty
? projects
: projects.where((p) =>
p.name.toLowerCase().contains(searchQuery.toLowerCase()) ||
p.workingDirectory.toLowerCase().contains(searchQuery.toLowerCase())
).toList();
return SelectItemList(
children: [
for (final project in filtered)
SelectItemButton(
value: project,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(project.name),
Text(project.workingDirectory).textSmall.muted,
],
),
),
],
);
},
),
onChanged: (project) {
if (project != null) coordinator.selectProject(project);
},
constraints: const BoxConstraints(minWidth: 240),
value: selected,
placeholder: const Text("Select a project"),
),
],
),
),
@@ -272,24 +249,69 @@ class _InsetShadowPainter extends CustomPainter {
}
class _SidebarPane extends StatelessWidget {
class _SidebarPane extends StatefulWidget {
const _SidebarPane();
@override
State<_SidebarPane> createState() => _SidebarPaneState();
}
class _SidebarPaneState extends State<_SidebarPane> {
bool _open = true;
static const _dur = Duration(milliseconds: 220);
static const _curve = Curves.easeInOut;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return OutlinedContainer(
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 16,
spreadRadius: 2,
return Stack(
clipBehavior: Clip.none,
children: [
AnimatedSlide(
offset: _open ? Offset.zero : const Offset(-1.1, 0),
duration: _dur,
curve: _curve,
child: AnimatedOpacity(
opacity: _open ? 1.0 : 0.0,
duration: _dur,
curve: _curve,
child: OutlinedContainer(
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 16,
spreadRadius: 2,
),
],
child: SidebarV2(onClose: () => setState(() => _open = false)),
),
),
),
AnimatedOpacity(
opacity: _open ? 0.0 : 1.0,
duration: _dur,
curve: _curve,
child: IgnorePointer(
ignoring: _open,
child: IconButton.ghost(
onPressed: () => setState(() => _open = true),
icon: Icon(
LucideIcons.panelLeftOpen,
size: 16,
color: theme.colorScheme.mutedForeground,
),
),
),
),
],
child: SidebarV2(),
);
}
}
+56 -4
View File
@@ -1,4 +1,5 @@
import "package:flutter/foundation.dart";
import "package:flutter/scheduler.dart";
import "dart:typed_data";
import "../../src/chat/tool_loop_service.dart";
@@ -6,9 +7,12 @@ import "../../src/compact/compact_service.dart";
import "../../src/hooks/hook_loader.dart";
import "../../src/hooks/hook_runner.dart";
import "../../src/permissions/permission_types.dart";
import "../../src/services/cost_tracker.dart" as cost_tracker;
import "../../src/session/session_runtime.dart";
import "../../src/session/session_store.dart";
import "../../src/session/session_types.dart";
import "../models/attachment.dart";
import "cost_provider.dart";
import "settings_provider.dart";
@@ -20,18 +24,35 @@ import "settings_provider.dart";
// running and save themselves to disk; when you switch back you see their
// live state.
class ChatProvider extends ChangeNotifier {
ChatProvider(this._settingsProvider) {
ChatProvider(this._settingsProvider, this._costProvider) {
_initHooks();
}
final SettingsProvider _settingsProvider;
final CostProvider _costProvider;
void Function(String sessionId, String newName)? onSessionNameChanged;
ToolLoopService _toolLoopService = ToolLoopService();
HookRunner? _hookRunner;
final Map<String, SessionRuntime> _runtimes = {};
final Map<String, ConversationSession> _sessions = {};
String? _activeSessionId;
bool _notifyScheduled = false;
void _scheduleNotify() {
if (_notifyScheduled) return;
_notifyScheduled = true;
SchedulerBinding.instance.scheduleFrameCallback((_) {
_notifyScheduled = false;
notifyListeners();
_costProvider.refreshCost();
});
}
// ─── hooks ──────────────────────────────────────────────────────────────────
Future<void> _initHooks() async {
@@ -82,6 +103,10 @@ class ChatProvider extends ChangeNotifier {
return r != null && r.hasUnreadResult;
}
void markSessionRead(String sessionId) {
_runtimes[sessionId]?.setUnreadResult(false);
}
int get contextTokens {
final msgs = messages;
for (var i = msgs.length - 1; i >= 0; i--) {
@@ -99,19 +124,46 @@ class ChatProvider extends ChangeNotifier {
final id = session.id;
if (!_runtimes.containsKey(id)) {
_sessions[id] = session;
_runtimes[id] = SessionRuntime(
session: session,
toolLoopService: _toolLoopService,
hookRunner: _hookRunner,
getSettings: () => _settingsProvider.settings,
normalizeModelId: (m) => _settingsProvider.normalizeModelId(m),
onChanged: notifyListeners,
onChanged: _scheduleNotify,
onCostAdded: (_) {
final s = _sessions[id];
if (s != null) SessionStore.instance.saveSession(s);
},
isActive: () => _activeSessionId == id,
onNameGenerated: (sid, name) {
onSessionNameChanged?.call(sid, name);
notifyListeners();
},
onPersistAllowRule: (rule) => _settingsProvider.addAlwaysAllowRule(rule),
);
}
_activeSessionId = id;
_runtimes[id]?.markRead();
// sync global cost tracker to this thread's persisted cost
cost_tracker.resetCostState();
final sessionCost = (_sessions[id] ?? session).cost;
if (sessionCost > 0) {
cost_tracker.setCostStateForRestore(
totalCostUsd: sessionCost,
totalApiDurationMs: 0,
totalApiDurationWithoutRetriesMs: 0,
totalToolDurationMs: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
);
}
notifyListeners();
_costProvider.refreshCost();
}
// Fast-path: switch focus to an already-running runtime without touching disk.
@@ -164,8 +216,8 @@ class ChatProvider extends ChangeNotifier {
_active?.runCompact(customInstructions: customInstructions) ??
Future.value();
Future<void> resolvePermission(PermissionDecision decision) =>
_active?.resolvePermission(decision) ?? Future.value();
Future<void> resolvePermission(PermissionDecision decision, {String? persistRule}) =>
_active?.resolvePermission(decision, persistRule: persistRule) ?? Future.value();
void removeQueuedMessage(int index) => _active?.removeQueuedMessage(index);
-2
View File
@@ -12,8 +12,6 @@ class CostProvider extends ChangeNotifier {
String getFormattedTotalCost() => cost_tracker.formatTotalCost();
Future<void> refreshCost() async {
// read current values from cost tracker
final _ = cost_tracker.getTotalCostUsd();
notifyListeners();
}
}
+6 -1
View File
@@ -11,7 +11,9 @@ import "settings_provider.dart";
class HomeCoordinator extends ChangeNotifier {
HomeCoordinator(this._projects, this._session, this._chat, this._settings);
HomeCoordinator(this._projects, this._session, this._chat, this._settings) {
_chat.onSessionNameChanged = (_, __) => _session.refreshSessions();
}
final ProjectsProvider _projects;
final SessionProvider _session;
@@ -86,6 +88,7 @@ class HomeCoordinator extends ChangeNotifier {
// without reloading from disk — avoids disrupting an in-progress turn.
if (_chat.isSessionRunning(session.id)) {
_chat.activateSessionById(session.id);
_chat.markSessionRead(session.id);
_session.setActiveSessionId(session.id);
_projects.selectProjectByWorkingDirectory(session.workingDirectory);
_settings.setThreadModel(session.model);
@@ -96,6 +99,7 @@ class HomeCoordinator extends ChangeNotifier {
final loaded = _session.currentSession;
if (loaded != null) {
_chat.activateSession(loaded);
_chat.markSessionRead(session.id);
}
_projects.selectProjectByWorkingDirectory(_session.activeWorkingDirectory);
_settings.setThreadModel(_session.currentSession?.model);
@@ -120,6 +124,7 @@ class HomeCoordinator extends ChangeNotifier {
final newSession = _session.currentSession;
if (newSession != null) {
_chat.activateSession(newSession);
_chat.markSessionRead(newSession.id);
}
}
+16
View File
@@ -82,6 +82,22 @@ class SettingsProvider extends ChangeNotifier {
notifyListeners();
}
Future<void> updateAdvisorEffortLevel(String? level) async {
await _settingsStore.update(
(current) => current.copyWith(advisorEffortLevel: level),
);
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> updateAdvisorModel(String? model) async {
await _settingsStore.update(
(current) => current.copyWith(advisorModel: model),
);
_globalSettings = _settingsStore.settings;
notifyListeners();
}
Future<void> updateEffortLevel(String newLevel) async {
await _settingsStore.update(
(current) => current.copyWith(effortLevel: newLevel),
@@ -2,12 +2,24 @@ import "package:gpt_markdown/gpt_markdown.dart";
import "package:shadcn_flutter/shadcn_flutter.dart";
class AssistantBubble extends StatelessWidget {
const AssistantBubble({super.key, required this.content});
const AssistantBubble({super.key, required this.content, this.isStreaming = false});
final String content;
final bool isStreaming;
@override
Widget build(BuildContext context) {
if (isStreaming) {
return SelectableText(
content,
style: TextStyle(
color: Theme.of(context).colorScheme.foreground,
fontSize: 14,
),
);
}
return GptMarkdown(content);
}
}
@@ -1 +1 @@
export "../../../../src/permissions/permission_types.dart" show PermissionDecision;
export "../../../../src/permissions/permission_types.dart" show PermissionDecision, PendingPermission;

Some files were not shown because too many files have changed in this diff Show More