Add initial project files and configurations for clawd_code

This commit is contained in:
ImBenji
2026-04-03 17:48:07 +01:00
parent 7541a5279b
commit c88a1badc7
273 changed files with 28339 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
export 'src/app.dart' show runClawdCode;
+43
View File
@@ -0,0 +1,43 @@
import "package:flutter/material.dart";
import "package:provider/provider.dart";
import "src/local_state.dart";
import "src/project_store.dart";
import "ui/app.dart";
import "ui/providers/chat_provider.dart";
import "ui/providers/cost_provider.dart";
import "ui/providers/projects_provider.dart";
import "ui/providers/session_provider.dart";
import "ui/providers/settings_provider.dart";
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final settingsStore = await SettingsStore.load();
final projectStore = await ProjectStore.load();
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(
create: (_) => SettingsProvider(settingsStore),
),
ChangeNotifierProvider(
create: (_) => ProjectsProvider(projectStore),
),
ChangeNotifierProvider(
create: (_) => CostProvider(),
),
ChangeNotifierProvider(
create: (_) => SessionProvider(),
),
ChangeNotifierProvider(
create: (context) => ChatProvider(
context.read<SettingsProvider>(),
),
),
],
child: const ClawdApp(),
),
);
}
+101
View File
@@ -0,0 +1,101 @@
// Analytics service — queue events, flush to ~/.claude/analytics.jsonl
// Ported from old_repo/services/analytics/index.ts + sink.ts
// HTTP reporting is a TODO
import "dart:convert";
import "dart:io";
import "../local_state.dart";
import "../services/analytics_config.dart";
import "analytics_types.dart";
// events bufferd before flush
final List<AnalyticsEvent> _queue = [];
bool _initialized = false;
// path to where we flush events
String _analyticsFilePath() {
final home = Platform.environment["HOME"];
if (home == null || home.isEmpty) {
return joinPath(Directory.current.path, ".claude/analytics.jsonl");
}
return joinPath(home, ".claude/analytics.jsonl");
}
void initAnalytics() {
if (_initialized) return;
_initialized = true;
// drain any queued events from before init
if (_queue.isNotEmpty) {
_flushQueue();
}
}
// log a single analytics event
// if telemetry is off we drop it silently
void logAnalyticsEvent(String name, AnalyticsMetadata metadata) {
if (isAnalyticsDisabled()) return;
final evt = AnalyticsEvent(
name: name,
metadata: metadata,
timestamp: DateTime.now().toUtc(),
);
if (!_initialized) {
_queue.add(evt);
return;
}
_writeEvent(evt);
}
Future<void> logAnalyticsEventAsync(String name, AnalyticsMetadata metadata) async {
logAnalyticsEvent(name, metadata);
}
// flush the event queue to disk
void _flushQueue() {
final events = List<AnalyticsEvent>.from(_queue);
_queue.clear();
for (final evt in events) {
_writeEvent(evt);
}
}
void _writeEvent(AnalyticsEvent evt) {
try {
final path = _analyticsFilePath();
final dir = File(path).parent;
// make sure the dir exists
if (!dir.existsSync()) {
dir.createSync(recursive: true);
}
final line = jsonEncode(evt.toJson());
File(path).writeAsStringSync("$line\n", mode: FileMode.append);
} catch (_) {
// swallow — analytics should never crash the app
}
// TODO: HTTP reporting to upstream endpoint
}
// persist any bufferd events and flush to disk
// call this before exit
void flushAnalytics() {
if (_queue.isNotEmpty) {
_flushQueue();
}
}
+28
View File
@@ -0,0 +1,28 @@
// analytics event types
// ported from old_repo/services/analytics/index.ts
// metadata values — only bools/nums to avoid logging code or filepaths by accident
typedef AnalyticsMetadata = Map<String, Object?>;
enum AnalyticsEventKind { sync, async_ }
class AnalyticsEvent {
const AnalyticsEvent({
required this.name,
required this.metadata,
this.kind = AnalyticsEventKind.sync,
required this.timestamp,
});
final String name;
final AnalyticsMetadata metadata;
final AnalyticsEventKind kind;
final DateTime timestamp;
Map<String, dynamic> toJson() => {
"event": name,
"ts": timestamp.toUtc().toIso8601String(),
...metadata,
};
}
+361
View File
@@ -0,0 +1,361 @@
// Anthropic API client
// Ported from old_repo/services/api/client.ts
import "dart:async";
import "dart:convert";
import "dart:io";
import "../services/oauth_service.dart";
import "api_types.dart";
import "request_builder.dart";
import "response_parser.dart";
// Configuration for the Anthropic API client
class AnthropicClientConfig {
final String apiKey;
final String baseUrl;
final int maxRetries;
final String? model;
final String? source;
final bool enableLogging;
const AnthropicClientConfig({
required this.apiKey,
required this.baseUrl,
this.maxRetries = 2,
this.model,
this.source,
this.enableLogging = false,
});
}
// Main Anthropic API client
class AnthropicClient {
final AnthropicClientConfig _config;
late HttpClient _httpClient;
AnthropicClient({required AnthropicClientConfig config}) : _config = config {
_httpClient = HttpClient();
_httpClient.connectionTimeout = Duration(seconds: 600);
}
// Get API key from environment or config
String _getApiKey() {
if (_config.apiKey.isNotEmpty) {
return _config.apiKey;
}
final env = Platform.environment;
return env["ANTHROPIC_API_KEY"] ??
env["CLAUDE_API_KEY"] ??
env["CLAUDE_CODE_API_KEY"] ??
"";
}
// Get base URL from environment or config
String _getBaseUrl() {
if (_config.baseUrl.isNotEmpty) {
return _config.baseUrl;
}
final env = Platform.environment;
final override =
env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
if (override != null && override.isNotEmpty) {
return override;
}
return "https://api.anthropic.com";
}
// Build headers for API request
Map<String, String> _buildHeaders() {
final builder = HeaderBuilder();
// Add API key authentication
final apiKey = _getApiKey();
if (apiKey.isNotEmpty) {
builder.addAuthHeader(apiKey);
}
// Add custom headers from environment
builder.addCustomHeadersFromEnv();
return builder.build();
}
// Send a message to Claude
Future<ApiMessage> createMessage({
required String model,
required int maxTokens,
required List<Map<String, dynamic>> messages,
String? system,
double? temperature,
List<Map<String, dynamic>>? tools,
String? toolChoice,
}) async {
final requestBuilder = MessageRequestBuilder(
model: model,
maxTokens: maxTokens,
messages: messages,
);
if (system != null) {
requestBuilder.withSystem(system);
}
if (temperature != null) {
requestBuilder.withTemperature(temperature);
}
if (tools != null && tools.isNotEmpty) {
requestBuilder.withTools(tools);
if (toolChoice != null) {
requestBuilder.withToolChoice(toolChoice);
}
}
final request = requestBuilder.build();
return _makeRequest(
method: "POST",
endpoint: "/v1/messages",
body: request.toJson(),
).then((response) {
return ResponseParser.parseMessageResponse(response);
});
}
// List available models (API endpoint)
Future<List<String>> listModels() async {
final response = await _makeRequest(
method: "GET",
endpoint: "/v1/models",
);
// parse models from response
final models = <String>[];
if (response["data"] is List) {
for (final model in response["data"] as List) {
if (model is Map<String, dynamic> && model["id"] is String) {
models.add(model["id"] as String);
}
}
}
return models;
}
// Get a single model's details
Future<Map<String, dynamic>> getModel(String modelId) async {
return _makeRequest(
method: "GET",
endpoint: "/v1/models/$modelId",
);
}
// Count tokens for a message (beta API)
Future<int> countTokens({
required String model,
required List<Map<String, dynamic>> messages,
String? system,
}) async {
final body = <String, dynamic>{
"model": model,
"messages": messages,
};
if (system != null) {
body["system"] = system;
}
final response = await _makeRequest(
method: "POST",
endpoint: "/v1/messages/count_tokens",
body: body,
);
final count = response["input_tokens"];
return count is int ? count : 0;
}
// Internal: make HTTP request to API
Future<Map<String, dynamic>> _makeRequest({
required String method,
required String endpoint,
Map<String, dynamic>? body,
}) async {
final baseUrl = _getBaseUrl();
final url = Uri.parse("$baseUrl$endpoint");
final headers = _buildHeaders();
if (_config.enableLogging) {
_log("[API REQUEST] $method $endpoint");
}
try {
final request = await _httpClient.openUrl(method, url);
// Set headers
headers.forEach((key, value) {
request.headers.set(key, value);
});
// Add content type for JSON
request.headers.contentType = ContentType.json;
// Write body if present
if (body != null) {
request.write(jsonEncode(body));
}
final response = await request.close();
final responseBody = await response.transform(utf8.decoder).join();
if (_config.enableLogging) {
_log("[API RESPONSE] ${response.statusCode}");
}
// Check for errors
if (response.statusCode >= 400) {
_handleErrorResponse(response.statusCode, responseBody);
}
// Parse response
final decoded = jsonDecode(responseBody);
if (decoded is! Map<String, dynamic>) {
throw Exception("Invalid API response format");
}
return decoded;
} catch (e) {
if (_config.enableLogging) {
_log("[API ERROR] $e");
}
rethrow;
}
}
// Handle error responses
void _handleErrorResponse(int statusCode, String body) {
late String errorMessage;
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final error = ErrorParser.extractErrorMessage(decoded);
if (error != null) {
errorMessage = error;
} else {
errorMessage = "HTTP $statusCode";
}
} else {
errorMessage = "HTTP $statusCode";
}
} catch (_) {
errorMessage = "HTTP $statusCode";
}
if (statusCode == 401 || statusCode == 403) {
throw AuthenticationException(errorMessage);
} else if (statusCode == 429) {
throw RateLimitException(errorMessage);
} else if (statusCode == 413) {
throw RequestTooLargeException(errorMessage);
} else {
throw ApiException(errorMessage, statusCode);
}
}
// Internal logging
void _log(String message) {
// could wire this to real logging later
print("[AnthropicClient] $message");
}
// Cleanup
void close() {
_httpClient.close();
}
}
// Exception classes for API errors
class ApiException implements Exception {
final String message;
final int? statusCode;
ApiException(this.message, [this.statusCode]);
@override
String toString() => "ApiException: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}";
}
class AuthenticationException extends ApiException {
AuthenticationException(String message) : super(message, 401);
@override
String toString() => "AuthenticationException: $message";
}
class RateLimitException extends ApiException {
RateLimitException(String message) : super(message, 429);
@override
String toString() => "RateLimitException: $message";
}
class RequestTooLargeException extends ApiException {
RequestTooLargeException(String message) : super(message, 413);
@override
String toString() => "RequestTooLargeException: $message";
}
// Factory to create client from environment
class AnthropicClientFactory {
static Future<AnthropicClient> create({
String? apiKey,
String? baseUrl,
int maxRetries = 2,
String? model,
String? source,
bool enableLogging = false,
}) async {
// Try to get OAuth tokens if available
final tokens = await loadStoredTokens();
final resolvedApiKey = apiKey ?? _resolveApiKey();
if (resolvedApiKey.isEmpty && tokens == null) {
throw Exception("No API key found and no OAuth tokens available");
}
final config = AnthropicClientConfig(
apiKey: resolvedApiKey,
baseUrl: baseUrl ?? _resolveBaseUrl(),
maxRetries: maxRetries,
model: model,
source: source,
enableLogging: enableLogging,
);
return AnthropicClient(config: config);
}
static String _resolveApiKey() {
final env = Platform.environment;
return env["ANTHROPIC_API_KEY"] ??
env["CLAUDE_API_KEY"] ??
env["CLAUDE_CODE_API_KEY"] ??
"";
}
static String _resolveBaseUrl() {
final env = Platform.environment;
final override =
env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
if (override != null && override.isNotEmpty) {
return override;
}
return "https://api.anthropic.com";
}
}
+268
View File
@@ -0,0 +1,268 @@
import "dart:convert";
// API types ported from old_repo/services/api/claude.ts
// Represents structures returned by Anthropic Message API
enum StopReason { endTurn, maxTokens, stopSequence, toolUse }
enum ContentBlockType { text, toolUse, toolResult, document }
// Text content block from API response
class TextBlock {
final String type;
final String text;
const TextBlock({required this.type, required this.text});
factory TextBlock.fromJson(Map<String, dynamic> json) {
return TextBlock(
type: json["type"] as String,
text: json["text"] as String,
);
}
Map<String, dynamic> toJson() => {"type": type, "text": text};
}
// Tool use block from API response
class ToolUse {
final String id;
final String type;
final String name;
final Map<String, dynamic> input;
const ToolUse({
required this.id,
required this.type,
required this.name,
required this.input,
});
factory ToolUse.fromJson(Map<String, dynamic> json) {
return ToolUse(
id: json["id"] as String,
type: json["type"] as String,
name: json["name"] as String,
input: json["input"] as Map<String, dynamic>,
);
}
Map<String, dynamic> toJson() => {
"id": id,
"type": type,
"name": name,
"input": input,
};
}
// Tool result block (sent as input to API)
class ToolResult {
final String type;
final String toolUseId;
final String? content;
const ToolResult({required this.type, required this.toolUseId, this.content});
factory ToolResult.fromJson(Map<String, dynamic> json) {
return ToolResult(
type: json["type"] as String,
toolUseId: json["tool_use_id"] as String,
content: json["content"] as String?,
);
}
Map<String, dynamic> toJson() => {
"type": type,
"tool_use_id": toolUseId,
if (content != null) "content": content,
};
}
// Text content block (sent as input to API)
class TextContent {
final String type;
final String text;
const TextContent({required this.type, required this.text});
factory TextContent.fromJson(Map<String, dynamic> json) {
return TextContent(
type: json["type"] as String,
text: json["text"] as String,
);
}
Map<String, dynamic> toJson() => {"type": type, "text": text};
}
// Full API message response
// Works with both Anthropic and OpenAI/OpenRouter formats
class ApiMessage {
final String id;
final String type;
final String role;
final List<dynamic> content;
final String model;
final String? stopReason;
final Map<String, dynamic>? usage;
final int? inputTokens;
final int? outputTokens;
const ApiMessage({
required this.id,
required this.type,
required this.role,
required this.content,
required this.model,
this.stopReason,
this.usage,
this.inputTokens,
this.outputTokens,
});
factory ApiMessage.fromJson(Map<String, dynamic> json) {
int? extractInputTokens() {
final usage = json["usage"] as Map<String, dynamic>?;
return (usage?["input_tokens"] as num?)?.toInt() ??
(usage?["prompt_tokens"] as num?)?.toInt();
}
int? extractOutputTokens() {
final usage = json["usage"] as Map<String, dynamic>?;
return (usage?["output_tokens"] as num?)?.toInt() ??
(usage?["completion_tokens"] as num?)?.toInt();
}
return ApiMessage(
id: json["id"] as String,
type: json["type"] as String? ?? "message",
role: json["role"] as String? ?? "assistant",
content: json["content"] as List<dynamic>,
model: json["model"] as String,
stopReason:
json["stop_reason"] as String? ?? json["finish_reason"] as String?,
usage: json["usage"] as Map<String, dynamic>?,
inputTokens: extractInputTokens(),
outputTokens: extractOutputTokens(),
);
}
// Factory for parsing OpenRouter chat/completions response
factory ApiMessage.fromOpenRouterResponse(Map<String, dynamic> json) {
final choices = json["choices"] as List<dynamic>? ?? [];
if (choices.isEmpty) {
throw Exception("No choices in OpenRouter response");
}
final firstChoice = choices[0] as Map<String, dynamic>;
final message = firstChoice["message"] as Map<String, dynamic>?;
if (message == null) {
throw Exception("No message in choice");
}
final contentBlocks = <Map<String, dynamic>>[];
final content = message["content"];
if (content is String && content.isNotEmpty) {
contentBlocks.add(<String, dynamic>{"type": "text", "text": content});
}
final toolCalls = message["tool_calls"];
if (toolCalls is List) {
for (final toolCall in toolCalls) {
if (toolCall is! Map<String, dynamic>) {
continue;
}
final function = toolCall["function"];
if (function is! Map<String, dynamic>) {
continue;
}
final arguments = function["arguments"];
Map<String, dynamic> input = <String, dynamic>{};
if (arguments is String && arguments.isNotEmpty) {
try {
final decoded = jsonDecode(arguments);
if (decoded is Map<String, dynamic>) {
input = decoded;
}
} catch (_) {}
}
contentBlocks.add(<String, dynamic>{
"type": "tool_use",
"id": toolCall["id"] as String? ?? "",
"name": function["name"] as String? ?? "",
"input": input,
});
}
}
int? extractInputTokens() {
final usage = json["usage"] as Map<String, dynamic>?;
return (usage?["prompt_tokens"] as num?)?.toInt();
}
int? extractOutputTokens() {
final usage = json["usage"] as Map<String, dynamic>?;
return (usage?["completion_tokens"] as num?)?.toInt();
}
return ApiMessage(
id: json["id"] as String? ?? "",
type: "message",
role: "assistant",
content: contentBlocks,
model: json["model"] as String? ?? "",
stopReason: firstChoice["finish_reason"] == "tool_calls"
? "tool_use"
: firstChoice["finish_reason"] as String?,
usage: json["usage"] as Map<String, dynamic>?,
inputTokens: extractInputTokens(),
outputTokens: extractOutputTokens(),
);
}
Map<String, dynamic> toJson() => {
"id": id,
"type": type,
"role": role,
"content": content,
"model": model,
if (stopReason != null) "stop_reason": stopReason,
if (usage != null) "usage": usage,
};
}
// Message API request parameters
class MessageRequest {
final String model;
final int maxTokens;
final List<Map<String, dynamic>> messages;
final String? systemPrompt;
final double? temperature;
final List<Map<String, dynamic>>? tools;
final String? toolChoice;
final Map<String, dynamic>? metadata;
const MessageRequest({
required this.model,
required this.maxTokens,
required this.messages,
this.systemPrompt,
this.temperature,
this.tools,
this.toolChoice,
this.metadata,
});
Map<String, dynamic> toJson() => {
"model": model,
"max_tokens": maxTokens,
"messages": messages,
if (systemPrompt != null) "system": systemPrompt,
if (temperature != null) "temperature": temperature,
if (tools != null && tools!.isNotEmpty) "tools": tools,
if (toolChoice != null) "tool_choice": toolChoice,
if (metadata != null) "metadata": metadata,
};
}
+294
View File
@@ -0,0 +1,294 @@
// OpenRouter API client
// Uses OpenAI-compatible chat completion endpoint
import "dart:async";
import "dart:convert";
import "dart:io";
import "api_types.dart";
import "request_builder.dart";
import "response_parser.dart";
class OpenRouterConfig {
final String apiKey;
final int maxRetries;
final String? model;
final bool enableLogging;
const OpenRouterConfig({
required this.apiKey,
this.maxRetries = 2,
this.model,
this.enableLogging = false,
});
}
class OpenRouterClient {
final OpenRouterConfig _config;
late HttpClient _httpClient;
bool _requestCancelled = false;
static const String _baseUrl = "https://openrouter.ai/api/v1";
OpenRouterClient({required OpenRouterConfig config}) : _config = config {
_httpClient = HttpClient();
_httpClient.connectionTimeout = Duration(seconds: 600);
}
String _getApiKey() {
if (_config.apiKey.isNotEmpty) {
return _config.apiKey;
}
final env = Platform.environment;
return env["OPENROUTER_API_KEY"] ?? "";
}
Map<String, String> _buildHeaders() {
final builder = HeaderBuilder();
final apiKey = _getApiKey();
if (apiKey.isNotEmpty) {
builder.addAuthHeader(apiKey);
}
builder.addOpenRouterHeaders();
return builder.build();
}
// Send a message using OpenAI-compatible chat completion endpoint
Future<ApiMessage> createMessage({
required String model,
required int maxTokens,
required List<Map<String, dynamic>> messages,
String? system,
double? temperature,
List<Map<String, dynamic>>? tools,
String? toolChoice,
}) async {
final requestBody = <String, dynamic>{
"model": model,
"max_tokens": maxTokens,
"messages": messages,
};
if (system != null) {
// Add system message as first message if not already present
if (messages.isEmpty || messages.first["role"] != "system") {
requestBody["messages"] = [
{"role": "system", "content": system},
...messages,
];
}
}
if (temperature != null) {
requestBody["temperature"] = temperature;
}
if (tools != null && tools.isNotEmpty) {
requestBody["tools"] = tools;
if (toolChoice != null) {
requestBody["tool_choice"] = toolChoice;
}
}
final response = await _makeRequest(
method: "POST",
endpoint: "/chat/completions",
body: requestBody,
);
return ResponseParser.parseOpenRouterResponse(response);
}
// List available models
Future<List<Map<String, dynamic>>> listModels() async {
final response = await _makeRequest(method: "GET", endpoint: "/models");
final models = <Map<String, dynamic>>[];
if (response["data"] is List) {
for (final model in response["data"] as List) {
if (model is Map<String, dynamic>) {
models.add(model);
}
}
}
return models;
}
Future<Map<String, dynamic>> _makeRequest({
required String method,
required String endpoint,
Map<String, dynamic>? body,
}) async {
final url = Uri.parse("$_baseUrl$endpoint");
final headers = _buildHeaders();
if (_config.enableLogging) {
_log("[API REQUEST] $method $endpoint");
}
try {
if (_requestCancelled) {
throw const RequestCancelledException();
}
final request = await _httpClient.openUrl(method, url);
headers.forEach((key, value) {
request.headers.set(key, value);
});
request.headers.contentType = ContentType.json;
if (body != null) {
request.write(jsonEncode(body));
}
final response = await request.close();
final responseBody = await response.transform(utf8.decoder).join();
if (_config.enableLogging) {
_log("[API RESPONSE] ${response.statusCode}");
}
if (response.statusCode >= 400) {
print(
"OpenRouter API error ${response.statusCode} for $endpoint: $responseBody",
);
_handleErrorResponse(response.statusCode, responseBody);
}
final decoded = jsonDecode(responseBody);
if (decoded is! Map<String, dynamic>) {
throw Exception("Invalid API response format");
}
return decoded;
} catch (e) {
if (_requestCancelled) {
throw const RequestCancelledException();
}
if (_config.enableLogging) {
_log("[API ERROR] $e");
}
rethrow;
}
}
void _handleErrorResponse(int statusCode, String body) {
late String errorMessage;
try {
final decoded = jsonDecode(body);
if (decoded is Map<String, dynamic>) {
final error = ErrorParser.extractErrorMessage(decoded);
if (error != null) {
errorMessage = error;
} else {
errorMessage = "HTTP $statusCode";
}
} else {
errorMessage = "HTTP $statusCode";
}
} catch (error, stackTrace) {
print("Failed to parse OpenRouter error response: $error");
print(stackTrace);
errorMessage = "HTTP $statusCode";
}
if (statusCode == 401 || statusCode == 403) {
throw AuthenticationException(errorMessage);
} else if (statusCode == 429) {
throw RateLimitException(errorMessage);
} else if (statusCode == 413) {
throw RequestTooLargeException(errorMessage);
} else {
throw ApiException(errorMessage, statusCode);
}
}
void _log(String message) {
print("[OpenRouterClient] $message");
}
void cancelActiveRequest() {
_requestCancelled = true;
_httpClient.close(force: true);
}
void close() {
_httpClient.close();
}
}
class RequestCancelledException implements Exception {
const RequestCancelledException();
@override
String toString() => "RequestCancelledException: Request cancelled by user";
}
class ApiException implements Exception {
final String message;
final int? statusCode;
ApiException(this.message, [this.statusCode]);
@override
String toString() =>
"ApiException: $message${statusCode != null ? ' (HTTP $statusCode)' : ''}";
}
class AuthenticationException extends ApiException {
AuthenticationException(String message) : super(message, 401);
@override
String toString() => "AuthenticationException: $message";
}
class RateLimitException extends ApiException {
RateLimitException(String message) : super(message, 429);
@override
String toString() => "RateLimitException: $message";
}
class RequestTooLargeException extends ApiException {
RequestTooLargeException(String message) : super(message, 413);
@override
String toString() => "RequestTooLargeException: $message";
}
class OpenRouterClientFactory {
static Future<OpenRouterClient> create({
String? apiKey,
int maxRetries = 2,
String? model,
bool enableLogging = false,
}) async {
final resolvedApiKey = apiKey ?? _resolveApiKey();
if (resolvedApiKey.isEmpty) {
throw Exception("No OpenRouter API key found. Set it in settings.");
}
final config = OpenRouterConfig(
apiKey: resolvedApiKey,
maxRetries: maxRetries,
model: model,
enableLogging: enableLogging,
);
return OpenRouterClient(config: config);
}
static String _resolveApiKey() {
final env = Platform.environment;
return env["OPENROUTER_API_KEY"] ?? "";
}
}
+221
View File
@@ -0,0 +1,221 @@
// Request builder to construct Anthropic Message API requests
// Ported from old_repo/services/api/claude.ts
import "dart:io";
import "api_types.dart";
// builds a message api request with all the standard options
class MessageRequestBuilder {
final String model;
final int maxTokens;
final List<Map<String, dynamic>> messages;
String? _systemPrompt;
double? _temperature;
List<Map<String, dynamic>>? _tools;
String? _toolChoice;
Map<String, dynamic>? _metadata;
MessageRequestBuilder({
required this.model,
required this.maxTokens,
required this.messages,
});
MessageRequestBuilder withSystem(String system) {
_systemPrompt = system;
return this;
}
MessageRequestBuilder withTemperature(double temp) {
_temperature = temp;
return this;
}
MessageRequestBuilder withTools(List<Map<String, dynamic>> tools) {
_tools = tools;
return this;
}
MessageRequestBuilder withToolChoice(String choice) {
_toolChoice = choice;
return this;
}
MessageRequestBuilder withMetadata(Map<String, dynamic> metadata) {
_metadata = metadata;
return this;
}
MessageRequest build() {
return MessageRequest(
model: model,
maxTokens: maxTokens,
messages: messages,
systemPrompt: _systemPrompt,
temperature: _temperature,
tools: _tools,
toolChoice: _toolChoice,
metadata: _metadata,
);
}
}
// helpers to add headers for API requests
class HeaderBuilder {
final Map<String, String> _headers = {};
HeaderBuilder() {
_initializeDefaultHeaders();
}
void _initializeDefaultHeaders() {
// Add standard headers for API requests
final env = Platform.environment;
// Session tracking
if (env.containsKey("CLAUDE_CODE_SESSION_ID")) {
_headers["X-Claude-Code-Session-Id"] = env["CLAUDE_CODE_SESSION_ID"]!;
}
// Remote tracking (if in a container)
if (env.containsKey("CLAUDE_CODE_CONTAINER_ID")) {
_headers["x-claude-remote-container-id"] = env["CLAUDE_CODE_CONTAINER_ID"]!;
}
if (env.containsKey("CLAUDE_CODE_REMOTE_SESSION_ID")) {
_headers["x-claude-remote-session-id"] = env["CLAUDE_CODE_REMOTE_SESSION_ID"]!;
}
// App identifier
_headers["x-app"] = "cli";
// User agent from utils would go here (TODO when http client created)
_headers["User-Agent"] = "clawd_code/0.1.0";
}
void addCustomHeader(String name, String value) {
_headers[name] = value;
}
void addAuthHeader(String apiKey) {
_headers["Authorization"] = "Bearer $apiKey";
}
void addOpenRouterHeaders() {
_headers["HTTP-Referer"] = "clawd_code";
_headers["X-Title"] = "clawd_code";
}
// parse custom headers from env var (newline or semicolon separated)
void addCustomHeadersFromEnv() {
final env = Platform.environment;
final customHeadersEnv = env["ANTHROPIC_CUSTOM_HEADERS"];
if (customHeadersEnv == null || customHeadersEnv.isEmpty) return;
final headerStrings = customHeadersEnv.split(RegExp(r"\n|\r\n"));
for (final headerString in headerStrings) {
if (headerString.trim().isEmpty) continue;
// parse "Name: Value" format, split on first colon
final colonIdx = headerString.indexOf(":");
if (colonIdx == -1) continue;
final name = headerString.substring(0, colonIdx).trim();
final value = headerString.substring(colonIdx + 1).trim();
if (name.isNotEmpty) {
_headers[name] = value;
}
}
}
Map<String, String> build() {
return Map.unmodifiable(_headers);
}
}
// builds user and assistant message objects for the API
class MessageBuilder {
// create a user message
static Map<String, dynamic> createUserMessage(String content) {
return {
"role": "user",
"content": content,
};
}
// create a user message with mixed content (text + tool results)
static Map<String, dynamic> createUserMessageWithContent(
List<Map<String, dynamic>> contentBlocks,
) {
return {
"role": "user",
"content": contentBlocks,
};
}
// create assistant message with text content
static Map<String, dynamic> createAssistantMessage(String content) {
return {
"role": "assistant",
"content": [
{
"type": "text",
"text": content,
}
],
};
}
// create assistant message with tool use
static Map<String, dynamic> createAssistantMessageWithToolUse(
String toolId,
String toolName,
Map<String, dynamic> toolInput,
) {
return {
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": toolId,
"name": toolName,
"input": toolInput,
}
],
};
}
// add tool result to existing user message
static Map<String, dynamic> createToolResultContent(
String toolUseId,
String content,
) {
return {
"type": "tool_result",
"tool_use_id": toolUseId,
"content": content,
};
}
}
// normalize message content for sending to API
List<Map<String, dynamic>> normalizeMessagesForApi(
List<Map<String, dynamic>> messages,
) {
// basic validation and normalization
// in real implementation would handle various message formats
return messages;
}
// normalize content from api response
dynamic normalizeContentFromApi(dynamic content) {
if (content is! List) return content;
// ensure all blocks have proper types
return List.from(content);
}
+196
View File
@@ -0,0 +1,196 @@
// Response parser for Anthropic Message API responses
// Ported from old_repo/services/api/errors.ts and claude.ts
import "api_types.dart";
// Parse Message API response into ApiMessage model
class ResponseParser {
static ApiMessage parseMessageResponse(Map<String, dynamic> json) {
return ApiMessage.fromJson(json);
}
static ApiMessage parseOpenRouterResponse(Map<String, dynamic> json) {
return ApiMessage.fromOpenRouterResponse(json);
}
// extract text content from message
static String extractTextContent(ApiMessage message) {
final textBlocks = <String>[];
for (final block in message.content) {
if (block is Map<String, dynamic>) {
final type = block["type"];
if (type == "text") {
final text = block["text"];
if (text is String) {
textBlocks.add(text);
}
}
}
}
return textBlocks.join("\n");
}
// extract all tool use blocks from message
static List<ToolUse> extractToolUseBlocks(ApiMessage message) {
final tools = <ToolUse>[];
for (final block in message.content) {
if (block is Map<String, dynamic>) {
final type = block["type"];
if (type == "tool_use") {
tools.add(ToolUse.fromJson(block));
}
}
}
return tools;
}
// check if message is a tool use (or contains only tool use)
static bool hasToolUse(ApiMessage message) {
return message.content.any((block) {
return block is Map<String, dynamic> && block["type"] == "tool_use";
});
}
// check stop reason
static bool didStopOnToolUse(ApiMessage message) {
return message.stopReason == "tool_use";
}
static bool didStopOnMaxTokens(ApiMessage message) {
return message.stopReason == "max_tokens";
}
static bool didCompleteNormally(ApiMessage message) {
return message.stopReason == "end_turn";
}
}
// Parse error responses from the API
class ErrorParser {
// check if raw API error is authentication related
static bool isAuthenticationError(String errorMessage) {
final lower = errorMessage.toLowerCase();
return lower.contains("unauthorized") ||
lower.contains("authentication") ||
lower.contains("invalid api key") ||
lower.contains("missing authentication");
}
// check if error is rate limit related
static bool isRateLimitError(String errorMessage) {
final lower = errorMessage.toLowerCase();
return lower.contains("rate limit") ||
lower.contains("too many requests") ||
lower.contains("quota");
}
// check if error is related to prompt being too long
static bool isPromptTooLongError(String errorMessage) {
final lower = errorMessage.toLowerCase();
return lower.contains("prompt is too long") ||
lower.contains("context_length_exceeded");
}
// check if error is media/content related
static bool isMediaSizeError(String errorMessage) {
final lower = errorMessage.toLowerCase();
return (lower.contains("image exceeds") && lower.contains("maximum")) ||
(lower.contains("image dimensions exceed") &&
lower.contains("many-image")) ||
RegExp(
r"maximum of \d+ pdf pages",
caseSensitive: false,
).hasMatch(errorMessage);
}
// parse prompt too long error to extract token counts
static ({int? actualTokens, int? limitTokens}) parsePromptTooLongError(
String rawMessage,
) {
final match = RegExp(
r"prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)",
caseSensitive: false,
).firstMatch(rawMessage);
return (
actualTokens: match != null ? int.tryParse(match.group(1)!) : null,
limitTokens: match != null ? int.tryParse(match.group(2)!) : null,
);
}
// extract server error message from API response
static String? extractErrorMessage(Map<String, dynamic>? errorJson) {
if (errorJson == null) return null;
final nestedError = errorJson["error"];
if (nestedError is Map<String, dynamic>) {
final nestedMessage = nestedError["message"];
if (nestedMessage is String && nestedMessage.isNotEmpty) {
return nestedMessage;
}
}
// try common error message fields
return errorJson["message"] as String? ??
errorJson["error"] as String? ??
errorJson["detail"] as String?;
}
}
// Streaming response parser for handling streamed API responses
class StreamingResponseParser {
// parse a streamed event from newline-delimited JSON
static Map<String, dynamic>? parseStreamLine(String line) {
if (line.trim().isEmpty) return null;
try {
// handle SSE format (data: {...})
final data = line.startsWith("data: ") ? line.substring(6) : line;
// simple JSON parsing - in production would use json.decode
return _parseJson(data);
} catch (_) {
return null;
}
}
static Map<String, dynamic>? _parseJson(String jsonStr) {
// stubbed - would use dart:convert.jsonDecode in real impl
// for now just return null to indicate parsing would happen
return null;
}
// check if streamed event is a message delta (partial response)
static bool isMessageDelta(Map<String, dynamic>? event) {
if (event == null) return false;
final type = event["type"];
return type == "content_block_delta";
}
// check if streamed event marks message completion
static bool isMessageStop(Map<String, dynamic>? event) {
if (event == null) return false;
final type = event["type"];
return type == "message_stop";
}
// extract partial text from delta event
static String? extractDeltaText(Map<String, dynamic>? event) {
if (event == null) return null;
try {
final delta = event["delta"] as Map<String, dynamic>?;
if (delta == null) return null;
final type = delta["type"];
if (type == "text_delta") {
return delta["text"] as String?;
}
} catch (_) {}
return null;
}
}
+4048
View File
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
import "dart:async";
import "dart:convert";
import "dart:io";
import "bridge_protocol.dart";
// Client-side bridge connection to a running daemon/bridge server via
// Unix domain socket (or named pipe on windows, but we target unix here).
//
// The wire protocol is newline-delimited JSON.
//
// Usage:
// final client = BridgeClient(socketPath: "/tmp/clawd-bridge.sock");
// await client.connect();
// client.send({"type": "ping"});
// client.messages.listen((msg) { ... });
// await client.close();
class BridgeClient {
BridgeClient({required this.socketPath, this.verbose = false});
final String socketPath;
final bool verbose;
Socket? _socket;
final _controller = StreamController<Map<String, dynamic>>.broadcast();
final _splitter = LineSplitter();
bool _closed = false;
Stream<Map<String, dynamic>> get messages => _controller.stream;
bool get isConnected => _socket != null && !_closed;
Future<void> connect() async {
if (_socket != null) throw StateError("already connected");
final address = InternetAddress(socketPath, type: InternetAddressType.unix);
_socket = await Socket.connect(address, 0);
_socket!.cast<List<int>>().transform(utf8.decoder).listen(
(chunk) {
final lines = _splitter.feed(chunk);
for (final line in lines) {
final msg = decodeFrame(line);
if (msg != null) {
if (verbose) {
stderr.writeln("[bridge-client] recv: ${msg["type"]}");
}
_controller.add(msg);
}
}
},
onError: (Object err) {
if (!_controller.isClosed) _controller.addError(err);
},
onDone: () {
_closed = true;
if (!_controller.isClosed) _controller.close();
},
cancelOnError: false,
);
}
void send(Map<String, dynamic> msg) {
if (_socket == null || _closed) {
throw StateError("not connected");
}
if (verbose) {
stderr.writeln("[bridge-client] send: ${msg["type"]}");
}
_socket!.add(encodeFrame(msg));
}
Future<void> close() async {
_closed = true;
await _socket?.close();
_socket = null;
if (!_controller.isClosed) await _controller.close();
}
// ─── higher-level helpers ─────────────────────────────────────────────
/// Send a message and wait for the first response matching [predicate].
/// Throws [TimeoutException] if nothing matches within [timeout].
Future<Map<String, dynamic>> sendAndAwait(
Map<String, dynamic> msg,
bool Function(Map<String, dynamic>) predicate, {
Duration timeout = const Duration(seconds: 15),
}) async {
send(msg);
return messages
.where(predicate)
.first
.timeout(timeout, onTimeout: () {
throw TimeoutException(
"no matching response within ${timeout.inSeconds}s",
);
});
}
/// Sends a ping and waits for pong. Returns round-trip ms.
Future<int> ping() async {
final sw = Stopwatch()..start();
await sendAndAwait(
{"type": "ping"},
(m) => m["type"] == "pong",
);
return sw.elapsedMilliseconds;
}
}
// ─── connection factory ────────────────────────────────────────────────────
/// Try to connect to the bridge socket, return null if not available.
Future<BridgeClient?> tryConnectBridge(
String socketPath, {
bool verbose = false,
}) async {
try {
final client = BridgeClient(socketPath: socketPath, verbose: verbose);
await client.connect();
return client;
} catch (_) {
return null;
}
}
/// Resolve the default socket path for the local bridge daemon.
/// Uses CLAWD_BRIDGE_SOCKET env var if set, otherwise a temp-dir path
/// that embeds the current user.
String defaultBridgeSocketPath() {
final env = Platform.environment["CLAWD_BRIDGE_SOCKET"];
if (env != null && env.isNotEmpty) return env;
final home =
Platform.environment["HOME"] ??
Platform.environment["USERPROFILE"] ??
"/tmp";
return "$home/.claude/bridge.sock";
}
+165
View File
@@ -0,0 +1,165 @@
import "dart:convert";
import "dart:typed_data";
// Message framing: newline-delimited JSON (each message is one JSON object
// followed by a \n). This matches how the legacy sessionRunner parses stdout
// line-by-line.
//
// Frame format on wire:
// <json-object>\n
const int _newline = 10;
// ─── framing helpers ──────────────────────────────────────────────────────
/// Encode a message map to a framed bytes (JSON + \n).
Uint8List encodeFrame(Map<String, dynamic> msg) {
final s = jsonEncode(msg) + "\n";
return Uint8List.fromList(utf8.encode(s));
}
/// Decode a single line (without the trailing \n) into a message map.
/// Returns null on parse failure.
Map<String, dynamic>? decodeFrame(String line) {
final trimmed = line.trim();
if (trimmed.isEmpty) return null;
try {
final obj = jsonDecode(trimmed);
if (obj is Map<String, dynamic>) return obj;
return null;
} catch (_) {
return null;
}
}
// ─── specific message builders ────────────────────────────────────────────
Map<String, dynamic> buildControlResponseSuccess(
String requestId, {
Map<String, dynamic>? responseData,
}) {
final inner = <String, dynamic>{
"subtype": "success",
"request_id": requestId,
};
if (responseData != null) inner["response"] = responseData;
return {"type": "control_response", "response": inner};
}
Map<String, dynamic> buildControlResponseError(
String requestId,
String error,
) => {
"type": "control_response",
"response": {
"subtype": "error",
"request_id": requestId,
"error": error,
},
};
Map<String, dynamic> buildInitializeResponse(String requestId, int pid) =>
buildControlResponseSuccess(requestId, responseData: {
"commands": <dynamic>[],
"output_style": "normal",
"available_output_styles": ["normal"],
"models": <dynamic>[],
"account": <String, dynamic>{},
"pid": pid,
});
// build a minimal result message for session archival
Map<String, dynamic> buildResultMessage(String sessionId, String uuid) => {
"type": "result",
"subtype": "success",
"duration_ms": 0,
"duration_api_ms": 0,
"is_error": false,
"num_turns": 0,
"result": "",
"stop_reason": null,
"total_cost_usd": 0,
"usage": {},
"modelUsage": {},
"permission_denials": <dynamic>[],
"session_id": sessionId,
"uuid": uuid,
};
// ─── Line splitter (stateful accumulator) ────────────────────────────────
/// Accumulates bytes/strings and emits complete JSON lines.
class LineSplitter {
final _buf = StringBuffer();
/// Feed data and return any complete lines (without trailing \n).
List<String> feed(String chunk) {
final lines = <String>[];
for (final ch in chunk.split("")) {
if (ch == "\n") {
final line = _buf.toString();
_buf.clear();
if (line.isNotEmpty) lines.add(line);
} else {
_buf.write(ch);
}
}
return lines;
}
/// Returns any partial (incomplete) line still buffered.
String get pending => _buf.toString();
}
// ─── message routing helpers ──────────────────────────────────────────────
/// Handle inbound control request, returning a response map.
/// Returns null if the subtype is unknown (callers should send an error).
Map<String, dynamic>? handleControlRequest(
Map<String, dynamic> request, {
int? pid,
void Function()? onInterrupt,
void Function(String?)? onSetModel,
void Function(int?)? onSetMaxThinkingTokens,
}) {
final reqId = request["request_id"] as String? ?? "";
final inner = request["request"] as Map<String, dynamic>? ?? {};
final subtype = inner["subtype"] as String? ?? "";
switch (subtype) {
case "initialize":
return buildInitializeResponse(reqId, pid ?? 0);
case "interrupt":
onInterrupt?.call();
return buildControlResponseSuccess(reqId);
case "set_model":
onSetModel?.call(inner["model"] as String?);
return buildControlResponseSuccess(reqId);
case "set_max_thinking_tokens":
final tokens = inner["max_thinking_tokens"];
onSetMaxThinkingTokens?.call(tokens as int?);
return buildControlResponseSuccess(reqId);
default:
return buildControlResponseError(
reqId,
"bridge does not handle control_request subtype: $subtype",
);
}
}
// ─── serialization utils ─────────────────────────────────────────────────
String serializeMessage(Map<String, dynamic> msg) => jsonEncode(msg);
Map<String, dynamic>? deserializeMessage(String raw) {
try {
final obj = jsonDecode(raw);
if (obj is Map<String, dynamic>) return obj;
} catch (_) {}
return null;
}
+189
View File
@@ -0,0 +1,189 @@
import "dart:async";
import "dart:convert";
import "dart:io";
import "bridge_protocol.dart";
// Server/daemon side. Listens on a Unix socket, accepts connections,
// and dispatches inbound messages to registered handlers.
//
// Each connected peer is a BridgeConnection.
// The server emits BridgeEvent objects on the events stream.
// ─── events ───────────────────────────────────────────────────────────────
enum BridgeEventKind { connected, disconnected, message }
class BridgeEvent {
const BridgeEvent({
required this.kind,
required this.connection,
this.message,
});
final BridgeEventKind kind;
final BridgeConnection connection;
final Map<String, dynamic>? message;
@override
String toString() =>
"BridgeEvent(${kind.name}, conn=${connection.id}, msg=${message?["type"]})";
}
// ─── per-connection state ─────────────────────────────────────────────────
class BridgeConnection {
BridgeConnection._(this.id, this._socket);
final String id;
final Socket _socket;
final _splitter = LineSplitter();
bool _closed = false;
bool get isAlive => !_closed;
void send(Map<String, dynamic> msg) {
if (_closed) return;
_socket.add(encodeFrame(msg));
}
Future<void> close() async {
_closed = true;
await _socket.close();
}
@override
String toString() => "BridgeConnection($id)";
}
// ─── server ───────────────────────────────────────────────────────────────
class BridgeServer {
BridgeServer({
required this.socketPath,
this.verbose = false,
});
final String socketPath;
final bool verbose;
ServerSocket? _server;
final _connections = <String, BridgeConnection>{};
final _eventController = StreamController<BridgeEvent>.broadcast();
int _nextId = 1;
Stream<BridgeEvent> get events => _eventController.stream;
int get connectionCount => _connections.length;
bool get isRunning => _server != null;
Future<void> start() async {
// remove stale socket file if it exists
final file = File(socketPath);
if (file.existsSync()) file.deleteSync();
// ensure parent dir exists
await file.parent.create(recursive: true);
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
_server = await ServerSocket.bind(addr, 0);
if (verbose) stderr.writeln("[bridge-server] listening on $socketPath");
_server!.listen(
_handleIncoming,
onError: (Object err) {
if (!_eventController.isClosed) {
stderr.writeln("[bridge-server] server error: $err");
}
},
onDone: () {
if (verbose) stderr.writeln("[bridge-server] server socket closed");
},
);
}
void _handleIncoming(Socket socket) {
final id = "conn-${_nextId++}";
final conn = BridgeConnection._(id, socket);
_connections[id] = conn;
if (verbose) stderr.writeln("[bridge-server] new connection $id");
_emit(BridgeEvent(kind: BridgeEventKind.connected, connection: conn));
socket.cast<List<int>>().transform(utf8.decoder).listen(
(chunk) {
final lines = conn._splitter.feed(chunk);
for (final line in lines) {
final msg = decodeFrame(line);
if (msg == null) continue;
if (verbose) {
stderr.writeln("[bridge-server] recv from $id: ${msg["type"]}");
}
// built-in ping/pong
if (msg["type"] == "ping") {
conn.send({"type": "pong"});
continue;
}
_emit(BridgeEvent(
kind: BridgeEventKind.message,
connection: conn,
message: msg,
));
}
},
onError: (Object err) {
if (verbose) stderr.writeln("[bridge-server] error on $id: $err");
_removeConn(conn);
},
onDone: () {
if (verbose) stderr.writeln("[bridge-server] closed $id");
_removeConn(conn);
},
cancelOnError: false,
);
}
void _removeConn(BridgeConnection conn) {
_connections.remove(conn.id);
conn._closed = true;
_emit(BridgeEvent(kind: BridgeEventKind.disconnected, connection: conn));
}
void _emit(BridgeEvent ev) {
if (!_eventController.isClosed) _eventController.add(ev);
}
/// Broadcast a message to all connected peers.
void broadcast(Map<String, dynamic> msg) {
for (final c in _connections.values) {
c.send(msg);
}
}
/// Send to a specific connection by id.
void sendTo(String id, Map<String, dynamic> msg) {
_connections[id]?.send(msg);
}
Future<void> stop() async {
for (final c in _connections.values) {
await c.close();
}
_connections.clear();
await _server?.close();
_server = null;
if (!_eventController.isClosed) await _eventController.close();
// clean up socket file
try {
File(socketPath).deleteSync();
} catch (_) {}
if (verbose) stderr.writeln("[bridge-server] stopped");
}
}
+461
View File
@@ -0,0 +1,461 @@
import "dart:convert";
// protocol constants
const int kDefaultSessionTimeoutMs = 24 * 60 * 60 * 1000;
const String kBridgeLoginInstruction =
"Remote Control is only available with claude.ai subscriptions. "
"Please use /login to sign in with your claude.ai account.";
const String kBridgeLoginError =
"Error: You must be logged in to use Remote Control.\n\n"
"$kBridgeLoginInstruction";
const String kRemoteControlDisconnectedMsg = "Remote Control disconnected.";
// ─── spawn modes ────────────────────────────────────────────────────────────
enum SpawnMode {
singleSession,
worktree,
sameDir;
String toJson() {
switch (this) {
case SpawnMode.singleSession:
return "single-session";
case SpawnMode.worktree:
return "worktree";
case SpawnMode.sameDir:
return "same-dir";
}
}
static SpawnMode fromJson(String v) {
switch (v) {
case "single-session":
return SpawnMode.singleSession;
case "worktree":
return SpawnMode.worktree;
case "same-dir":
return SpawnMode.sameDir;
default:
return SpawnMode.singleSession;
}
}
}
// ─── session activity ─────────────────────────────────────────────────────
enum SessionActivityType { toolStart, text, result, error }
class SessionActivity {
const SessionActivity({
required this.type,
required this.summary,
required this.timestamp,
});
factory SessionActivity.fromJson(Map<String, dynamic> j) {
SessionActivityType t;
switch (j["type"] as String) {
case "tool_start":
t = SessionActivityType.toolStart;
break;
case "text":
t = SessionActivityType.text;
break;
case "result":
t = SessionActivityType.result;
break;
default:
t = SessionActivityType.error;
}
return SessionActivity(
type: t,
summary: j["summary"] as String,
timestamp: j["timestamp"] as int,
);
}
final SessionActivityType type;
final String summary;
final int timestamp;
Map<String, dynamic> toJson() => {
"type": type.name,
"summary": summary,
"timestamp": timestamp,
};
}
// ─── session done status ──────────────────────────────────────────────────
enum SessionDoneStatus { completed, failed, interrupted }
// ─── work data / work response (environments API) ─────────────────────────
class WorkData {
const WorkData({required this.type, required this.id});
factory WorkData.fromJson(Map<String, dynamic> j) {
return WorkData(type: j["type"] as String, id: j["id"] as String);
}
final String type; // 'session' | 'healthcheck'
final String id;
Map<String, dynamic> toJson() => {"type": type, "id": id};
}
class WorkResponse {
const WorkResponse({
required this.id,
required this.environmentId,
required this.state,
required this.data,
required this.secret,
required this.createdAt,
});
factory WorkResponse.fromJson(Map<String, dynamic> j) {
return WorkResponse(
id: j["id"] as String,
environmentId: j["environment_id"] as String,
state: j["state"] as String,
data: WorkData.fromJson(j["data"] as Map<String, dynamic>),
secret: j["secret"] as String,
createdAt: j["created_at"] as String,
);
}
final String id;
final String environmentId;
final String state;
final WorkData data;
final String secret;
final String createdAt;
Map<String, dynamic> toJson() => {
"id": id,
"environment_id": environmentId,
"state": state,
"data": data.toJson(),
"secret": secret,
"created_at": createdAt,
};
}
// ─── bridge config ─────────────────────────────────────────────────────────
class BridgeConfig {
const BridgeConfig({
required this.dir,
required this.machineName,
required this.branch,
required this.gitRepoUrl,
required this.maxSessions,
required this.spawnMode,
required this.verbose,
required this.sandbox,
required this.bridgeId,
required this.workerType,
required this.environmentId,
required this.apiBaseUrl,
required this.sessionIngressUrl,
this.reuseEnvironmentId,
this.debugFile,
this.sessionTimeoutMs,
});
factory BridgeConfig.fromJson(Map<String, dynamic> j) {
return BridgeConfig(
dir: j["dir"] as String,
machineName: j["machineName"] as String,
branch: j["branch"] as String,
gitRepoUrl: j["gitRepoUrl"] as String?,
maxSessions: j["maxSessions"] as int,
spawnMode: SpawnMode.fromJson(j["spawnMode"] as String),
verbose: j["verbose"] as bool,
sandbox: j["sandbox"] as bool,
bridgeId: j["bridgeId"] as String,
workerType: j["workerType"] as String,
environmentId: j["environmentId"] as String,
apiBaseUrl: j["apiBaseUrl"] as String,
sessionIngressUrl: j["sessionIngressUrl"] as String,
reuseEnvironmentId: j["reuseEnvironmentId"] as String?,
debugFile: j["debugFile"] as String?,
sessionTimeoutMs: j["sessionTimeoutMs"] as int?,
);
}
final String dir;
final String machineName;
final String branch;
final String? gitRepoUrl;
final int maxSessions;
final SpawnMode spawnMode;
final bool verbose;
final bool sandbox;
final String bridgeId;
final String workerType;
final String environmentId;
final String apiBaseUrl;
final String sessionIngressUrl;
final String? reuseEnvironmentId;
final String? debugFile;
final int? sessionTimeoutMs;
Map<String, dynamic> toJson() {
final m = <String, dynamic>{
"dir": dir,
"machineName": machineName,
"branch": branch,
"gitRepoUrl": gitRepoUrl,
"maxSessions": maxSessions,
"spawnMode": spawnMode.toJson(),
"verbose": verbose,
"sandbox": sandbox,
"bridgeId": bridgeId,
"workerType": workerType,
"environmentId": environmentId,
"apiBaseUrl": apiBaseUrl,
"sessionIngressUrl": sessionIngressUrl,
};
if (reuseEnvironmentId != null) m["reuseEnvironmentId"] = reuseEnvironmentId;
if (debugFile != null) m["debugFile"] = debugFile;
if (sessionTimeoutMs != null) m["sessionTimeoutMs"] = sessionTimeoutMs;
return m;
}
}
// ─── control request/response types ─────────────────────────────────────
enum ControlSubtype {
initialize,
interrupt,
setModel,
setMaxThinkingTokens,
setPermissionMode,
canUseTool,
unknown,
}
ControlSubtype controlSubtypeFromString(String s) {
switch (s) {
case "initialize":
return ControlSubtype.initialize;
case "interrupt":
return ControlSubtype.interrupt;
case "set_model":
return ControlSubtype.setModel;
case "set_max_thinking_tokens":
return ControlSubtype.setMaxThinkingTokens;
case "set_permission_mode":
return ControlSubtype.setPermissionMode;
case "can_use_tool":
return ControlSubtype.canUseTool;
default:
return ControlSubtype.unknown;
}
}
String controlSubtypeToString(ControlSubtype s) {
switch (s) {
case ControlSubtype.initialize:
return "initialize";
case ControlSubtype.interrupt:
return "interrupt";
case ControlSubtype.setModel:
return "set_model";
case ControlSubtype.setMaxThinkingTokens:
return "set_max_thinking_tokens";
case ControlSubtype.setPermissionMode:
return "set_permission_mode";
case ControlSubtype.canUseTool:
return "can_use_tool";
case ControlSubtype.unknown:
return "unknown";
}
}
class SdkControlRequest {
const SdkControlRequest({
required this.type,
required this.requestId,
required this.request,
});
factory SdkControlRequest.fromJson(Map<String, dynamic> j) {
return SdkControlRequest(
type: j["type"] as String,
requestId: j["request_id"] as String,
request: j["request"] as Map<String, dynamic>,
);
}
final String type; // always "control_request"
final String requestId;
final Map<String, dynamic> request;
ControlSubtype get subtype =>
controlSubtypeFromString(request["subtype"] as String? ?? "");
Map<String, dynamic> toJson() => {
"type": type,
"request_id": requestId,
"request": request,
};
}
class SdkControlResponse {
const SdkControlResponse({
required this.type,
required this.response,
});
factory SdkControlResponse.fromJson(Map<String, dynamic> j) {
return SdkControlResponse(
type: j["type"] as String,
response: j["response"] as Map<String, dynamic>,
);
}
final String type; // always "control_response"
final Map<String, dynamic> response;
String get subtype => response["subtype"] as String? ?? "";
String get requestId => response["request_id"] as String? ?? "";
Map<String, dynamic> toJson() => {
"type": type,
"response": response,
};
// factory helpers
static SdkControlResponse success(
String requestId, {
Map<String, dynamic>? responseData,
}) {
final inner = <String, dynamic>{
"subtype": "success",
"request_id": requestId,
};
if (responseData != null) inner["response"] = responseData;
return SdkControlResponse(type: "control_response", response: inner);
}
static SdkControlResponse error(String requestId, String errorMsg) {
return SdkControlResponse(
type: "control_response",
response: {
"subtype": "error",
"request_id": requestId,
"error": errorMsg,
},
);
}
}
// ─── SDK messages (discriminated on 'type') ──────────────────────────────
class SdkMessage {
const SdkMessage({
required this.type,
required this.raw,
});
factory SdkMessage.fromJson(Map<String, dynamic> j) {
return SdkMessage(
type: j["type"] as String? ?? "",
raw: j,
);
}
final String type;
final Map<String, dynamic> raw;
String? get uuid => raw["uuid"] as String?;
Map<String, dynamic> toJson() => raw;
}
// ─── bounded uuid set (echo dedup ring buffer) ────────────────────────────
class BoundedUuidSet {
BoundedUuidSet(this._capacity) : _ring = List.filled(_capacity, null);
final int _capacity;
final List<String?> _ring;
final _set = <String>{};
int _writeIdx = 0;
void add(String uuid) {
if (_set.contains(uuid)) return;
final evicted = _ring[_writeIdx];
if (evicted != null) _set.remove(evicted);
_ring[_writeIdx] = uuid;
_set.add(uuid);
_writeIdx = (_writeIdx + 1) % _capacity;
}
bool has(String uuid) => _set.contains(uuid);
void clear() {
_set.clear();
_ring.fillRange(0, _capacity, null);
_writeIdx = 0;
}
}
// ─── permission request ───────────────────────────────────────────────────
class PermissionRequest {
const PermissionRequest({
required this.requestId,
required this.toolName,
required this.input,
required this.toolUseId,
});
factory PermissionRequest.fromJson(Map<String, dynamic> j) {
final req = j["request"] as Map<String, dynamic>;
return PermissionRequest(
requestId: j["request_id"] as String,
toolName: req["tool_name"] as String,
input: (req["input"] as Map?)?.cast<String, dynamic>() ?? {},
toolUseId: req["tool_use_id"] as String,
);
}
final String requestId;
final String toolName;
final Map<String, dynamic> input;
final String toolUseId;
Map<String, dynamic> toJson() => {
"type": "control_request",
"request_id": requestId,
"request": {
"subtype": "can_use_tool",
"tool_name": toolName,
"input": input,
"tool_use_id": toolUseId,
},
};
}
// little helper
bool isControlRequest(Map<String, dynamic> m) =>
m["type"] == "control_request" &&
m.containsKey("request_id") &&
m.containsKey("request");
bool isControlResponse(Map<String, dynamic> m) =>
m["type"] == "control_response" && m.containsKey("response");
bool isSdkMessage(Map<String, dynamic> m) =>
m.containsKey("type") && m["type"] is String;
// ignore: unused_element
String _jsonEncode(Object? o) => jsonEncode(o);
+14
View File
@@ -0,0 +1,14 @@
abstract final class BuildInfo {
static const packageName = 'clawd_code';
static const version =
String.fromEnvironment('CLAWD_CODE_VERSION', defaultValue: '0.1.0');
static const buildTime = String.fromEnvironment('CLAWD_CODE_BUILD_TIME');
static String get versionDisplay {
if (buildTime.isEmpty) {
return version;
}
return '$version (built $buildTime)';
}
}
+379
View File
@@ -0,0 +1,379 @@
import "dart:convert";
import "package:path/path.dart" as path;
import "../api/api_types.dart";
import "../api/openrouter_client.dart";
import "../api/response_parser.dart";
import "../system_prompt/system_prompt_builder.dart";
import "../tools/tool_registry.dart";
class ToolLoopResult {
const ToolLoopResult({
required this.apiMessages,
required this.responseText,
required this.response,
});
final List<Map<String, dynamic>> apiMessages;
final String responseText;
final ApiMessage response;
}
class ToolLoopException implements Exception {
const ToolLoopException({
required this.cause,
required this.stackTrace,
required this.apiMessages,
});
final Object cause;
final StackTrace stackTrace;
final List<Map<String, dynamic>> apiMessages;
@override
String toString() => cause.toString();
}
class ToolLoopService {
ToolLoopService() : _toolRegistry = ToolRegistry();
final ToolRegistry _toolRegistry;
Future<ToolLoopResult> runTurn({
required OpenRouterClient client,
required String model,
required List<Map<String, dynamic>> apiMessages,
required String userText,
String? workingDirectory,
void Function(String toolName, Map<String, dynamic> input)? onToolCall,
void Function(String toolName, String result)? onToolResult,
}) async {
final updatedMessages = List<Map<String, dynamic>>.from(apiMessages)
..add(<String, dynamic>{"role": "user", "content": userText});
late ApiMessage lastResponse;
try {
while (true) {
lastResponse = await client.createMessage(
model: model,
maxTokens: 4096,
messages: updatedMessages,
system: _buildSystemPrompt(workingDirectory),
tools: _buildToolDefinitions(),
toolChoice: "auto",
);
updatedMessages.add(_assistantMessageForApi(lastResponse));
final toolUses = ResponseParser.extractToolUseBlocks(lastResponse);
if (toolUses.isEmpty) {
final responseText = ResponseParser.extractTextContent(
lastResponse,
).trim();
return ToolLoopResult(
apiMessages: updatedMessages,
responseText: responseText.isEmpty
? _buildEmptyAssistantFallback(lastResponse)
: responseText,
response: lastResponse,
);
}
for (final toolUse in toolUses) {
final normalizedInput = _normalizeToolInput(
toolName: toolUse.name,
input: toolUse.input,
workingDirectory: workingDirectory,
);
onToolCall?.call(toolUse.name, normalizedInput);
final toolResult = await _executeTool(
toolUse: toolUse,
normalizedInput: normalizedInput,
);
onToolResult?.call(toolUse.name, toolResult);
updatedMessages.add(<String, dynamic>{
"role": "tool",
"tool_call_id": toolUse.id,
"content": toolResult,
});
}
}
} catch (error, stackTrace) {
if (error is RequestCancelledException) {
rethrow;
}
if (error is ToolLoopException) {
rethrow;
}
throw ToolLoopException(
cause: error,
stackTrace: stackTrace,
apiMessages: List<Map<String, dynamic>>.from(updatedMessages),
);
}
}
Future<String> _executeTool({
required ToolUse toolUse,
required Map<String, dynamic> normalizedInput,
}) async {
print(
"Executing tool ${toolUse.name} with input: ${jsonEncode(normalizedInput)}",
);
try {
final result = await _toolRegistry.execute(toolUse.name, normalizedInput);
print("Tool ${toolUse.name} completed");
return result;
} catch (error, stackTrace) {
print("Tool ${toolUse.name} failed: $error");
print(stackTrace);
return "Error executing ${toolUse.name}: $error";
}
}
Map<String, dynamic> _normalizeToolInput({
required String toolName,
required Map<String, dynamic> input,
String? workingDirectory,
}) {
final normalized = Map<String, dynamic>.from(input);
final cwd = workingDirectory?.trim();
if (cwd == null || cwd.isEmpty) {
return normalized;
}
switch (toolName) {
case "Bash":
normalized["cwd"] = cwd;
break;
case "Read":
case "Edit":
case "Write":
final rawPath = normalized["file_path"];
if (rawPath is String && rawPath.isNotEmpty) {
normalized["file_path"] = _resolvePath(rawPath, cwd);
}
break;
case "Glob":
case "Grep":
final rawPath = normalized["path"];
if (rawPath is String && rawPath.isNotEmpty) {
normalized["path"] = _resolvePath(rawPath, cwd);
} else {
normalized["path"] = cwd;
}
break;
}
return normalized;
}
String _resolvePath(String rawPath, String cwd) {
if (path.isAbsolute(rawPath)) {
return path.normalize(rawPath);
}
return path.normalize(path.join(cwd, rawPath));
}
Map<String, dynamic> _assistantMessageForApi(ApiMessage response) {
final toolCalls = <Map<String, dynamic>>[];
final textParts = <String>[];
for (final block in response.content) {
if (block is! Map<String, dynamic>) {
continue;
}
final type = block["type"];
if (type == "text") {
final text = block["text"];
if (text is String && text.isNotEmpty) {
textParts.add(text);
}
} else if (type == "tool_use") {
toolCalls.add(<String, dynamic>{
"id": block["id"],
"type": "function",
"function": <String, dynamic>{
"name": block["name"],
"arguments": jsonEncode(block["input"] ?? <String, dynamic>{}),
},
});
}
}
final message = <String, dynamic>{"role": "assistant"};
message["content"] = textParts.join("\n");
if (toolCalls.isNotEmpty) {
message["tool_calls"] = toolCalls;
}
return message;
}
List<Map<String, dynamic>> _buildToolDefinitions() {
return <Map<String, dynamic>>[
_functionTool(
name: "Bash",
description:
"Execute a shell command in the selected project directory.",
properties: <String, dynamic>{
"command": <String, dynamic>{
"type": "string",
"description": "The shell command to run.",
},
"timeout": <String, dynamic>{
"type": "integer",
"description": "Optional timeout in milliseconds.",
},
},
required: const <String>["command"],
),
_functionTool(
name: "Glob",
description: "Find files matching a glob pattern in the project.",
properties: <String, dynamic>{
"pattern": <String, dynamic>{
"type": "string",
"description": "Glob pattern such as **/*.dart.",
},
"path": <String, dynamic>{
"type": "string",
"description": "Optional directory to search from.",
},
},
required: const <String>["pattern"],
),
_functionTool(
name: "Grep",
description: "Search project files using a regex pattern.",
properties: <String, dynamic>{
"pattern": <String, dynamic>{
"type": "string",
"description": "Regex pattern to search for.",
},
"path": <String, dynamic>{
"type": "string",
"description": "Optional file or directory path to search.",
},
"glob": <String, dynamic>{
"type": "string",
"description": "Optional glob filter such as **/*.dart.",
},
"output_mode": <String, dynamic>{
"type": "string",
"enum": <String>["files_with_matches", "content", "count"],
},
},
required: const <String>["pattern"],
),
_functionTool(
name: "Read",
description: "Read a file from the project with line numbers.",
properties: <String, dynamic>{
"file_path": <String, dynamic>{
"type": "string",
"description": "Path to the file to read.",
},
"offset": <String, dynamic>{
"type": "integer",
"description": "Optional starting line offset.",
},
"limit": <String, dynamic>{
"type": "integer",
"description": "Optional maximum number of lines to read.",
},
},
required: const <String>["file_path"],
),
_functionTool(
name: "Edit",
description: "Edit a file by replacing exact text.",
properties: <String, dynamic>{
"file_path": <String, dynamic>{
"type": "string",
"description": "Path to the file to edit.",
},
"old_string": <String, dynamic>{
"type": "string",
"description": "Text to replace.",
},
"new_string": <String, dynamic>{
"type": "string",
"description": "Replacement text.",
},
"replace_all": <String, dynamic>{
"type": "boolean",
"description": "Replace every occurrence when true.",
},
},
required: const <String>["file_path", "old_string", "new_string"],
),
_functionTool(
name: "Write",
description: "Write a file in the project.",
properties: <String, dynamic>{
"file_path": <String, dynamic>{
"type": "string",
"description": "Path to the file to write.",
},
"content": <String, dynamic>{
"type": "string",
"description": "Full file contents to write.",
},
},
required: const <String>["file_path", "content"],
),
];
}
Map<String, dynamic> _functionTool({
required String name,
required String description,
required Map<String, dynamic> properties,
required List<String> required,
}) {
return <String, dynamic>{
"type": "function",
"function": <String, dynamic>{
"name": name,
"description": description,
"parameters": <String, dynamic>{
"type": "object",
"properties": properties,
"required": required,
"additionalProperties": true,
},
},
};
}
String _buildSystemPrompt(String? workingDirectory) {
final cwd = workingDirectory?.trim();
final appendPrompt = [
if (cwd == null || cwd.isEmpty)
"No working directory is currently selected."
else
"The active working directory is: $cwd",
"You have access to tools for shell commands, file globbing, grep search, file reads, exact edits, and file writes.",
"When the user asks about files, code, project structure, configuration, or repository contents, use the tools instead of guessing.",
"If the user asks you to inspect the project structure, start by using Glob or Bash to inspect the filesystem.",
"Do not claim you cannot access the project when tools are available.",
"Keep answers concise and grounded in tool results.",
].join("\n");
return buildDefaultSystemPrompt(appendSystemPrompt: appendPrompt);
}
String _buildEmptyAssistantFallback(ApiMessage response) {
if (response.stopReason == "tool_use") {
return "The model requested more tool work but did not provide a final answer.";
}
return "The model completed the turn without returning visible text.";
}
}
+176
View File
@@ -0,0 +1,176 @@
import 'dart:io';
import 'local_state.dart';
import 'runtime_state.dart';
enum CommandKind { local, localJsx, prompt, reservedEntryPoint }
enum InvocationSurface { slash, topLevel, both }
extension InvocationSurfaceMatcher on InvocationSurface {
bool supports(InvocationSurface requested) {
return this == InvocationSurface.both || this == requested;
}
String get label {
switch (this) {
case InvocationSurface.slash:
return 'slash';
case InvocationSurface.topLevel:
return 'top-level';
case InvocationSurface.both:
return 'both';
}
}
}
class LegacyCommandDescriptor {
const LegacyCommandDescriptor({
required this.name,
required this.legacySourcePath,
this.aliases = const [],
this.description,
this.kind = CommandKind.localJsx,
this.surface = InvocationSurface.slash,
this.isInferred = false,
});
final List<String> aliases;
final String? description;
final bool isInferred;
final CommandKind kind;
final String legacySourcePath;
final String name;
final InvocationSurface surface;
bool matches(String token, InvocationSurface requestedSurface) {
if (!surface.supports(requestedSurface)) {
return false;
}
return token == name || aliases.contains(token);
}
}
typedef CommandHandler =
Future<CommandResult> Function(CommandContext context, List<String> args);
class CommandSpec extends LegacyCommandDescriptor {
const CommandSpec({
required super.name,
required super.legacySourcePath,
required this.handler,
super.aliases = const [],
super.description,
super.kind = CommandKind.localJsx,
super.surface = InvocationSurface.both,
});
final CommandHandler handler;
}
class CommandResult {
const CommandResult({this.exitCode = 0, this.exitRepl = false});
final int exitCode;
final bool exitRepl;
}
class CommandContext {
CommandContext({
required this.catalog,
required this.interactive,
required this.out,
required this.err,
required this.surface,
required this.settingsStore,
required this.runtimeStateStore,
required this.sessionState,
required this.workingDirectory,
});
final CommandCatalog catalog;
final IOSink err;
final bool interactive;
final IOSink out;
final RuntimeStateStore runtimeStateStore;
final SettingsStore settingsStore;
final SessionState sessionState;
final InvocationSurface surface;
final String workingDirectory;
void writeError(String message) {
err.writeln(message);
}
void writeLine(String message) {
out.writeln(message);
}
}
class CommandCatalog {
CommandCatalog({
required List<LegacyCommandDescriptor> legacyCommands,
required List<CommandSpec> portedCommands,
required List<LegacyCommandDescriptor> reservedTopLevelEntryPoints,
}) : legacyCommands = List.unmodifiable(legacyCommands),
portedCommands = List.unmodifiable(portedCommands),
reservedTopLevelEntryPoints = List.unmodifiable(
reservedTopLevelEntryPoints,
),
_portedNameSet = portedCommands.map((command) => command.name).toSet();
final List<LegacyCommandDescriptor> legacyCommands;
final List<CommandSpec> portedCommands;
final List<LegacyCommandDescriptor> reservedTopLevelEntryPoints;
final Set<String> _portedNameSet;
CommandSpec? findPorted(String token, InvocationSurface surface) {
for (final command in portedCommands) {
if (command.matches(token, surface)) {
return command;
}
}
return null;
}
LegacyCommandDescriptor? findLegacy(String token, InvocationSurface surface) {
for (final command in legacyCommands) {
if (command.matches(token, surface)) {
return command;
}
}
return null;
}
LegacyCommandDescriptor? findReservedTopLevel(String token) {
for (final entryPoint in reservedTopLevelEntryPoints) {
if (entryPoint.matches(token, InvocationSurface.topLevel)) {
return entryPoint;
}
}
return null;
}
List<LegacyCommandDescriptor> get unportedSlashCommands {
return legacyCommands
.where(
(command) =>
command.surface.supports(InvocationSurface.slash) &&
!_portedNameSet.contains(command.name),
)
.toList(growable: false);
}
int get totalKnownSlashCommands {
return legacyCommands
.where((command) => command.surface.supports(InvocationSurface.slash))
.length;
}
int get totalReservedTopLevelEntryPoints =>
reservedTopLevelEntryPoints.length;
}
+27
View File
@@ -0,0 +1,27 @@
// API limits — keep this file dep-free to avoid circular imports
// Last verified: 2025-12-22
// image limits
const int apiImageMaxBase64Size = 5 * 1024 * 1024; // 5 MB
const int imageTargetRawSize = (apiImageMaxBase64Size * 3) ~/ 4; // 3.75 MB
const int imageMaxWidth = 2000;
const int imageMaxHeight = 2000;
// pdf limits
const int pdfTargetRawSize = 20 * 1024 * 1024; // 20 MB
const int apiPdfMaxPages = 100;
const int pdfExtractSizeThreshold = 3 * 1024 * 1024; // 3 MB
const int pdfMaxExtractSize = 100 * 1024 * 1024; // 100 MB
const int pdfMaxPagesPerRead = 20;
const int pdfAtMentionInlineThreshold = 10;
// media limits
const int apiMaxMediaPerRequest = 100;
+34
View File
@@ -0,0 +1,34 @@
// API beta header constants
const String claudeCode20250219BetaHeader = "claude-code-20250219";
const String interleavedThinkingBetaHeader = "interleaved-thinking-2025-05-14";
const String context1mBetaHeader = "context-1m-2025-08-07";
const String contextManagementBetaHeader = "context-management-2025-06-27";
const String structuredOutputsBetaHeader = "structured-outputs-2025-12-15";
const String webSearchBetaHeader = "web-search-2025-03-05";
const String toolSearchBetaHeader1p = "advanced-tool-use-2025-11-20";
const String toolSearchBetaHeader3p = "tool-search-tool-2025-10-19";
const String effortBetaHeader = "effort-2025-11-24";
const String taskBudgetsBetaHeader = "task-budgets-2026-03-13";
const String promptCachingScopeBetaHeader = "prompt-caching-scope-2026-01-05";
const String fastModeBetaHeader = "fast-mode-2026-02-01";
const String redactThinkingBetaHeader = "redact-thinking-2026-02-12";
const String tokenEfficientToolsBetaHeader = "token-efficient-tools-2026-03-28";
const String advisorBetaHeader = "advisor-tool-2026-03-01";
// Betas that go in Bedrock extraBodyParams instead of headers
const Set<String> bedrockExtraParamsHeaders = {
interleavedThinkingBetaHeader,
context1mBetaHeader,
toolSearchBetaHeader3p,
};
// Betas allowed on Vertex countTokens API
const Set<String> vertexCountTokensAllowedBetas = {
claudeCode20250219BetaHeader,
interleavedThinkingBetaHeader,
contextManagementBetaHeader,
};
+40
View File
@@ -0,0 +1,40 @@
import "dart:io";
// Returns the local date in ISO format (YYYY-MM-DD)
// Respects CLAUDE_CODE_OVERRIDE_DATE env variable for testing
String getLocalIsoDate() {
final override = Platform.environment["CLAUDE_CODE_OVERRIDE_DATE"];
if (override != null && override.isNotEmpty) {
return override;
}
final now = DateTime.now();
final month = now.month.toString().padLeft(2, "0");
final day = now.day.toString().padLeft(2, "0");
return "${now.year}-$month-$day";
}
// cached at session start for prompt-cache stability
String? _sessionStartDate;
String getSessionStartDate() {
_sessionStartDate ??= getLocalIsoDate();
return _sessionStartDate!;
}
// Returns "Month YYYY" in the local timezone (e.g. "February 2026")
// Changes monthly — used in tool prompts to minimize cache busting
String getLocalMonthYear() {
final override = Platform.environment["CLAUDE_CODE_OVERRIDE_DATE"];
final date = override != null && override.isNotEmpty
? DateTime.parse(override)
: DateTime.now();
const months = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
];
return "${months[date.month - 1]} ${date.year}";
}
+9
View File
@@ -0,0 +1,9 @@
// Error IDs for tracking in production
// These obfuscated IDs help trace which logError() call generated an error.
//
// ADDING A NEW ERROR:
// 1. Add const based on Next ID
// 2. Increment Next ID
// Next ID: 346
const int eToolUseSummaryGenerationFailed = 344;
+42
View File
@@ -0,0 +1,42 @@
// UI glyphs and unicode symbols used throughout the app
const String blackCircle = "";
const String bulletOperator = "";
const String teardropAsterisk = "";
const String upArrow = "\u2191";
const String downArrow = "\u2193";
const String lightningBolt = "";
const String effortLow = "";
const String effortMedium = "";
const String effortHigh = "";
const String effortMax = "";
const String playIcon = "\u25b6";
const String pauseIcon = "\u23f8";
const String refreshArrow = "\u21bb";
const String channelArrow = "\u2190";
const String injectedArrow = "\u2192";
const String forkGlyph = "\u2442";
// review status indicators
const String diamondOpen = "\u25c7";
const String diamondFilled = "\u25c6";
const String referenceMark = "\u203b";
const String flagIcon = "\u2691";
const String blockquoteBar = "\u258e";
const String heavyHorizontal = "\u2501";
const List<String> bridgeSpinnerFrames = [
"\u00b7|\u00b7",
"\u00b7/\u00b7",
"\u00b7\u2014\u00b7",
"\u00b7\\\u00b7",
];
const String bridgeReadyIndicator = "\u00b7\u2714\ufe0e\u00b7";
const String bridgeFailedIndicator = "\u00d7";
const String noContentMessage = "(no content)";
+62
View File
@@ -0,0 +1,62 @@
// Binary file extensions to skip for text-based operations
// Ported from old_repo/constants/files.ts
const Set<String> binaryExtensions = {
// images
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico",
".webp", ".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 / binaries
".exe", ".dll", ".so", ".dylib", ".bin", ".o",
".a", ".obj", ".lib", ".app", ".msi", ".deb", ".rpm",
// documents (PDF excluded from text tools at the call site)
".pdf", ".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",
// lock / profiling
".lockb", ".dat", ".data",
};
bool hasBinaryExtension(String filePath) {
final dot = filePath.lastIndexOf(".");
if (dot < 0) return false;
final ext = filePath.substring(dot).toLowerCase();
return binaryExtensions.contains(ext);
}
// how many bytes we inspect for binary content detection
const int _binaryCheckSize = 8192;
bool isBinaryContent(List<int> bytes) {
final checkSize = bytes.length < _binaryCheckSize ? bytes.length : _binaryCheckSize;
int nonPrintable = 0;
for (int i = 0; i < checkSize; i++) {
final b = bytes[i];
if (b == 0) return true; // null byte = definately binary
if (b < 32 && b != 9 && b != 10 && b != 13) {
nonPrintable++;
}
}
return nonPrintable / checkSize > 0.1;
}
+158
View File
@@ -0,0 +1,158 @@
// OAuth configuration constants
// Ported from old_repo/constants/oauth.ts
import "dart:io";
const String claudeAiInferenceScope = "user:inference";
const String claudeAiProfileScope = "user:profile";
const String _consoleScope = "org:create_api_key";
const String oauthBetaHeader = "oauth-2025-04-20";
const String mcpClientMetadataUrl =
"https://claude.ai/oauth/claude-code-client-metadata";
// Console OAuth scopes — for API key creation
const List<String> consoleOauthScopes = [_consoleScope, claudeAiProfileScope];
// Claude.ai OAuth scopes — for Pro/Max/Team/Enterprise subscribers
const List<String> claudeAiOauthScopes = [
claudeAiProfileScope,
claudeAiInferenceScope,
"user:sessions:claude_code",
"user:mcp_servers",
"user:file_upload",
];
// union of all scopes
final List<String> allOauthScopes = List.unmodifiable(
{...consoleOauthScopes, ...claudeAiOauthScopes}.toList(),
);
enum OauthConfigType { prod, staging, local }
class OauthConfig {
final String baseApiUrl;
final String consoleAuthorizeUrl;
final String claudeAiAuthorizeUrl;
final String claudeAiOrigin;
final String tokenUrl;
final String apiKeyUrl;
final String rolesUrl;
final String consoleSuccessUrl;
final String claudeAiSuccessUrl;
final String manualRedirectUrl;
final String clientId;
final String oauthFileSuffix;
final String mcpProxyUrl;
final String mcpProxyPath;
const OauthConfig({
required this.baseApiUrl,
required this.consoleAuthorizeUrl,
required this.claudeAiAuthorizeUrl,
required this.claudeAiOrigin,
required this.tokenUrl,
required this.apiKeyUrl,
required this.rolesUrl,
required this.consoleSuccessUrl,
required this.claudeAiSuccessUrl,
required this.manualRedirectUrl,
required this.clientId,
required this.oauthFileSuffix,
required this.mcpProxyUrl,
required this.mcpProxyPath,
});
}
// production config — default
const OauthConfig _prodOauthConfig = OauthConfig(
baseApiUrl: "https://api.anthropic.com",
consoleAuthorizeUrl: "https://platform.claude.com/oauth/authorize",
claudeAiAuthorizeUrl: "https://claude.com/cai/oauth/authorize",
claudeAiOrigin: "https://claude.ai",
tokenUrl: "https://platform.claude.com/v1/oauth/token",
apiKeyUrl: "https://api.anthropic.com/api/oauth/claude_cli/create_api_key",
rolesUrl: "https://api.anthropic.com/api/oauth/claude_cli/roles",
consoleSuccessUrl:
"https://platform.claude.com/buy_credits?returnUrl=/oauth/code/success%3Fapp%3Dclaude-code",
claudeAiSuccessUrl:
"https://platform.claude.com/oauth/code/success?app=claude-code",
manualRedirectUrl: "https://platform.claude.com/oauth/code/callback",
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
oauthFileSuffix: "",
mcpProxyUrl: "https://mcp-proxy.anthropic.com",
mcpProxyPath: "/v1/mcp/{server_id}",
);
// Only allowed FedStart base URLs for custom oauth override
const List<String> allowedOauthBaseUrls = [
"https://beacon.claude-ai.staging.ant.dev",
"https://claude.fedstart.com",
"https://claude-staging.fedstart.com",
];
OauthConfig getOauthConfig() {
final env = Platform.environment;
// check for custom oauth url override (FedStart only)
final customBase = env["CLAUDE_CODE_CUSTOM_OAUTH_URL"];
if (customBase != null && customBase.isNotEmpty) {
final base = customBase.replaceAll(RegExp(r"/$"), "");
if (!allowedOauthBaseUrls.contains(base)) {
throw Exception("CLAUDE_CODE_CUSTOM_OAUTH_URL is not an approved endpoint.");
}
var config = _prodOauthConfig;
return OauthConfig(
baseApiUrl: base,
consoleAuthorizeUrl: "$base/oauth/authorize",
claudeAiAuthorizeUrl: "$base/oauth/authorize",
claudeAiOrigin: base,
tokenUrl: "$base/v1/oauth/token",
apiKeyUrl: "$base/api/oauth/claude_cli/create_api_key",
rolesUrl: "$base/api/oauth/claude_cli/roles",
consoleSuccessUrl: "$base/oauth/code/success?app=claude-code",
claudeAiSuccessUrl: "$base/oauth/code/success?app=claude-code",
manualRedirectUrl: "$base/oauth/code/callback",
clientId: env["CLAUDE_CODE_OAUTH_CLIENT_ID"] ?? config.clientId,
oauthFileSuffix: "-custom-oauth",
mcpProxyUrl: config.mcpProxyUrl,
mcpProxyPath: config.mcpProxyPath,
);
}
var config = _prodOauthConfig;
// allow client ID override
final clientOverride = env["CLAUDE_CODE_OAUTH_CLIENT_ID"];
if (clientOverride != null && clientOverride.isNotEmpty) {
config = OauthConfig(
baseApiUrl: config.baseApiUrl,
consoleAuthorizeUrl: config.consoleAuthorizeUrl,
claudeAiAuthorizeUrl: config.claudeAiAuthorizeUrl,
claudeAiOrigin: config.claudeAiOrigin,
tokenUrl: config.tokenUrl,
apiKeyUrl: config.apiKeyUrl,
rolesUrl: config.rolesUrl,
consoleSuccessUrl: config.consoleSuccessUrl,
claudeAiSuccessUrl: config.claudeAiSuccessUrl,
manualRedirectUrl: config.manualRedirectUrl,
clientId: clientOverride,
oauthFileSuffix: config.oauthFileSuffix,
mcpProxyUrl: config.mcpProxyUrl,
mcpProxyPath: config.mcpProxyPath,
);
}
return config;
}
String fileSuffixForOauthConfig() {
final customUrl = Platform.environment["CLAUDE_CODE_CUSTOM_OAUTH_URL"];
if (customUrl != null && customUrl.isNotEmpty) {
return "-custom-oauth";
}
// default to prod (no suffix)
return "";
}
+28
View File
@@ -0,0 +1,28 @@
// Product URLs and environment helpers
const String productUrl = "https://claude.com/claude-code";
const String claudeAiBaseUrl = "https://claude.ai";
const String claudeAiStagingBaseUrl = "https://claude-ai.staging.ant.dev";
const String claudeAiLocalBaseUrl = "http://localhost:4000";
bool isRemoteSessionStaging({String? sessionId, String? ingressUrl}) {
return (sessionId?.contains("_staging_") ?? false) ||
(ingressUrl?.contains("staging") ?? false);
}
bool isRemoteSessionLocal({String? sessionId, String? ingressUrl}) {
return (sessionId?.contains("_local_") ?? false) ||
(ingressUrl?.contains("localhost") ?? false);
}
String getClaudeAiBaseUrl({String? sessionId, String? ingressUrl}) {
if (isRemoteSessionLocal(sessionId: sessionId, ingressUrl: ingressUrl)) {
return claudeAiLocalBaseUrl;
}
if (isRemoteSessionStaging(sessionId: sessionId, ingressUrl: ingressUrl)) {
return claudeAiStagingBaseUrl;
}
return claudeAiBaseUrl;
}
+204
View File
@@ -0,0 +1,204 @@
// Spinner verbs shown while claude is thinking
// Also turn completion verbs (past tense) at bottom
const List<String> spinnerVerbs = [
"Accomplishing",
"Actioning",
"Actualizing",
"Architecting",
"Baking",
"Beaming",
"Beboppin'",
"Befuddling",
"Billowing",
"Blanching",
"Bloviating",
"Boogieing",
"Boondoggling",
"Booping",
"Bootstrapping",
"Brewing",
"Bunning",
"Burrowing",
"Calculating",
"Canoodling",
"Caramelizing",
"Cascading",
"Catapulting",
"Cerebrating",
"Channeling",
"Channelling",
"Choreographing",
"Churning",
"Clauding",
"Coalescing",
"Cogitating",
"Combobulating",
"Composing",
"Computing",
"Concocting",
"Considering",
"Contemplating",
"Cooking",
"Crafting",
"Creating",
"Crunching",
"Crystallizing",
"Cultivating",
"Deciphering",
"Deliberating",
"Determining",
"Dilly-dallying",
"Discombobulating",
"Doing",
"Doodling",
"Drizzling",
"Ebbing",
"Effecting",
"Elucidating",
"Embellishing",
"Enchanting",
"Envisioning",
"Evaporating",
"Fermenting",
"Fiddle-faddling",
"Finagling",
"Flambéing",
"Flibbertigibbeting",
"Flowing",
"Flummoxing",
"Fluttering",
"Forging",
"Forming",
"Frolicking",
"Frosting",
"Gallivanting",
"Galloping",
"Garnishing",
"Generating",
"Gesticulating",
"Germinating",
"Gitifying",
"Grooving",
"Gusting",
"Harmonizing",
"Hashing",
"Hatching",
"Herding",
"Honking",
"Hullaballooing",
"Hyperspacing",
"Ideating",
"Imagining",
"Improvising",
"Incubating",
"Inferring",
"Infusing",
"Ionizing",
"Jitterbugging",
"Julienning",
"Kneading",
"Leavening",
"Levitating",
"Lollygagging",
"Manifesting",
"Marinating",
"Meandering",
"Metamorphosing",
"Misting",
"Moonwalking",
"Moseying",
"Mulling",
"Mustering",
"Musing",
"Nebulizing",
"Nesting",
"Newspapering",
"Noodling",
"Nucleating",
"Orbiting",
"Orchestrating",
"Osmosing",
"Perambulating",
"Percolating",
"Perusing",
"Philosophising",
"Photosynthesizing",
"Pollinating",
"Pondering",
"Pontificating",
"Pouncing",
"Precipitating",
"Prestidigitating",
"Processing",
"Proofing",
"Propagating",
"Puttering",
"Puzzling",
"Quantumizing",
"Razzle-dazzling",
"Razzmatazzing",
"Recombobulating",
"Reticulating",
"Roosting",
"Ruminating",
"Sautéing",
"Scampering",
"Schlepping",
"Scurrying",
"Seasoning",
"Shenaniganing",
"Shimmying",
"Simmering",
"Skedaddling",
"Sketching",
"Slithering",
"Smooshing",
"Sock-hopping",
"Spelunking",
"Spinning",
"Sprouting",
"Stewing",
"Sublimating",
"Swirling",
"Swooping",
"Symbioting",
"Synthesizing",
"Tempering",
"Thinking",
"Thundering",
"Tinkering",
"Tomfoolering",
"Topsy-turvying",
"Transfiguring",
"Transmuting",
"Twisting",
"Undulating",
"Unfurling",
"Unravelling",
"Vibing",
"Waddling",
"Wandering",
"Warping",
"Whatchamacalliting",
"Whirlpooling",
"Whirring",
"Whisking",
"Wibbling",
"Working",
"Wrangling",
"Zesting",
"Zigzagging",
];
// past-tense verbs for turn completion messages ("Worked for 5s")
const List<String> turnCompletionVerbs = [
"Baked",
"Brewed",
"Churned",
"Cogitated",
"Cooked",
"Crunched",
"Sautéed",
"Worked",
];
+13
View File
@@ -0,0 +1,13 @@
// Tool result size limits
const int defaultMaxResultSizeChars = 50000;
const int maxToolResultTokens = 100000;
const int bytesPerToken = 4;
const int maxToolResultBytes = maxToolResultTokens * bytesPerToken;
const int maxToolResultsPerMessageChars = 200000;
const int toolSummaryMaxLength = 50;
+68
View File
@@ -0,0 +1,68 @@
// XML tag names used in message content
const String commandNameTag = "command-name";
const String commandMessageTag = "command-message";
const String commandArgsTag = "command-args";
// terminal / bash tags
const String bashInputTag = "bash-input";
const String bashStdoutTag = "bash-stdout";
const String bashStderrTag = "bash-stderr";
const String localCommandStdoutTag = "local-command-stdout";
const String localCommandStderrTag = "local-command-stderr";
const String localCommandCaveatTag = "local-command-caveat";
const List<String> terminalOutputTags = [
bashInputTag,
bashStdoutTag,
bashStderrTag,
localCommandStdoutTag,
localCommandStderrTag,
localCommandCaveatTag,
];
const String tickTag = "tick";
// task notification tags
const String taskNotificationTag = "task-notification";
const String taskIdTag = "task-id";
const String toolUseIdTag = "tool-use-id";
const String taskTypeTag = "task-type";
const String outputFileTag = "output-file";
const String statusTag = "status";
const String summaryTag = "summary";
const String reasonTag = "reason";
const String worktreeTag = "worktree";
const String worktreePathTag = "worktreePath";
const String worktreeBranchTag = "worktreeBranch";
const String ultraplanTag = "ultraplan";
const String remoteReviewTag = "remote-review";
const String remoteReviewProgressTag = "remote-review-progress";
const String teammateMessageTag = "teammate-message";
const String channelMessageTag = "channel-message";
const String channelTag = "channel";
const String crossSessionMessageTag = "cross-session-message";
const String forkBoilerplateTag = "fork-boilerplate";
// prefix before the directive text, stripped by renderer
const String forkDirectivePrefix = "Your directive: ";
const List<String> commonHelpArgs = ["help", "-h", "--help"];
const List<String> commonInfoArgs = [
"list",
"show",
"display",
"current",
"view",
"get",
"check",
"describe",
"print",
"version",
"about",
"status",
"?",
];
+185
View File
@@ -0,0 +1,185 @@
/// Context window and token management
///
/// Tracks token usage throughout a session across:
/// - System prompt
/// - User/assistant messages
/// - Tool definitions
/// - Attached files
import "context_types.dart";
import "token_counter.dart";
/// Manages context window token accounting
/// Tracks usage by component and provides query methods
class ContextManager {
final int maxTokens;
/// Tokens used by system prompt (instructions, git status, etc.)
int _systemTokens = 0;
/// Tokens used by conversation messages
int _messageTokens = 0;
/// Tokens used by tool definitions/schemas
int _toolTokens = 0;
/// Tokens used by files/attachments
int _fileTokens = 0;
/// History of token additions by component (for analysis)
final Map<String, List<int>> _history = {
"system": [],
"messages": [],
"tools": [],
"files": [],
};
ContextManager({required this.maxTokens});
/// Get current context window state
ContextWindow getCurrentState() {
return ContextWindow.from(
maxTokens: maxTokens,
systemTokens: _systemTokens,
messageTokens: _messageTokens,
toolTokens: _toolTokens,
fileTokens: _fileTokens,
);
}
/// Get available tokens remaining
int getAvailableTokens() {
final current = _systemTokens + _messageTokens + _toolTokens + _fileTokens;
return maxTokens - current;
}
/// Get percentage of context used (0-100)
double getPercentageUsed() {
final current = _systemTokens + _messageTokens + _toolTokens + _fileTokens;
return maxTokens > 0
? ((current.toDouble() / maxTokens.toDouble()) * 100)
: 0;
}
/// Add system context (prompt, instructions, git status)
void addSystemContext(String content) {
final tokens = countTokensInString(content);
_systemTokens += tokens;
_history["system"]!.add(tokens);
}
/// Add message to context
void addMessage(Map<String, dynamic> message) {
final tokens = countTokensInMessage(message);
_messageTokens += tokens;
_history["messages"]!.add(tokens);
}
/// Add multiple messages at once
void addMessages(List<Map<String, dynamic>> messages) {
for (final msg in messages) {
addMessage(msg);
}
}
/// Add tool definition to context
void addToolDefinition(String toolName, Map<String, dynamic> definition) {
final tokens = countTokensInJson(definition);
_toolTokens += tokens;
_history["tools"]!.add(tokens);
}
/// Add file/attachment to context
void addFile(String filePath, String content) {
final tokens = countTokensInString(content);
_fileTokens += tokens;
_history["files"]!.add(tokens);
}
/// Remove message tokens from context
/// (e.g., when compacting or trimming old messages)
void removeMessageTokens(int tokens) {
_messageTokens = (_messageTokens - tokens).clamp(0, _messageTokens);
}
/// Remove file tokens from context
void removeFileTokens(int tokens) {
_fileTokens = (_fileTokens - tokens).clamp(0, _fileTokens);
}
/// Estimate tokens for a string without adding to context
int estimateTokens(String content) {
return countTokensInString(content);
}
/// Estimate tokens for a message without adding to context
int estimateMessageTokens(Map<String, dynamic> message) {
return countTokensInMessage(message);
}
/// Get breakdown of current context usage
Map<String, int> getContextBreakdown() {
return {
"system": _systemTokens,
"messages": _messageTokens,
"tools": _toolTokens,
"files": _fileTokens,
"total": _systemTokens + _messageTokens + _toolTokens + _fileTokens,
"available": getAvailableTokens(),
};
}
/// Get token history for a component
List<int> getComponentHistory(String component) {
return _history[component] ?? [];
}
/// Reset all tokens and history
void reset() {
_systemTokens = 0;
_messageTokens = 0;
_toolTokens = 0;
_fileTokens = 0;
for (final key in _history.keys) {
_history[key]!.clear();
}
}
/// Reset specific component
void resetComponent(String component) {
switch (component) {
case "system":
_systemTokens = 0;
break;
case "messages":
_messageTokens = 0;
break;
case "tools":
_toolTokens = 0;
break;
case "files":
_fileTokens = 0;
break;
}
_history[component]?.clear();
}
/// Check if context is near capacity (>85%)
bool isNearCapacity() {
return getPercentageUsed() > 85.0;
}
/// Check if context is at warning level (>75%)
bool isAtWarningLevel() {
return getPercentageUsed() > 75.0;
}
/// Check if context is critical (>95%)
bool isCritical() {
return getPercentageUsed() > 95.0;
}
@override
String toString() => getCurrentState().toString();
}
+95
View File
@@ -0,0 +1,95 @@
/// Context window and token management types
///
/// Represents the current state of the context window, tracking:
/// - total token capacity
/// - current token usage across components
/// - breakdown by component (system, messages, tools, files)
class ContextWindow {
/// Maximum tokens available in this context window
final int maxTokens;
/// Current tokens used (sum of all components)
final int currentTokens;
/// Tokens consumed by system prompt (context instructions, git status, etc.)
final int systemTokens;
/// Tokens consumed by conversation messages (user + assistant)
final int messageTokens;
/// Tokens consumed by tool definitions/schemas
final int toolTokens;
/// Tokens consumed by file content (attachments, context, etc.)
final int fileTokens;
/// Tokens available for new content
final int availableTokens;
/// Approximate percent of context used (0-100)
final double percentageUsed;
ContextWindow({
required this.maxTokens,
required this.currentTokens,
required this.systemTokens,
required this.messageTokens,
required this.toolTokens,
required this.fileTokens,
}) : availableTokens = maxTokens - currentTokens,
percentageUsed = maxTokens > 0
? ((currentTokens.toDouble() / maxTokens.toDouble()) * 100)
: 0;
/// Create a ContextWindow from components
factory ContextWindow.from({
required int maxTokens,
required int systemTokens,
required int messageTokens,
required int toolTokens,
required int fileTokens,
}) {
final current =
systemTokens + messageTokens + toolTokens + fileTokens;
return ContextWindow(
maxTokens: maxTokens,
currentTokens: current,
systemTokens: systemTokens,
messageTokens: messageTokens,
toolTokens: toolTokens,
fileTokens: fileTokens,
);
}
/// Check if context is approaching full (>90%)
bool get isNearCapacity => percentageUsed > 90.0;
/// Check if context is at critical level (>95%)
bool get isCritical => percentageUsed > 95.0;
/// Human-readable breakdown of token usage
Map<String, int> get breakdown => {
"system": systemTokens,
"messages": messageTokens,
"tools": toolTokens,
"files": fileTokens,
};
@override
String toString() {
return """ContextWindow {
maxTokens: $maxTokens,
currentTokens: $currentTokens,
availableTokens: $availableTokens,
percentageUsed: ${percentageUsed.toStringAsFixed(1)}%,
breakdown: {
system: $systemTokens,
messages: $messageTokens,
tools: $toolTokens,
files: $fileTokens
}
}""";
}
}
+123
View File
@@ -0,0 +1,123 @@
/// Token counting logic with character-based heuristics
///
/// Since tiktoken is not available in Dart, we use standard
/// estimations: roughly 4 characters per token
const int _charsPerToken = 4;
/// Estimate token count from plain text
/// Uses 4 chars per token heuristic
int countTokensInString(String text) {
if (text.isEmpty) return 0;
return (text.length / _charsPerToken).ceil();
}
/// Estimate token count from a JSON structure
/// Converts to string representation first
int countTokensInJson(Map<String, dynamic> json) {
final str = json.toString();
return countTokensInString(str);
}
/// Estimate token count for a message content block
/// Handles text, tool use, tool results, images, etc.
int countTokensInContentBlock(Map<String, dynamic> block) {
final type = block["type"] as String?;
switch (type) {
case "text":
final text = (block["text"] as String?) ?? "";
return countTokensInString(text);
case "tool_use":
var tokens = 4; // overhead for tool_use block
final name = block["name"] as String?;
if (name != null) {
tokens += countTokensInString(name);
}
final input = block["input"];
if (input is Map<String, dynamic>) {
tokens += countTokensInJson(input);
} else if (input is String) {
tokens += countTokensInString(input);
}
return tokens;
case "tool_result":
var tokens = 4; // overhead
final content = block["content"];
if (content is String) {
tokens += countTokensInString(content);
} else if (content is List) {
for (final item in content) {
if (item is Map<String, dynamic>) {
tokens += countTokensInContentBlock(item);
} else if (item is String) {
tokens += countTokensInString(item);
}
}
} else if (content is Map<String, dynamic>) {
tokens += countTokensInJson(content);
}
return tokens;
case "image":
// Image tokens depend on size/resolution - estimate modestly
// (actual size info would require image metadata)
return 1000;
case "thinking":
final thinking = (block["thinking"] as String?) ?? "";
return countTokensInString(thinking);
case "redacted_thinking":
final data = (block["data"] as String?) ?? "";
return countTokensInString(data);
default:
// fallback - stringify whole block
return countTokensInJson(block);
}
}
/// Estimate tokens for an entire message (with role overhead)
int countTokensInMessage(Map<String, dynamic> message) {
var tokens = 0;
// Role overhead (role + colon + space)
tokens += 4;
final content = message["content"];
if (content is String) {
tokens += countTokensInString(content);
} else if (content is List) {
for (final block in content) {
if (block is Map<String, dynamic>) {
tokens += countTokensInContentBlock(block);
} else if (block is String) {
tokens += countTokensInString(block);
}
}
}
return tokens;
}
/// Estimate tokens for a list of messages
int countTokensInMessages(List<Map<String, dynamic>> messages) {
var total = 0;
for (final msg in messages) {
total += countTokensInMessage(msg);
}
return total;
}
/// Count tokens for content - handles both strings and JSON structures
int countTokensForContent(dynamic content) {
if (content is String) {
return countTokensInString(content);
} else if (content is Map<String, dynamic>) {
return countTokensInJson(content);
}
return 0;
}
+146
View File
@@ -0,0 +1,146 @@
// coordinatorMode — coordinator mode utilities for multi-agent workflows.
// Ported from old_repo/coordinator/coordinatorMode.ts
import "dart:io";
import "package:clawd_code/src/utils/env_utils.dart";
// Constants for tool names
const String agentToolName = "agent";
const String sendMessageToolName = "send_message";
const String taskStopToolName = "task_stop";
const String teamCreateToolName = "team_create";
const String teamDeleteToolName = "team_delete";
const String syntheticOutputToolName = "structured_output";
// Tool sets for internal coordinator operations
const internalWorkerTools = {
teamCreateToolName,
teamDeleteToolName,
sendMessageToolName,
syntheticOutputToolName,
};
// Check if coordinator mode is enabled
bool isCoordinatorMode() {
return isEnvTruthy(Platform.environment["CLAUDE_CODE_COORDINATOR_MODE"]);
}
// Check if a session was in coordinator mode (for mode matching on resume)
bool matchSessionMode(String? sessionMode) {
if (sessionMode == null || sessionMode.isEmpty) return false;
final currentIsCoordinator = isCoordinatorMode();
final sessionIsCoordinator = sessionMode == "coordinator";
if (currentIsCoordinator == sessionIsCoordinator) {
// no switch needed
return true;
}
// would need to flip the env var, but in Dart we cant modify the process env
// and have it persist. instead, callers would need to set it before starting.
// for now, just return false to indicate a mismatch.
return false;
}
// Build coordinator user context — injected into system prompt
String getCoordinatorUserContext(
List<Map<String, String>> mcpClients,
String? scratchpadDir,
) {
if (!isCoordinatorMode()) {
return "";
}
// Get list of tools available to workers
final workerTools = <String>[
"bash",
"read",
"edit",
].join(", ");
var content = "Workers spawned via the $agentToolName tool have access to these tools: $workerTools";
if (mcpClients.isNotEmpty) {
final serverNames = mcpClients.map((c) => c["name"] ?? "unknown").join(", ");
content += "\n\nWorkers also have access to MCP tools from connected MCP servers: $serverNames";
}
if (scratchpadDir != null && scratchpadDir.isNotEmpty) {
content += "\n\nScratchpad directory: $scratchpadDir\nWorkers can read and write here without permission prompts. Use this for durable cross-worker knowledge — structure files however fits the work.";
}
return content;
}
// Build coordinator system prompt
String getCoordinatorSystemPrompt() {
final workerCapabilities = isSimpleMode()
? "Workers have access to Bash, Read, and Edit tools, plus MCP tools from configured MCP servers."
: "Workers have access to standard tools, MCP tools from configured MCP servers, and project skills via the Skill tool. Delegate skill invocations (e.g. /commit, /verify) to workers.";
return """You are Claude Code, an AI assistant that orchestrates software engineering tasks across multiple workers.
## 1. Your Role
You are a **coordinator**. Your job is to:
- Help the user achieve their goal
- Direct workers to research, implement and verify code changes
- Synthesize results and communicate with the user
- Answer questions directly when possible — don't delegate work that you can handle without tools
Every message you send is to the user. Worker results and system notifications are internal signals, not conversation partners — never thank or acknowledge them. Summarize new information for the user as it arrives.
## 2. Your Tools
- **$agentToolName** - Spawn a new worker
- **$sendMessageToolName** - Continue an existing worker (send a follow-up to its \`to\` agent ID)
- **$taskStopToolName** - Stop a running worker
When calling $agentToolName:
- Do not use one worker to check on another. Workers will notify you when they are done.
- Do not use workers to trivially report file contents or run commands. Give them higher-level tasks.
- Do not set the model parameter. Workers need the default model for the substantive tasks you delegate.
- Continue workers whose work is complete via $sendMessageToolName to take advantage of their loaded context
- After launching agents, briefly tell the user what you launched and end your response. Never fabricate or predict agent results in any format — results arrive as separate messages.
## 3. Workers
$workerCapabilities
## 4. Task Workflow
Most tasks can be broken down into the following phases:
### Phases
| Phase | Who | Purpose |
|-------|-----|---------|
| Research | Workers (parallel) | Investigate codebase, find files, understand problem |
| Synthesis | **You** (coordinator) | Read findings, understand the problem, craft implementation specs |
| Implementation | Workers | Make targeted changes per spec, commit |
| Verification | Workers | Test changes work |
### Concurrency
**Parallelism is your superpower. Workers are async. Launch independent workers concurrently whenever possible — don't serialize work that can run simultaneously and look for opportunities to fan out.**
Manage concurrency:
- **Read-only tasks** (research) — run in parallel freely
- **Write-heavy tasks** (implementation) — one at a time per set of files
- **Verification** can sometimes run alongside implementation on different file areas
""";
}
// Check if simple mode (reduced toolset) is enabled
bool isSimpleMode() {
return isEnvTruthy(Platform.environment["CLAUDE_CODE_SIMPLE"]);
}
+288
View File
@@ -0,0 +1,288 @@
import "dart:async";
import "dart:convert";
import "dart:io";
import "daemon_types.dart";
// DaemonManager: manages background Claude sessions.
//
// Session records are stored as JSON under ~/.claude/sessions/<id>.json
// Each record includes pid, workingDir, status, log path, etc.
//
// The manager can start new background sessions, list them, stream their
// logs, attach to them (tail log), and kill them.
class DaemonManager {
DaemonManager({String? sessionsDir})
: sessionsDir = sessionsDir ?? _defaultSessionsDir();
final String sessionsDir;
static String _defaultSessionsDir() {
final home =
Platform.environment["HOME"] ??
Platform.environment["USERPROFILE"] ??
"/tmp";
return "$home/.claude/sessions";
}
Directory get _dir => Directory(sessionsDir);
// ─── registry I/O ───────────────────────────────────────────────────────
Future<void> _ensureDir() async {
await _dir.create(recursive: true);
}
String _recordPath(String id) =>
"$sessionsDir/${safeFilenameId(id)}.json";
Future<void> saveRecord(SessionRecord rec) async {
await _ensureDir();
final f = File(_recordPath(rec.id));
await f.writeAsString(jsonEncode(rec.toJson()));
}
Future<SessionRecord?> loadRecord(String id) async {
final f = File(_recordPath(id));
if (!f.existsSync()) return null;
try {
final raw = await f.readAsString();
return SessionRecord.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
} catch (_) {
return null;
}
}
Future<void> deleteRecord(String id) async {
final f = File(_recordPath(id));
if (f.existsSync()) await f.delete();
}
/// List all session records. Stale (process-dead) running sessions
/// are updated to status=failed automatically.
Future<List<SessionRecord>> listSessions({bool refreshStatus = true}) async {
await _ensureDir();
final files = _dir
.listSync()
.whereType<File>()
.where((f) => f.path.endsWith(".json"))
.toList();
final records = <SessionRecord>[];
for (final f in files) {
try {
final raw = await f.readAsString();
final rec = SessionRecord.fromJson(
jsonDecode(raw) as Map<String, dynamic>,
);
records.add(rec);
} catch (_) {
// skip corrupt files
}
}
if (refreshStatus) {
for (final rec in records) {
if (rec.status == SessionStatus.running) {
final alive = _isPidAlive(rec.pid);
if (!alive) {
rec.status = SessionStatus.failed;
rec.endedAt = DateTime.now().toUtc().toIso8601String();
await saveRecord(rec);
}
}
}
}
records.sort((a, b) => a.startedAt.compareTo(b.startedAt));
return records;
}
// ─── process helpers ─────────────────────────────────────────────────────
bool _isPidAlive(int pid) {
// On Unix, sending signal 0 tests process existence
try {
return Process.killPid(pid, ProcessSignal.sigusr2) ||
// fallback: check /proc on linux
File("/proc/$pid").existsSync();
} catch (_) {
// ESRCH = no such process
try {
return File("/proc/$pid").existsSync();
} catch (_) {
return false;
}
}
}
// ─── start a background session ─────────────────────────────────────────
/// Spawn a new background Claude session.
///
/// [executable] is the claude binary path (defaults to "claude").
/// [promptArgs] are forwarded as-is to the child process.
/// Returns the session record.
Future<SessionRecord> startSession({
String executable = "claude",
List<String> promptArgs = const [],
String? workingDirectory,
String? model,
String? title,
}) async {
await _ensureDir();
final id = generateSessionId();
final logDir = "$sessionsDir/logs";
await Directory(logDir).create(recursive: true);
final logFile = "$logDir/${safeFilenameId(id)}.log";
final cwd = workingDirectory ?? Directory.current.path;
// args: --bg tells the legacy claude CLI to run headlessly (non-interactive)
final args = [
"--bg",
if (model != null) ...["--model", model],
...promptArgs,
];
final logSink = File(logFile).openWrite();
final proc = await Process.start(
executable,
args,
workingDirectory: cwd,
environment: {
...Platform.environment,
"CLAWD_SESSION_ID": id,
},
mode: ProcessStartMode.detachedWithStdio,
);
// pipe stdout/stderr into the log file
proc.stdout.listen((d) => logSink.add(d));
proc.stderr.listen((d) => logSink.add(d));
unawaited(proc.exitCode.then((code) async {
await logSink.close();
final rec = await loadRecord(id);
if (rec != null) {
rec.status = code == 0 ? SessionStatus.completed : SessionStatus.failed;
rec.endedAt = DateTime.now().toUtc().toIso8601String();
rec.exitCode = code;
await saveRecord(rec);
}
}));
final rec = SessionRecord(
id: id,
pid: proc.pid,
workingDirectory: cwd,
startedAt: DateTime.now().toUtc().toIso8601String(),
status: SessionStatus.running,
logFile: logFile,
title: title,
model: model,
);
await saveRecord(rec);
return rec;
}
// ─── kill a session ──────────────────────────────────────────────────────
/// Kill the session by id. force=true sends SIGKILL, otherwise SIGTERM.
Future<bool> killSession(String id, {bool force = false}) async {
final rec = await loadRecord(id);
if (rec == null) return false;
if (rec.status != SessionStatus.running) return false;
try {
final sig = force ? ProcessSignal.sigkill : ProcessSignal.sigterm;
final sent = Process.killPid(rec.pid, sig);
if (sent) {
rec.status = SessionStatus.killed;
rec.endedAt = DateTime.now().toUtc().toIso8601String();
await saveRecord(rec);
}
return sent;
} catch (_) {
return false;
}
}
// ─── logs ─────────────────────────────────────────────────────────────────
/// Read the log file for a session. Returns null if not found.
Future<String?> readLogs(String id, {int? tail}) async {
final rec = await loadRecord(id);
if (rec == null || rec.logFile == null) return null;
final f = File(rec.logFile!);
if (!f.existsSync()) return null;
final contents = await f.readAsString();
if (tail == null) return contents;
final lines = contents.split("\n");
final start = lines.length > tail ? lines.length - tail : 0;
return lines.sublist(start).join("\n");
}
/// Stream log output from a session (tail -f style).
/// The stream ends when the session process exits.
Stream<String> streamLogs(String id) async* {
final rec = await loadRecord(id);
if (rec == null || rec.logFile == null) return;
final f = File(rec.logFile!);
if (!f.existsSync()) return;
// first emit existing content
final existing = await f.readAsString();
if (existing.isNotEmpty) yield existing;
// then watch for changes
if (rec.status != SessionStatus.running) return;
var offset = existing.length;
while (true) {
await Future<void>.delayed(const Duration(milliseconds: 250));
final current = await loadRecord(id);
final content = await f.readAsString();
if (content.length > offset) {
yield content.substring(offset);
offset = content.length;
}
if (current == null || current.status != SessionStatus.running) break;
if (!_isPidAlive(current.pid)) break;
}
}
// ─── attach ──────────────────────────────────────────────────────────────
/// Print session info suitable for "attach" display.
Future<String?> describeSession(String id) async {
final rec = await loadRecord(id);
if (rec == null) return null;
final buf = StringBuffer();
buf.writeln("Session: ${rec.id}");
buf.writeln(" PID: ${rec.pid}");
buf.writeln(" Status: ${rec.status.name}");
buf.writeln(" Dir: ${rec.workingDirectory}");
buf.writeln(" Started: ${rec.startedAt}");
if (rec.endedAt != null) buf.writeln(" Ended: ${rec.endedAt}");
if (rec.title != null) buf.writeln(" Title: ${rec.title}");
if (rec.logFile != null) buf.writeln(" Log: ${rec.logFile}");
return buf.toString();
}
}
+171
View File
@@ -0,0 +1,171 @@
import "dart:io";
// Types for the daemon session registry.
//
// Sessions are persisted as JSON under ~/.claude/sessions/<id>.json
// ─── session status ───────────────────────────────────────────────────────
enum SessionStatus {
running,
completed,
failed,
killed;
String toJson() => name;
static SessionStatus fromJson(String s) {
switch (s) {
case "running":
return SessionStatus.running;
case "completed":
return SessionStatus.completed;
case "failed":
return SessionStatus.failed;
case "killed":
return SessionStatus.killed;
default:
return SessionStatus.running;
}
}
}
// ─── session record ───────────────────────────────────────────────────────
class SessionRecord {
SessionRecord({
required this.id,
required this.pid,
required this.workingDirectory,
required this.startedAt,
required this.status,
this.endedAt,
this.logFile,
this.title,
this.model,
this.exitCode,
});
factory SessionRecord.fromJson(Map<String, dynamic> j) {
return SessionRecord(
id: j["id"] as String,
pid: j["pid"] as int,
workingDirectory: j["workingDirectory"] as String,
startedAt: j["startedAt"] as String,
status: SessionStatus.fromJson(j["status"] as String? ?? "running"),
endedAt: j["endedAt"] as String?,
logFile: j["logFile"] as String?,
title: j["title"] as String?,
model: j["model"] as String?,
exitCode: j["exitCode"] as int?,
);
}
String id;
int pid;
String workingDirectory;
String startedAt;
SessionStatus status;
String? endedAt;
String? logFile;
String? title;
String? model;
int? exitCode;
bool get isAlive => status == SessionStatus.running;
Map<String, dynamic> toJson() {
final m = <String, dynamic>{
"id": id,
"pid": pid,
"workingDirectory": workingDirectory,
"startedAt": startedAt,
"status": status.toJson(),
};
if (endedAt != null) m["endedAt"] = endedAt;
if (logFile != null) m["logFile"] = logFile;
if (title != null) m["title"] = title;
if (model != null) m["model"] = model;
if (exitCode != null) m["exitCode"] = exitCode;
return m;
}
@override
String toString() {
final stat = status.name;
final pid_ = pid;
return "SessionRecord($id pid=$pid_ status=$stat dir=$workingDirectory)";
}
}
// ─── daemon state ─────────────────────────────────────────────────────────
class DaemonState {
DaemonState({
required this.pid,
required this.socketPath,
required this.startedAt,
required this.sessions,
});
factory DaemonState.fromJson(Map<String, dynamic> j) {
final rawSessions = (j["sessions"] as List?)?.cast<Map<String, dynamic>>();
return DaemonState(
pid: j["pid"] as int,
socketPath: j["socketPath"] as String,
startedAt: j["startedAt"] as String,
sessions: rawSessions?.map(SessionRecord.fromJson).toList() ?? [],
);
}
int pid;
String socketPath;
String startedAt;
List<SessionRecord> sessions;
Map<String, dynamic> toJson() => {
"pid": pid,
"socketPath": socketPath,
"startedAt": startedAt,
"sessions": sessions.map((s) => s.toJson()).toList(),
};
}
// ─── process info (lightweight live-process check) ───────────────────────
class ProcessInfo {
const ProcessInfo({required this.pid, required this.alive});
final int pid;
final bool alive;
/// Check if a PID is still alive by sending signal 0.
static ProcessInfo check(int pid) {
try {
// Process.killPid with signal 0 tests existence without actually killing
final alive = Process.killPid(pid, ProcessSignal.sigusr1);
// If that didn't throw, process exists. But signal 0 isn't directly
// available; we use a /proc check on linux or fallback.
return ProcessInfo(pid: pid, alive: alive);
} catch (_) {
return ProcessInfo(pid: pid, alive: false);
}
}
@override
String toString() => "ProcessInfo(pid=$pid alive=$alive)";
}
// ─── helpers ─────────────────────────────────────────────────────────────
/// Generate a short random session id (8 hex chars).
String generateSessionId() {
final now = DateTime.now().microsecondsSinceEpoch;
final rand = now ^ (now >> 16);
return rand.toUnsigned(32).toRadixString(16).padLeft(8, "0");
}
/// Sanitize a session id so it's safe to use in file names.
String safeFilenameId(String id) {
return id.replaceAll(RegExp(r"[^a-zA-Z0-9_\-]"), "_");
}
+115
View File
@@ -0,0 +1,115 @@
import 'dart:convert';
import 'hook_types.dart';
/// Context passed to hooks for inspection/modification
class HookContext {
/// The kind of hook being executed
final HookKind kind;
/// Name of the target (tool, command, etc)
final String? targetName;
/// tool/command input as JSON
final Map<String, dynamic>? input;
/// Tool output (for post-tool hooks)
final dynamic output;
/// Exit code from command
final int? exitCode;
/// Environment variables available to hook
final Map<String, String> environment;
/// Additional context about the operation
final Map<String, dynamic> metadata;
HookContext({
required this.kind,
this.targetName,
this.input,
this.output,
this.exitCode,
Map<String, String>? environment,
Map<String, dynamic>? metadata,
}) : environment = environment ?? {},
metadata = metadata ?? {};
/// Convenient method to get display name
String get kindName => kind.displayName;
/// Convert context to JSON for passing to shell commands
String toJsonString() {
return jsonEncode({
'hook_kind': kindName,
if (targetName != null) 'target': targetName,
if (input != null) 'input': input,
if (output != null) 'output': output,
if (exitCode != null) 'exit_code': exitCode,
...metadata,
});
}
}
/// Result returned from executing a hook
class HookResult {
/// Whether the hook completed successfully
final bool success;
/// Output from the hook
final String? stdout;
final String? stderr;
/// Exit code (for command hooks)
final int? exitCode;
/// Whether hook wants to continue or block (if applicable)
final bool? shouldContinue;
/// Custom message to display
final String? message;
/// Hook-specific output for processing
final Map<String, dynamic>? hookOutput;
HookResult({
required this.success,
this.stdout,
this.stderr,
this.exitCode,
this.shouldContinue,
this.message,
this.hookOutput,
});
/// Parse hook output JSON (for structured responses)
static HookResult fromJson(
String jsonStr, {
required int exitCode,
required String stdout,
required String stderr,
}) {
try {
final parsed = jsonDecode(jsonStr) as Map<String, dynamic>;
return HookResult(
success: exitCode == 0,
stdout: stdout,
stderr: stderr,
exitCode: exitCode,
shouldContinue: parsed['continue'] as bool? ?? true,
message: parsed['message'] as String?,
hookOutput: parsed,
);
} catch (e) {
return HookResult(
success: exitCode == 0,
stdout: stdout,
stderr: stderr,
exitCode: exitCode,
);
}
}
String get displayOutput => stdout ?? stderr ?? '';
}
+260
View File
@@ -0,0 +1,260 @@
import 'dart:convert';
import 'dart:io';
import 'hook_types.dart';
/// Loads hook configuration from ~/.claude/hooks.yaml or hooks.json
class HookLoader {
/// Load hooks from config files
static Future<List<HookSpec>> loadHooks() async {
final homeDir = Platform.environment['HOME'];
if (homeDir == null) {
return [];
}
final claudeDir = Directory('$homeDir/.claude');
final hooks = <HookSpec>[];
// Try JSON first
final jsonFile = File('${claudeDir.path}/hooks.json');
if (jsonFile.existsSync()) {
try {
final content = await jsonFile.readAsString();
final data = jsonDecode(content) as Map<String, dynamic>;
hooks.addAll(_parseHooksFromConfig(data));
return hooks;
} catch (e) {
print('error loading hooks.json: $e');
}
}
// Try YAML (basic parsing without external deps)
final yamlFile = File('${claudeDir.path}/hooks.yaml');
if (yamlFile.existsSync()) {
try {
final content = await yamlFile.readAsString();
// Basic YAML-to-JSON conversion for hooks config
hooks.addAll(_parseHooksFromYaml(content));
return hooks;
} catch (e) {
print('error loading hooks.yaml: $e');
}
}
return hooks;
}
/// Parse hooks from JSON config
static List<HookSpec> _parseHooksFromConfig(Map<String, dynamic> config) {
final hooks = <HookSpec>[];
config.forEach((eventName, matchers) {
final kind = _hookKindFromString(eventName);
if (kind == null) return;
if (matchers is! List) return;
for (final matcher in matchers) {
if (matcher is! Map<String, dynamic>) continue;
final matcherString = matcher['matcher'] as String?;
final hooksList = matcher['hooks'] as List?;
if (hooksList == null) continue;
for (final hookData in hooksList) {
if (hookData is! Map<String, dynamic>) continue;
try {
final hook = _parseHookCommand(hookData);
if (hook != null) {
hooks.add(HookSpec(
kind: kind,
command: hook,
target: matcherString,
));
}
} catch (e) {
print('error parsing hook: $e');
}
}
}
});
return hooks;
}
/// Parse hooks from YAML content (basic parser)
static List<HookSpec> _parseHooksFromYaml(String content) {
// this is a simplistic parser for basic hook YAML
// for complex YAML, users should use hooks.json instead
final hooks = <HookSpec>[];
// quick and dirty YAML parsing
// look for pattern: EventName: and then " - matcher:" blocks
final lines = content.split('\n');
HookKind? currentKind;
String? currentMatcher;
for (int i = 0; i < lines.length; i++) {
final line = lines[i];
final trimmed = line.trim();
// Check for hook event (no leading spaces)
if (!line.startsWith(' ')) {
final kindMatch = trimmed.split(':')[0];
currentKind = _hookKindFromString(kindMatch);
currentMatcher = null;
continue;
}
if (currentKind == null) continue;
// Check for matcher
if (trimmed.startsWith('- matcher:')) {
currentMatcher = trimmed.substring(10).trim();
continue;
}
// Check for hooks array
if (trimmed.startsWith('hooks:')) {
// next non-empty indented lines are hooks
i++;
while (i < lines.length) {
final hookLine = lines[i];
if (!hookLine.startsWith(' ')) break;
final hookTrimmed = hookLine.trim();
if (hookTrimmed.startsWith('- type:')) {
// parse individual hook
final hookBlock = <String>[];
hookBlock.add(hookTrimmed);
// collect remaining lines for this hook
i++;
while (i < lines.length) {
final nextLine = lines[i];
if (nextLine.trim().isEmpty) {
i++;
continue;
}
if (!nextLine.startsWith(' ')) break;
hookBlock.add(nextLine.trim());
i++;
}
i--; // back up one since the outer loop will increment
try {
final hook = _parseHookFromYamlBlock(hookBlock);
if (hook != null) {
hooks.add(HookSpec(
kind: currentKind,
command: hook,
target: currentMatcher,
));
}
} catch (e) {
print('error parsing YAML hook: $e');
}
} else {
i++;
}
}
i--;
}
}
return hooks;
}
/// Parse single hook from YAML block lines
static HookCommand? _parseHookFromYamlBlock(List<String> lines) {
final map = <String, dynamic>{};
for (final line in lines) {
final colonIndex = line.indexOf(':');
if (colonIndex == -1) continue;
final key = line.substring(0, colonIndex).trim();
final value = line.substring(colonIndex + 1).trim();
// handle basic value types
if (value == 'true' || value == 'false') {
map[key] = value == 'true';
} else if (value.startsWith('[') && value.endsWith(']')) {
// simple array parsing
final items = value
.substring(1, value.length - 1)
.split(',')
.map((s) => s.trim())
.toList();
map[key] = items;
} else {
// remove quotes if present
String stringValue = value;
if ((stringValue.startsWith('"') && stringValue.endsWith('"')) ||
(stringValue.startsWith("'") && stringValue.endsWith("'"))) {
stringValue = stringValue.substring(1, stringValue.length - 1);
}
// try to parse as number
final numValue = num.tryParse(stringValue);
map[key] = numValue ?? stringValue;
}
}
return _parseHookCommand(map);
}
/// Parse a single hook command from a map
static HookCommand? _parseHookCommand(Map<String, dynamic> data) {
final type = data['type'] as String?;
switch (type) {
case 'command':
return BashCommandHook.fromMap(data);
case 'prompt':
return PromptHook.fromMap(data);
case 'http':
return HttpHook.fromMap(data);
case 'agent':
return AgentHook.fromMap(data);
default:
return null;
}
}
/// Convert hook kind string to enum
static HookKind? _hookKindFromString(String name) {
const mapping = {
'PreToolUse': HookKind.preToolUse,
'PostToolUse': HookKind.postToolUse,
'PostToolUseFailure': HookKind.postToolUseFailure,
'PermissionDenied': HookKind.permissionDenied,
'Notification': HookKind.notification,
'UserPromptSubmit': HookKind.userPromptSubmit,
'SessionStart': HookKind.sessionStart,
'SessionEnd': HookKind.sessionEnd,
'Stop': HookKind.stop,
'StopFailure': HookKind.stopFailure,
'SubagentStart': HookKind.subagentStart,
'SubagentStop': HookKind.subagentStop,
'PreCompact': HookKind.preCompact,
'PostCompact': HookKind.postCompact,
'PermissionRequest': HookKind.permissionRequest,
'Setup': HookKind.setup,
'TeammateIdle': HookKind.teammateIdle,
'TaskCreated': HookKind.taskCreated,
'TaskCompleted': HookKind.taskCompleted,
'Elicitation': HookKind.elicitation,
'ElicitationResult': HookKind.elicitationResult,
'ConfigChange': HookKind.configChange,
'InstructionsLoaded': HookKind.instructionsLoaded,
'WorktreeCreate': HookKind.worktreeCreate,
'WorktreeRemove': HookKind.worktreeRemove,
'CwdChanged': HookKind.cwdChanged,
'FileChanged': HookKind.fileChanged,
};
return mapping[name];
}
}
+220
View File
@@ -0,0 +1,220 @@
import 'dart:convert';
import 'dart:io';
import 'hook_context.dart';
import 'hook_types.dart';
/// Executes hooks based on kind and optional target filtering
class HookRunner {
static const _defaultShell = 'bash';
static const _defaultTimeoutSecs = 10 * 60;
final List<HookSpec> hooks;
HookRunner({required this.hooks});
/// Run hooks matching the given kind and optional target
Future<List<HookResult>> runHooksForKind(
HookKind kind, {
String? targetName,
Map<String, dynamic>? input,
dynamic output,
int? exitCode,
Map<String, String>? environment,
Map<String, dynamic>? metadata,
}) async {
final results = <HookResult>[];
// Filter hooks by kind and optional target
final matchingHooks = hooks.where((h) {
if (h.kind != kind) return false;
// If target is required, filter by target
if (targetName != null && h.target != null) {
return h.target == targetName;
}
return true;
}).toList();
for (final hookSpec in matchingHooks) {
// Evaluate condition if present
if (hookSpec.command.ifCondition != null) {
if (!_evaluateCondition(hookSpec.command.ifCondition!)) {
continue;
}
}
final context = HookContext(
kind: kind,
targetName: targetName,
input: input,
output: output,
exitCode: exitCode,
environment: environment,
metadata: metadata,
);
try {
final result = await _executeHook(hookSpec, context);
results.add(result);
// If hook wants to stop, break
if (result.hookOutput != null) {
final shouldContinue = result.hookOutput!['continue'] as bool? ?? true;
if (!shouldContinue) {
break;
}
}
} catch (e) {
print('error executing hook: $e');
results.add(HookResult(
success: false,
stderr: 'error: $e',
exitCode: 1,
));
}
}
return results;
}
/// Execute a single hook
Future<HookResult> _executeHook(HookSpec spec, HookContext context) async {
final command = spec.command;
if (command is BashCommandHook) {
return _executeBashHook(command, context);
} else if (command is HttpHook) {
return _executeHttpHook(command, context);
} else if (command is PromptHook) {
// prompt hooks not implemented yet in CLI version
return HookResult(success: true);
} else if (command is AgentHook) {
// agent hooks not implemented yet in CLI version
return HookResult(success: true);
}
return HookResult(success: false, stderr: 'unsupported hook type');
}
/// Execute a bash command hook
Future<HookResult> _executeBashHook(
BashCommandHook hook,
HookContext context,
) async {
final shell = hook.shell ?? _defaultShell;
final timeout =
Duration(seconds: (hook.timeout?.toInt() ?? _defaultTimeoutSecs));
// prepare environment
final env = Map<String, String>.from(Platform.environment);
env.addAll(context.environment);
// Pass context as JSON via environment
env['HOOK_INPUT'] = context.toJsonString();
try {
final process = await Process.start(
shell,
['-c', hook.command],
environment: env,
workingDirectory: Directory.current.path,
);
// Capture output
final stdout = StringBuffer();
final stderr = StringBuffer();
process.stdout.transform(utf8.decoder).listen((data) {
stdout.write(data);
});
process.stderr.transform(utf8.decoder).listen((data) {
stderr.write(data);
});
// Wait for process with timeout
final exitCode = await process.exitCode.timeout(
timeout,
onTimeout: () {
process.kill();
return 124; // timeout exit code
},
);
final stdoutStr = stdout.toString();
final stderrStr = stderr.toString();
return HookResult.fromJson(
stdoutStr,
exitCode: exitCode,
stdout: stdoutStr,
stderr: stderrStr,
);
} catch (e) {
return HookResult(
success: false,
stderr: 'failed to execute hook: $e',
exitCode: 1,
);
}
}
/// Execute an HTTP hook
Future<HookResult> _executeHttpHook(
HttpHook hook,
HookContext context,
) async {
try {
final client = HttpClient();
final timeout = Duration(
seconds: (hook.timeout?.toInt() ?? _defaultTimeoutSecs),
);
final request = await client.postUrl(Uri.parse(hook.url)).timeout(timeout);
// add headers
if (hook.headers != null) {
hook.headers!.forEach((key, value) {
// interpolate env vars if allowed
String finalValue = value;
if (hook.allowedEnvVars != null) {
for (final varName in hook.allowedEnvVars!) {
final pattern = RegExp(r'\$\{?$varName\}?');
final envValue = Platform.environment[varName] ?? '';
finalValue = finalValue.replaceAll(pattern, envValue);
}
}
request.headers.set(key, finalValue);
});
}
request.headers.set('Content-Type', 'application/json');
// send context as JSON body
request.write(context.toJsonString());
final response = await request.close().timeout(timeout);
final responseBody = await response.transform(utf8.decoder).join();
return HookResult(
success: response.statusCode == 200,
stdout: responseBody,
exitCode: response.statusCode,
);
} catch (e) {
return HookResult(
success: false,
stderr: 'HTTP hook failed: $e',
exitCode: 1,
);
}
}
/// Evaluate condition expression (simple placeholder implementation)
/// Full implementation would parse permission rule syntax
bool _evaluateCondition(String condition) {
// basic placeholder: if condition exists and is non-empty, evaluate to true
// full implementation should parse pattern like "Bash(git *)" or "Read(*.ts)"
return condition.isNotEmpty;
}
}
+261
View File
@@ -0,0 +1,261 @@
enum HookKind {
preToolUse,
postToolUse,
postToolUseFailure,
permissionDenied,
notification,
userPromptSubmit,
sessionStart,
sessionEnd,
stop,
stopFailure,
subagentStart,
subagentStop,
preCompact,
postCompact,
permissionRequest,
setup,
teammateIdle,
taskCreated,
taskCompleted,
elicitation,
elicitationResult,
configChange,
instructionsLoaded,
worktreeCreate,
worktreeRemove,
cwdChanged,
fileChanged,
}
extension HookKindExt on HookKind {
String get displayName {
const names = {
HookKind.preToolUse: 'PreToolUse',
HookKind.postToolUse: 'PostToolUse',
HookKind.postToolUseFailure: 'PostToolUseFailure',
HookKind.permissionDenied: 'PermissionDenied',
HookKind.notification: 'Notification',
HookKind.userPromptSubmit: 'UserPromptSubmit',
HookKind.sessionStart: 'SessionStart',
HookKind.sessionEnd: 'SessionEnd',
HookKind.stop: 'Stop',
HookKind.stopFailure: 'StopFailure',
HookKind.subagentStart: 'SubagentStart',
HookKind.subagentStop: 'SubagentStop',
HookKind.preCompact: 'PreCompact',
HookKind.postCompact: 'PostCompact',
HookKind.permissionRequest: 'PermissionRequest',
HookKind.setup: 'Setup',
HookKind.teammateIdle: 'TeammateIdle',
HookKind.taskCreated: 'TaskCreated',
HookKind.taskCompleted: 'TaskCompleted',
HookKind.elicitation: 'Elicitation',
HookKind.elicitationResult: 'ElicitationResult',
HookKind.configChange: 'ConfigChange',
HookKind.instructionsLoaded: 'InstructionsLoaded',
HookKind.worktreeCreate: 'WorktreeCreate',
HookKind.worktreeRemove: 'WorktreeRemove',
HookKind.cwdChanged: 'CwdChanged',
HookKind.fileChanged: 'FileChanged',
};
return names[this] ?? 'Unknown';
}
}
// hook command types
sealed class HookCommand {
final String? ifCondition;
final double? timeout;
final String? statusMessage;
final bool? once;
HookCommand({
this.ifCondition,
this.timeout,
this.statusMessage,
this.once,
});
}
class BashCommandHook extends HookCommand {
final String command;
final String? shell;
final bool? async;
final bool? asyncRewake;
BashCommandHook({
required this.command,
this.shell,
this.async,
this.asyncRewake,
super.ifCondition,
super.timeout,
super.statusMessage,
super.once,
});
factory BashCommandHook.fromMap(Map<String, dynamic> map) {
return BashCommandHook(
command: map['command'] as String,
shell: map['shell'] as String?,
async: map['async'] as bool?,
asyncRewake: map['asyncRewake'] as bool?,
ifCondition: map['if'] as String?,
timeout: (map['timeout'] as num?)?.toDouble(),
statusMessage: map['statusMessage'] as String?,
once: map['once'] as bool?,
);
}
Map<String, dynamic> toMap() => {
'type': 'command',
'command': command,
if (shell != null) 'shell': shell,
if (async != null) 'async': async,
if (asyncRewake != null) 'asyncRewake': asyncRewake,
if (ifCondition != null) 'if': ifCondition,
if (timeout != null) 'timeout': timeout,
if (statusMessage != null) 'statusMessage': statusMessage,
if (once != null) 'once': once,
};
}
class PromptHook extends HookCommand {
final String prompt;
final String? model;
PromptHook({
required this.prompt,
this.model,
super.ifCondition,
super.timeout,
super.statusMessage,
super.once,
});
factory PromptHook.fromMap(Map<String, dynamic> map) {
return PromptHook(
prompt: map['prompt'] as String,
model: map['model'] as String?,
ifCondition: map['if'] as String?,
timeout: (map['timeout'] as num?)?.toDouble(),
statusMessage: map['statusMessage'] as String?,
once: map['once'] as bool?,
);
}
Map<String, dynamic> toMap() => {
'type': 'prompt',
'prompt': prompt,
if (model != null) 'model': model,
if (ifCondition != null) 'if': ifCondition,
if (timeout != null) 'timeout': timeout,
if (statusMessage != null) 'statusMessage': statusMessage,
if (once != null) 'once': once,
};
}
class HttpHook extends HookCommand {
final String url;
final Map<String, String>? headers;
final List<String>? allowedEnvVars;
HttpHook({
required this.url,
this.headers,
this.allowedEnvVars,
super.ifCondition,
super.timeout,
super.statusMessage,
super.once,
});
factory HttpHook.fromMap(Map<String, dynamic> map) {
return HttpHook(
url: map['url'] as String,
headers: (map['headers'] as Map<String, dynamic>?)?.cast<String, String>(),
allowedEnvVars: (map['allowedEnvVars'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
ifCondition: map['if'] as String?,
timeout: (map['timeout'] as num?)?.toDouble(),
statusMessage: map['statusMessage'] as String?,
once: map['once'] as bool?,
);
}
Map<String, dynamic> toMap() => {
'type': 'http',
'url': url,
if (headers != null) 'headers': headers,
if (allowedEnvVars != null) 'allowedEnvVars': allowedEnvVars,
if (ifCondition != null) 'if': ifCondition,
if (timeout != null) 'timeout': timeout,
if (statusMessage != null) 'statusMessage': statusMessage,
if (once != null) 'once': once,
};
}
class AgentHook extends HookCommand {
final String prompt;
final String? model;
AgentHook({
required this.prompt,
this.model,
super.ifCondition,
super.timeout,
super.statusMessage,
super.once,
});
factory AgentHook.fromMap(Map<String, dynamic> map) {
return AgentHook(
prompt: map['prompt'] as String,
model: map['model'] as String?,
ifCondition: map['if'] as String?,
timeout: (map['timeout'] as num?)?.toDouble(),
statusMessage: map['statusMessage'] as String?,
once: map['once'] as bool?,
);
}
Map<String, dynamic> toMap() => {
'type': 'agent',
'prompt': prompt,
if (model != null) 'model': model,
if (ifCondition != null) 'if': ifCondition,
if (timeout != null) 'timeout': timeout,
if (statusMessage != null) 'statusMessage': statusMessage,
if (once != null) 'once': once,
};
}
// hook spec represents a parsed hook configuration
class HookSpec {
final HookKind kind;
final HookCommand command;
final String? target; // for hooks with matchers (tool_name, notification_type, etc)
HookSpec({
required this.kind,
required this.command,
this.target,
});
String getDisplayText() {
if (command is BashCommandHook) {
return (command as BashCommandHook).command;
} else if (command is PromptHook) {
return (command as PromptHook).prompt;
} else if (command is AgentHook) {
return (command as AgentHook).prompt;
} else if (command is HttpHook) {
return (command as HttpHook).url;
}
return command.statusMessage ?? 'unknown';
}
}
+101
View File
@@ -0,0 +1,101 @@
// Loads ~/.claude/keybindings.json and merges with defaults
// Ported from old_repo/keybindings/loadUserBindings.ts
import "dart:convert";
import "dart:io";
import "../local_state.dart";
import "keybindings_types.dart";
String getKeybindingsPath() {
final home = Platform.environment["HOME"];
if (home == null || home.isEmpty) {
return joinPath(Directory.current.path, ".claude/keybindings.json");
}
return joinPath(home, ".claude/keybindings.json");
}
// parse a single binding block from JSON
List<KeyBinding> _parseBlock(Map<String, dynamic> block) {
final contextStr = block["context"] as String?;
if (contextStr == null) return [];
final ctx = keyContextFromString(contextStr);
if (ctx == null) return [];
final bindings = block["bindings"];
if (bindings == null || bindings is! Map) return [];
final result = <KeyBinding>[];
for (final entry in (bindings as Map).entries) {
final keystroke = entry.key as String;
final actionVal = entry.value;
// null means unbind
final String? action = actionVal == null ? null : actionVal.toString();
result.add(KeyBinding(context: ctx, keystroke: keystroke, action: action));
}
return result;
}
// load and parse user keybindings from disk
// returns empty list if file doesnt exist or is malformed
List<KeyBinding> loadKeybindings() {
final path = getKeybindingsPath();
try {
final content = File(path).readAsStringSync();
final parsed = jsonDecode(content);
if (parsed is! Map<String, dynamic>) return [];
final bindingsVal = parsed["bindings"];
if (bindingsVal == null || bindingsVal is! List) return [];
final result = <KeyBinding>[];
for (final block in bindingsVal) {
if (block is Map<String, dynamic>) {
result.addAll(_parseBlock(block));
}
}
return result;
} on FileSystemException {
// file doesn't exist - thats fine
return [];
} on FormatException {
// malformed JSON
return [];
} catch (_) {
return [];
}
}
// find the action for a given keystroke in a given context
// checks context-specific bindings first, then Global
String? resolveKeybinding(
List<KeyBinding> bindings,
String keystroke,
KeyContext context,
) {
// check context-specific first
for (final b in bindings) {
if (b.context == context && b.keystroke == keystroke) {
return b.action;
}
}
// fall back to global
for (final b in bindings) {
if (b.context == KeyContext.global_ && b.keystroke == keystroke) {
return b.action;
}
}
return null;
}
@@ -0,0 +1,69 @@
// keybinding types
// ported from old_repo/keybindings/schema.ts
// contexts where keybindings apply
enum KeyContext {
global_,
chat,
autocomplete,
confirmation,
help,
transcript,
historySearch,
task,
themePicker,
settings,
tabs,
attachments,
footer,
messageSelector,
diffDialog,
modelPicker,
select,
plugin_,
}
KeyContext? keyContextFromString(String s) {
switch (s) {
case "Global": return KeyContext.global_;
case "Chat": return KeyContext.chat;
case "Autocomplete": return KeyContext.autocomplete;
case "Confirmation": return KeyContext.confirmation;
case "Help": return KeyContext.help;
case "Transcript": return KeyContext.transcript;
case "HistorySearch": return KeyContext.historySearch;
case "Task": return KeyContext.task;
case "ThemePicker": return KeyContext.themePicker;
case "Settings": return KeyContext.settings;
case "Tabs": return KeyContext.tabs;
case "Attachments": return KeyContext.attachments;
case "Footer": return KeyContext.footer;
case "MessageSelector": return KeyContext.messageSelector;
case "DiffDialog": return KeyContext.diffDialog;
case "ModelPicker": return KeyContext.modelPicker;
case "Select": return KeyContext.select;
case "Plugin": return KeyContext.plugin_;
default: return null;
}
}
// a single parsed keybinding entry
class KeyBinding {
const KeyBinding({
required this.context,
required this.keystroke,
required this.action,
});
final KeyContext context;
// e.g. "ctrl+k" or "shift+tab"
final String keystroke;
// action string like "app:interrupt" or "command:help", or null to unbind
final String? action;
@override
String toString() => "KeyBinding($context, $keystroke => $action)";
}
+537
View File
@@ -0,0 +1,537 @@
import 'command.dart';
const legacySourceFileCount = 1902;
const legacyCommandInventory = <LegacyCommandDescriptor>[
LegacyCommandDescriptor(
name: 'add-dir',
legacySourcePath: 'old_repo/commands/add-dir/index.ts',
),
LegacyCommandDescriptor(
name: 'advisor',
legacySourcePath: 'old_repo/commands/advisor.ts',
),
LegacyCommandDescriptor(
name: 'agents',
legacySourcePath: 'old_repo/commands/agents/index.ts',
),
LegacyCommandDescriptor(
name: 'ant-trace',
legacySourcePath: 'old_repo/commands/ant-trace/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'autofix-pr',
legacySourcePath: 'old_repo/commands/autofix-pr/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'backfill-sessions',
legacySourcePath: 'old_repo/commands/backfill-sessions/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'branch',
legacySourcePath: 'old_repo/commands/branch/index.ts',
),
LegacyCommandDescriptor(
name: 'break-cache',
legacySourcePath: 'old_repo/commands/break-cache/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'bridge-kick',
legacySourcePath: 'old_repo/commands/bridge-kick.ts',
),
LegacyCommandDescriptor(
name: 'brief',
legacySourcePath: 'old_repo/commands/brief.ts',
),
LegacyCommandDescriptor(
name: 'btw',
legacySourcePath: 'old_repo/commands/btw/index.ts',
),
LegacyCommandDescriptor(
name: 'bughunter',
legacySourcePath: 'old_repo/commands/bughunter/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'chrome',
legacySourcePath: 'old_repo/commands/chrome/index.ts',
),
LegacyCommandDescriptor(
name: 'clear',
legacySourcePath: 'old_repo/commands/clear/index.ts',
aliases: ['reset', 'new'],
),
LegacyCommandDescriptor(
name: 'color',
legacySourcePath: 'old_repo/commands/color/index.ts',
),
LegacyCommandDescriptor(
name: 'commit',
legacySourcePath: 'old_repo/commands/commit.ts',
),
LegacyCommandDescriptor(
name: 'commit-push-pr',
legacySourcePath: 'old_repo/commands/commit-push-pr.ts',
),
LegacyCommandDescriptor(
name: 'compact',
legacySourcePath: 'old_repo/commands/compact/index.ts',
),
LegacyCommandDescriptor(
name: 'config',
legacySourcePath: 'old_repo/commands/config/index.ts',
aliases: ['settings'],
),
LegacyCommandDescriptor(
name: 'context',
legacySourcePath: 'old_repo/commands/context/index.ts',
),
LegacyCommandDescriptor(
name: 'copy',
legacySourcePath: 'old_repo/commands/copy/index.ts',
),
LegacyCommandDescriptor(
name: 'cost',
legacySourcePath: 'old_repo/commands/cost/index.ts',
),
LegacyCommandDescriptor(
name: 'ctx-viz',
legacySourcePath: 'old_repo/commands/ctx_viz/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'debug-tool-call',
legacySourcePath: 'old_repo/commands/debug-tool-call/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'desktop',
legacySourcePath: 'old_repo/commands/desktop/index.ts',
aliases: ['app'],
),
LegacyCommandDescriptor(
name: 'diff',
legacySourcePath: 'old_repo/commands/diff/index.ts',
),
LegacyCommandDescriptor(
name: 'doctor',
legacySourcePath: 'old_repo/commands/doctor/index.ts',
),
LegacyCommandDescriptor(
name: 'effort',
legacySourcePath: 'old_repo/commands/effort/index.ts',
),
LegacyCommandDescriptor(
name: 'env',
legacySourcePath: 'old_repo/commands/env/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'exit',
legacySourcePath: 'old_repo/commands/exit/index.ts',
aliases: ['quit'],
),
LegacyCommandDescriptor(
name: 'export',
legacySourcePath: 'old_repo/commands/export/index.ts',
),
LegacyCommandDescriptor(
name: 'extra-usage',
legacySourcePath: 'old_repo/commands/extra-usage/index.ts',
),
LegacyCommandDescriptor(
name: 'fast',
legacySourcePath: 'old_repo/commands/fast/index.ts',
),
LegacyCommandDescriptor(
name: 'feedback',
legacySourcePath: 'old_repo/commands/feedback/index.ts',
aliases: ['bug'],
),
LegacyCommandDescriptor(
name: 'files',
legacySourcePath: 'old_repo/commands/files/index.ts',
),
LegacyCommandDescriptor(
name: 'good-claude',
legacySourcePath: 'old_repo/commands/good-claude/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'heapdump',
legacySourcePath: 'old_repo/commands/heapdump/index.ts',
),
LegacyCommandDescriptor(
name: 'help',
legacySourcePath: 'old_repo/commands/help/index.ts',
description: 'Show help and available commands',
),
LegacyCommandDescriptor(
name: 'hooks',
legacySourcePath: 'old_repo/commands/hooks/index.ts',
),
LegacyCommandDescriptor(
name: 'ide',
legacySourcePath: 'old_repo/commands/ide/index.ts',
),
LegacyCommandDescriptor(
name: 'init',
legacySourcePath: 'old_repo/commands/init.ts',
),
LegacyCommandDescriptor(
name: 'init-verifiers',
legacySourcePath: 'old_repo/commands/init-verifiers.ts',
),
LegacyCommandDescriptor(
name: 'insights',
legacySourcePath: 'old_repo/commands/insights.ts',
),
LegacyCommandDescriptor(
name: 'install-github-app',
legacySourcePath: 'old_repo/commands/install-github-app/index.ts',
),
LegacyCommandDescriptor(
name: 'install-slack-app',
legacySourcePath: 'old_repo/commands/install-slack-app/index.ts',
),
LegacyCommandDescriptor(
name: 'issue',
legacySourcePath: 'old_repo/commands/issue/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'keybindings',
legacySourcePath: 'old_repo/commands/keybindings/index.ts',
),
LegacyCommandDescriptor(
name: 'login',
legacySourcePath: 'old_repo/commands/login/index.ts',
),
LegacyCommandDescriptor(
name: 'logout',
legacySourcePath: 'old_repo/commands/logout/index.ts',
),
LegacyCommandDescriptor(
name: 'mcp',
legacySourcePath: 'old_repo/commands/mcp/index.ts',
),
LegacyCommandDescriptor(
name: 'memory',
legacySourcePath: 'old_repo/commands/memory/index.ts',
),
LegacyCommandDescriptor(
name: 'mobile',
legacySourcePath: 'old_repo/commands/mobile/index.ts',
aliases: ['ios', 'android'],
),
LegacyCommandDescriptor(
name: 'mock-limits',
legacySourcePath: 'old_repo/commands/mock-limits/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'model',
legacySourcePath: 'old_repo/commands/model/index.ts',
),
LegacyCommandDescriptor(
name: 'oauth-refresh',
legacySourcePath: 'old_repo/commands/oauth-refresh/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'onboarding',
legacySourcePath: 'old_repo/commands/onboarding/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'output-style',
legacySourcePath: 'old_repo/commands/output-style/index.ts',
),
LegacyCommandDescriptor(
name: 'passes',
legacySourcePath: 'old_repo/commands/passes/index.ts',
),
LegacyCommandDescriptor(
name: 'perf-issue',
legacySourcePath: 'old_repo/commands/perf-issue/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'permissions',
legacySourcePath: 'old_repo/commands/permissions/index.ts',
aliases: ['allowed-tools'],
),
LegacyCommandDescriptor(
name: 'plan',
legacySourcePath: 'old_repo/commands/plan/index.ts',
),
LegacyCommandDescriptor(
name: 'plugin',
legacySourcePath: 'old_repo/commands/plugin/index.tsx',
aliases: ['plugins', 'marketplace'],
),
LegacyCommandDescriptor(
name: 'pr-comments',
legacySourcePath: 'old_repo/commands/pr_comments/index.ts',
),
LegacyCommandDescriptor(
name: 'privacy-settings',
legacySourcePath: 'old_repo/commands/privacy-settings/index.ts',
),
LegacyCommandDescriptor(
name: 'rate-limit-options',
legacySourcePath: 'old_repo/commands/rate-limit-options/index.ts',
),
LegacyCommandDescriptor(
name: 'release-notes',
legacySourcePath: 'old_repo/commands/release-notes/index.ts',
),
LegacyCommandDescriptor(
name: 'reload-plugins',
legacySourcePath: 'old_repo/commands/reload-plugins/index.ts',
),
LegacyCommandDescriptor(
name: 'remote-control',
legacySourcePath: 'old_repo/commands/bridge/index.ts',
aliases: ['rc'],
),
LegacyCommandDescriptor(
name: 'remote-env',
legacySourcePath: 'old_repo/commands/remote-env/index.ts',
),
LegacyCommandDescriptor(
name: 'rename',
legacySourcePath: 'old_repo/commands/rename/index.ts',
),
LegacyCommandDescriptor(
name: 'reset-limits',
legacySourcePath: 'old_repo/commands/reset-limits/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'resume',
legacySourcePath: 'old_repo/commands/resume/index.ts',
aliases: ['continue'],
),
LegacyCommandDescriptor(
name: 'review',
legacySourcePath: 'old_repo/commands/review.ts',
),
LegacyCommandDescriptor(
name: 'rewind',
legacySourcePath: 'old_repo/commands/rewind/index.ts',
aliases: ['checkpoint'],
),
LegacyCommandDescriptor(
name: 'sandbox',
legacySourcePath: 'old_repo/commands/sandbox-toggle/index.ts',
),
LegacyCommandDescriptor(
name: 'security-review',
legacySourcePath: 'old_repo/commands/security-review.ts',
),
LegacyCommandDescriptor(
name: 'session',
legacySourcePath: 'old_repo/commands/session/index.ts',
aliases: ['remote'],
),
LegacyCommandDescriptor(
name: 'share',
legacySourcePath: 'old_repo/commands/share/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'skills',
legacySourcePath: 'old_repo/commands/skills/index.ts',
),
LegacyCommandDescriptor(
name: 'stats',
legacySourcePath: 'old_repo/commands/stats/index.ts',
),
LegacyCommandDescriptor(
name: 'status',
legacySourcePath: 'old_repo/commands/status/index.ts',
description:
'Show Claude Code status including version, model, account, API connectivity, and tool statuses',
),
LegacyCommandDescriptor(
name: 'statusline',
legacySourcePath: 'old_repo/commands/statusline.tsx',
),
LegacyCommandDescriptor(
name: 'stickers',
legacySourcePath: 'old_repo/commands/stickers/index.ts',
),
LegacyCommandDescriptor(
name: 'summary',
legacySourcePath: 'old_repo/commands/summary/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'tag',
legacySourcePath: 'old_repo/commands/tag/index.ts',
),
LegacyCommandDescriptor(
name: 'tasks',
legacySourcePath: 'old_repo/commands/tasks/index.ts',
aliases: ['bashes'],
),
LegacyCommandDescriptor(
name: 'teleport',
legacySourcePath: 'old_repo/commands/teleport/index.js',
isInferred: true,
),
LegacyCommandDescriptor(
name: 'terminal-setup',
legacySourcePath: 'old_repo/commands/terminalSetup/index.ts',
),
LegacyCommandDescriptor(
name: 'theme',
legacySourcePath: 'old_repo/commands/theme/index.ts',
),
LegacyCommandDescriptor(
name: 'think-back',
legacySourcePath: 'old_repo/commands/thinkback/index.ts',
),
LegacyCommandDescriptor(
name: 'thinkback-play',
legacySourcePath: 'old_repo/commands/thinkback-play/index.ts',
),
LegacyCommandDescriptor(
name: 'ultrareview',
legacySourcePath: 'old_repo/commands/review.ts',
),
LegacyCommandDescriptor(
name: 'upgrade',
legacySourcePath: 'old_repo/commands/upgrade/index.ts',
),
LegacyCommandDescriptor(
name: 'usage',
legacySourcePath: 'old_repo/commands/usage/index.ts',
),
LegacyCommandDescriptor(
name: 'version',
legacySourcePath: 'old_repo/commands/version.ts',
description:
'Print the version this session is running (not what autoupdate downloaded)',
kind: CommandKind.local,
),
LegacyCommandDescriptor(
name: 'vim',
legacySourcePath: 'old_repo/commands/vim/index.ts',
),
LegacyCommandDescriptor(
name: 'voice',
legacySourcePath: 'old_repo/commands/voice/index.ts',
),
LegacyCommandDescriptor(
name: 'web-setup',
legacySourcePath: 'old_repo/commands/remote-setup/index.ts',
),
];
const legacyTopLevelEntryPoints = <LegacyCommandDescriptor>[
LegacyCommandDescriptor(
name: 'remote-control',
aliases: ['rc', 'remote', 'sync', 'bridge'],
description: 'Fast-path remote control bootstrap entrypoint',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: 'daemon',
description: 'Fast-path daemon supervisor entrypoint',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: 'ps',
description: 'Background session management entrypoint',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: 'logs',
description: 'Background session log entrypoint',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: 'attach',
description: 'Background session attach entrypoint',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: 'kill',
description: 'Background session termination entrypoint',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: 'new',
description: 'Template job entrypoint',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: 'list',
description: 'Template job listing entrypoint',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: 'reply',
description: 'Template job reply entrypoint',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: '--daemon-worker',
description: 'Internal daemon worker bootstrap flag',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: '--dump-system-prompt',
description: 'System prompt dump fast path',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: '--claude-in-chrome-mcp',
description: 'Claude-in-Chrome MCP server bootstrap flag',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: '--chrome-native-host',
description: 'Chrome native host bootstrap flag',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
LegacyCommandDescriptor(
name: '--computer-use-mcp',
description: 'Computer use MCP server bootstrap flag',
legacySourcePath: 'old_repo/entrypoints/cli.tsx',
kind: CommandKind.reservedEntryPoint,
surface: InvocationSurface.topLevel,
),
];
+381
View File
@@ -0,0 +1,381 @@
import 'dart:convert';
import 'dart:io';
const supportedThemeSettings = <String>[
'auto',
'dark',
'light',
'light-daltonized',
'dark-daltonized',
'light-ansi',
'dark-ansi',
];
const supportedThemeNames = <String>[
'dark',
'light',
'light-daltonized',
'dark-daltonized',
'light-ansi',
'dark-ansi',
];
const supportedAgentColors = <String>[
'red',
'blue',
'green',
'yellow',
'purple',
'orange',
'pink',
'cyan',
];
const supportedEffortLevels = <String>['low', 'medium', 'high', 'max'];
const supportedPermissionModes = <String>[
'acceptEdits',
'auto',
'bubble',
'bypassPermissions',
'default',
'dontAsk',
'plan',
];
String getConfigHomeDir() {
final home = Platform.environment['HOME'];
if (home == null || home.isEmpty) {
return joinPath(Directory.current.path, '.clawd_code');
}
return joinPath(home, '.clawd_code');
}
String getSettingsFilePath() {
return joinPath(getConfigHomeDir(), 'settings.json');
}
String getPlansDirectoryPath() {
return joinPath(getConfigHomeDir(), 'plans');
}
String joinPath(String base, String child) {
if (base.endsWith(Platform.pathSeparator)) {
return '$base$child';
}
return '$base${Platform.pathSeparator}$child';
}
class LocalSettings {
const LocalSettings({
this.advisorModel,
this.alwaysAllowRules = const <String>[],
this.alwaysAskRules = const <String>[],
this.alwaysDenyRules = const <String>[],
this.editorMode = 'normal',
this.effortLevel,
this.fastMode = false,
this.hooks,
this.mcpServers,
this.model,
this.openRouterApiKey,
this.outputStyle,
this.permissionMode = 'default',
this.privacyLevel,
this.statusLinePrompt,
this.telemetry,
this.theme = 'dark',
});
factory LocalSettings.fromJson(Map<String, dynamic> json) {
return LocalSettings(
advisorModel: _readString(json, 'advisorModel'),
alwaysAllowRules: _readStringList(json, 'alwaysAllowRules'),
alwaysAskRules: _readStringList(json, 'alwaysAskRules'),
alwaysDenyRules: _readStringList(json, 'alwaysDenyRules'),
editorMode: _readString(json, 'editorMode') ?? 'normal',
effortLevel: _readString(json, 'effortLevel'),
fastMode: _readBool(json, 'fastMode') ?? false,
hooks: _readStringMap(json, 'hooks'),
mcpServers: _readMcpServers(json),
model: _readString(json, 'model'),
openRouterApiKey: _readString(json, 'openRouterApiKey'),
outputStyle: _readString(json, 'outputStyle'),
permissionMode: _readString(json, 'permissionMode') ?? 'default',
privacyLevel: _readString(json, 'privacyLevel'),
statusLinePrompt: _readString(json, 'statusLinePrompt'),
telemetry: _readString(json, 'telemetry'),
theme: _readString(json, 'theme') ?? 'dark',
);
}
// advisor model name - optional
final String? advisorModel;
final List<String> alwaysAllowRules;
final List<String> alwaysAskRules;
final List<String> alwaysDenyRules;
final String editorMode;
final String? effortLevel;
final bool fastMode;
// hook configs keyed by event name
final Map<String, String>? hooks;
// mcp server configs - each entry is a map of server name -> config object
final Map<String, Map<String, dynamic>>? mcpServers;
final String? model;
final String? openRouterApiKey;
final String? outputStyle;
final String permissionMode;
final String? privacyLevel;
final String? statusLinePrompt;
final String? telemetry;
final String theme;
LocalSettings copyWith({
Object? advisorModel = _sentinel,
List<String>? alwaysAllowRules,
List<String>? alwaysAskRules,
List<String>? alwaysDenyRules,
String? editorMode,
Object? effortLevel = _sentinel,
bool? fastMode,
Object? hooks = _sentinel,
Object? mcpServers = _sentinel,
Object? model = _sentinel,
Object? openRouterApiKey = _sentinel,
Object? outputStyle = _sentinel,
String? permissionMode,
Object? privacyLevel = _sentinel,
Object? statusLinePrompt = _sentinel,
Object? telemetry = _sentinel,
String? theme,
}) {
return LocalSettings(
advisorModel: identical(advisorModel, _sentinel) ? this.advisorModel : advisorModel 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?,
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>>?,
model: identical(model, _sentinel) ? this.model : model as String?,
openRouterApiKey: identical(openRouterApiKey, _sentinel) ? this.openRouterApiKey : openRouterApiKey as String?,
outputStyle: identical(outputStyle, _sentinel)
? this.outputStyle
: outputStyle as String?,
permissionMode: permissionMode ?? this.permissionMode,
privacyLevel: identical(privacyLevel, _sentinel) ? this.privacyLevel : privacyLevel as String?,
statusLinePrompt: identical(statusLinePrompt, _sentinel)
? this.statusLinePrompt
: statusLinePrompt as String?,
telemetry: identical(telemetry, _sentinel) ? this.telemetry : telemetry as String?,
theme: theme ?? this.theme,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'advisorModel': advisorModel,
'alwaysAllowRules': alwaysAllowRules,
'alwaysAskRules': alwaysAskRules,
'alwaysDenyRules': alwaysDenyRules,
'editorMode': editorMode,
'effortLevel': effortLevel,
'fastMode': fastMode,
'hooks': hooks,
'mcpServers': mcpServers,
'model': model,
'openRouterApiKey': openRouterApiKey,
'outputStyle': outputStyle,
'permissionMode': permissionMode,
'privacyLevel': privacyLevel,
'statusLinePrompt': statusLinePrompt,
'telemetry': telemetry,
'theme': theme,
};
}
}
class SessionState {
SessionState({required this.workingDirectory, String? effortValue})
: effortValue = effortValue,
startedAt = DateTime.now().toUtc();
String? effortValue;
int commandsExecuted = 0;
bool planModeEnabled = false;
bool briefModeEnabled = false;
bool bughunterMode = false;
String? sessionColor;
// set via /advisor
String? advisorModel;
// tag toggled via /tag command
String? sessionTag;
// name set via /rename
String? sessionName;
// extra dirs added via /add-dir
final List<String> additionalDirectories = [];
final DateTime startedAt;
final String workingDirectory;
String get planFilePath {
return joinPath(getPlansDirectoryPath(), 'active-plan.md');
}
Future<String?> readPlan() async {
final file = File(planFilePath);
if (!await file.exists()) {
return null;
}
return file.readAsString();
}
Future<void> writePlan(String content) async {
final directory = Directory(getPlansDirectoryPath());
await directory.create(recursive: true);
await File(planFilePath).writeAsString(content);
}
}
class SettingsStore {
SettingsStore._({required this.path, required this.settings});
static const _encoder = JsonEncoder.withIndent(' ');
final String path;
LocalSettings settings;
static Future<SettingsStore> load() async {
final path = getSettingsFilePath();
final file = File(path);
if (!await file.exists()) {
final store = SettingsStore._(
path: path,
settings: const LocalSettings(),
);
await store.save();
return store;
}
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
return SettingsStore._(
path: path,
settings: LocalSettings.fromJson(decoded),
);
}
if (decoded is Map) {
return SettingsStore._(
path: path,
settings: LocalSettings.fromJson(
decoded.map((key, value) => MapEntry(key.toString(), value)),
),
);
}
} catch (_) {
// Fall back to defaults if the file is unreadable.
}
final store = SettingsStore._(path: path, settings: const LocalSettings());
await store.save();
return store;
}
Future<void> save() async {
final file = File(path);
await file.parent.create(recursive: true);
await file.writeAsString('${_encoder.convert(settings.toJson())}\n');
}
Future<void> update(
LocalSettings Function(LocalSettings current) transform,
) async {
settings = transform(settings);
await save();
}
}
const _sentinel = Object();
bool? _readBool(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is bool) {
return value;
}
return null;
}
String? _readString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is String && value.isNotEmpty) {
return value;
}
return null;
}
List<String> _readStringList(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! List) {
return const <String>[];
}
return value
.whereType<String>()
.where((item) => item.trim().isNotEmpty)
.toList(growable: false);
}
Map<String, String>? _readStringMap(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is! Map) {
return null;
}
final result = <String, String>{};
for (final entry in value.entries) {
if (entry.key is String && entry.value is String) {
result[entry.key as String] = entry.value as String;
}
}
return result.isEmpty ? null : result;
}
Map<String, Map<String, dynamic>>? _readMcpServers(Map<String, dynamic> json) {
final value = json['mcpServers'];
if (value is! Map) {
return null;
}
final result = <String, Map<String, dynamic>>{};
for (final entry in value.entries) {
if (entry.key is! String) continue;
final serverCfg = entry.value;
if (serverCfg is Map) {
result[entry.key as String] = serverCfg.map(
(k, v) => MapEntry(k.toString(), v),
);
}
}
return result.isEmpty ? null : result;
}
+294
View File
@@ -0,0 +1,294 @@
import "dart:async";
import "dart:convert";
import "dart:io";
import "mcp_types.dart";
// timeout for initial connection handshake
const int _kConnectTimeoutMs = 30000;
// timeout for individual rpc calls (like listTools, callTool etc)
const int _kRequestTimeoutMs = 60000;
/// McpClient manages a single MCP server subprocess (stdio transport).
/// Handles the JSON-RPC 2.0 framing, subprocess lifecycle, and request/response matching.
class McpClient {
McpClient(this.config);
final McpServerConfig config;
Process? _process;
StreamSubscription<String>? _stdoutSub;
StreamSubscription<List<int>>? _stderrSub;
// pending rpc calls waiting for a response
final Map<int, Completer<Map<String, dynamic>>> _pendingRequests = {};
int _nextId = 1;
bool _connected = false;
bool get isConnected => _connected;
// stderr output - kept for debugging failed connections
String _stderrBuf = "";
/// Connect to the server by spawning the subprocess and doing the initialize handshake
Future<void> connect() async {
if (_connected) return;
final stdioConf = config.stdioConfig;
if (stdioConf == null) {
throw StateError("McpClient.connect() only suppots stdio servers, got type=${config.type}");
}
final env = <String, String>{
...Platform.environment,
...?stdioConf.env,
};
_process = await Process.start(
stdioConf.command,
stdioConf.args,
environment: env,
runInShell: false,
);
// pipe stderr to internal buffer so it doesnt leak to the terminal
_stderrSub = _process!.stderr.listen((data) {
if (_stderrBuf.length < 64 * 1024) {
_stderrBuf += utf8.decode(data, allowMalformed: true);
}
});
// read stdout line by line - each line is a complete JSON-RPC message
_stdoutSub = _process!.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(_handleLine, onError: _handleReadError, onDone: _handleDone);
// do the MCP initialize handshake
await _initialize();
_connected = true;
}
Future<void> _initialize() async {
final result = await _sendRequest(
"initialize",
<String, dynamic>{
"protocolVersion": "2024-11-05",
"capabilities": <String, dynamic>{
"roots": <String, dynamic>{},
},
"clientInfo": <String, dynamic>{
"name": "clawd-code",
"version": "0.1.0",
},
},
timeoutMs: _kConnectTimeoutMs,
);
// send the initialized notification so server knows we're ready
_sendNotification("notifications/initialized", <String, dynamic>{});
// ignore result for now - could parse serverInfo out of it later
}
/// Disconnect and kill the subprocess
Future<void> disconnect() async {
if (!_connected && _process == null) return;
_connected = false;
// cancel subscriptions first
await _stdoutSub?.cancel();
await _stderrSub?.cancel();
_stdoutSub = null;
_stderrSub = null;
// fail all pending requests
for (final completer in _pendingRequests.values) {
if (!completer.isCompleted) {
completer.completeError(StateError("MCP client disconnected"));
}
}
_pendingRequests.clear();
// kill the process
_process?.kill();
await _process?.exitCode.timeout(
const Duration(seconds: 3),
onTimeout: () {
_process?.kill(ProcessSignal.sigkill);
return -1;
},
);
_process = null;
}
/// List all tools exposed by this server
Future<List<McpTool>> listTools() async {
final result = await _sendRequest("tools/list", <String, dynamic>{});
final tools = result["tools"] as List<dynamic>? ?? <dynamic>[];
return tools
.map((t) => McpTool.fromJson(config.name, Map<String, dynamic>.from(t as Map)))
.toList();
}
/// List all resources exposed by this server
Future<List<McpResource>> listResources() async {
final result = await _sendRequest("resources/list", <String, dynamic>{});
final resources = result["resources"] as List<dynamic>? ?? <dynamic>[];
return resources
.map((r) => McpResource.fromJson(config.name, Map<String, dynamic>.from(r as Map)))
.toList();
}
/// List prompt templates on this server
Future<List<McpPrompt>> listPrompts() async {
final result = await _sendRequest("prompts/list", <String, dynamic>{});
final prompts = result["prompts"] as List<dynamic>? ?? <dynamic>[];
return prompts
.map((p) => McpPrompt.fromJson(config.name, Map<String, dynamic>.from(p as Map)))
.toList();
}
/// Call a tool by name with the given arguments
Future<McpToolResult> callTool(
String toolName,
Map<String, dynamic> arguments,
) async {
final result = await _sendRequest(
"tools/call",
<String, dynamic>{
"name": toolName,
"arguments": arguments,
},
);
return McpToolResult.fromJson(result);
}
// --- JSON-RPC internals ---
void _handleLine(String line) {
final trimmed = line.trim();
if (trimmed.isEmpty) return;
Map<String, dynamic> msg;
try {
msg = jsonDecode(trimmed) as Map<String, dynamic>;
} catch (e) {
// not json, probably debug output from the server - ignore
return;
}
final id = msg["id"];
if (id == null) {
// notification from server - nothing to do for now
return;
}
final intId = (id is int) ? id : int.tryParse(id.toString());
if (intId == null) return;
final completer = _pendingRequests.remove(intId);
if (completer == null) return;
if (msg.containsKey("error")) {
final err = msg["error"] as Map<String, dynamic>? ?? <String, dynamic>{};
final code = err["code"] ?? 0;
final message = err["message"] ?? "unknown error";
completer.completeError(McpRpcError(code as int, message as String));
} else {
final resultData = msg["result"];
if (resultData is Map<String, dynamic>) {
completer.complete(resultData);
} else {
// some servers return null for notificaitons/initialized ack
completer.complete(<String, dynamic>{});
}
}
}
void _handleReadError(Object err) {
// if we get a read error while connected, fail pending requests
for (final c in _pendingRequests.values) {
if (!c.isCompleted) c.completeError(err);
}
_pendingRequests.clear();
_connected = false;
}
void _handleDone() {
_connected = false;
for (final c in _pendingRequests.values) {
if (!c.isCompleted) {
c.completeError(StateError("MCP server process exited unexpectedly"));
}
}
_pendingRequests.clear();
}
Future<Map<String, dynamic>> _sendRequest(
String method,
Map<String, dynamic> params, {
int timeoutMs = _kRequestTimeoutMs,
}) async {
final id = _nextId++;
final msg = jsonEncode(<String, dynamic>{
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
});
final completer = Completer<Map<String, dynamic>>();
_pendingRequests[id] = completer;
try {
_process!.stdin.writeln(msg);
await _process!.stdin.flush();
} catch (e) {
_pendingRequests.remove(id);
rethrow;
}
return completer.future.timeout(
Duration(milliseconds: timeoutMs),
onTimeout: () {
_pendingRequests.remove(id);
throw TimeoutException(
"MCP request timed out: $method",
Duration(milliseconds: timeoutMs),
);
},
);
}
void _sendNotification(String method, Map<String, dynamic> params) {
if (_process == null) return;
final msg = jsonEncode(<String, dynamic>{
"jsonrpc": "2.0",
"method": method,
"params": params,
});
try {
_process!.stdin.writeln(msg);
// dont await flush for notifications - fire and forget
} catch (_) {
// ignore notification send errors
}
}
}
/// Error returned from the MCP server via JSON-RPC error response
class McpRpcError implements Exception {
const McpRpcError(this.code, this.message);
final int code;
final String message;
@override
String toString() => "McpRpcError($code): $message";
}
+184
View File
@@ -0,0 +1,184 @@
import "dart:async";
import "../local_state.dart";
import "mcp_client.dart";
import "mcp_types.dart";
/// McpManager holds all MCP server connections for a session.
/// It reads configs from LocalSettings.mcpServers, starts/stops clients,
/// and provides cross-server tool lookup.
class McpManager {
McpManager();
// map of server name -> state
final Map<String, McpServerState> _states = {};
// map of server name -> active client
final Map<String, McpClient> _clients = {};
// true if we've done an initial startup
bool _started = false;
/// Start all servers from the given settings. Safe to call multiple times -
/// will not restart already-connected servers.
Future<void> startFromSettings(LocalSettings settings) async {
final configs = settings.mcpServers;
if (configs == null || configs.isEmpty) return;
final futures = <Future<void>>[];
for (final entry in configs.entries) {
final name = entry.key;
final rawConfig = entry.value;
if (_clients.containsKey(name)) continue; // already connected or failed
final serverConfig = McpServerConfig.fromJson(name, rawConfig);
futures.add(_connectOne(serverConfig));
}
// connect in batches of 3 like the original does
await _batchedConnect(futures, batchSize: 3);
_started = true;
}
Future<void> _batchedConnect(List<Future<void>> futures, {int batchSize = 3}) async {
for (int i = 0; i < futures.length; i += batchSize) {
final batch = futures.skip(i).take(batchSize).toList();
await Future.wait(batch);
}
}
Future<void> _connectOne(McpServerConfig serverConfig) async {
final state = McpServerState(
config: serverConfig,
status: McpServerStatus.pending,
);
_states[serverConfig.name] = state;
// only stdio supported fully, others are noted but wont connect
if (serverConfig.type != "stdio" && serverConfig.rawJson?["command"] == null) {
state.status = McpServerStatus.failed;
state.error = "Transport type \"${serverConfig.type}\" is not supported in this Dart port. Only stdio is implemented.";
return;
}
final client = McpClient(serverConfig);
_clients[serverConfig.name] = client;
try {
await client.connect();
state.status = McpServerStatus.connected;
// fetch tools/resources/prompts in parallel
List<McpTool> tools;
List<McpResource> resources;
List<McpPrompt> prompts;
try {
tools = await client.listTools();
} catch (_) {
tools = <McpTool>[];
}
try {
resources = await client.listResources();
} catch (_) {
resources = <McpResource>[];
}
try {
prompts = await client.listPrompts();
} catch (_) {
prompts = <McpPrompt>[];
}
state.tools = tools;
state.resources = resources;
state.prompts = prompts;
} catch (e) {
state.status = McpServerStatus.failed;
state.error = e.toString();
await client.disconnect().catchError((_) {});
_clients.remove(serverConfig.name);
}
}
/// Stop a single server by name
Future<void> stopServer(String name) async {
final client = _clients.remove(name);
if (client != null) {
await client.disconnect().catchError((_) {});
}
_states[name]?.status = McpServerStatus.disabled;
}
/// Stop all servers
Future<void> stopAll() async {
final names = _clients.keys.toList();
for (final name in names) {
await stopServer(name);
}
}
/// Get all server states
List<McpServerState> get allStates => _states.values.toList();
/// Get connected server states only
List<McpServerState> get connectedStates => _states.values
.where((s) => s.status == McpServerStatus.connected)
.toList();
/// Look up a tool by its qualified name (mcp__serverName__toolName)
/// Returns null if not found
McpTool? findTool(String qualifiedName) {
for (final state in connectedStates) {
for (final tool in state.tools) {
if (tool.qualifiedName == qualifiedName || tool.name == qualifiedName) {
return tool;
}
}
}
return null;
}
/// All tools across all connected servers
List<McpTool> get allTools {
final tools = <McpTool>[];
for (final state in connectedStates) {
tools.addAll(state.tools);
}
return tools;
}
/// All resources across connected servers
List<McpResource> get allResources {
final resources = <McpResource>[];
for (final state in connectedStates) {
resources.addAll(state.resources);
}
return resources;
}
/// Call a tool by qualified name. Finds the right server and delegates to McpClient.
Future<McpToolResult> callTool(
String qualifiedName,
Map<String, dynamic> arguments,
) async {
final tool = findTool(qualifiedName);
if (tool == null) {
throw ArgumentError("No MCP tool found with name: $qualifiedName");
}
final client = _clients[tool.serverName];
if (client == null || !client.isConnected) {
throw StateError("MCP server \"${tool.serverName}\" is not connected");
}
return client.callTool(tool.name, arguments);
}
/// Get the client for a specific server (for direct access if needed)
McpClient? clientFor(String serverName) => _clients[serverName];
/// Get state for a server
McpServerState? stateFor(String serverName) => _states[serverName];
}
+240
View File
@@ -0,0 +1,240 @@
// server status enum - matches whats in old_repo/services/mcp/types.ts
enum McpServerStatus {
connected,
failed,
pending,
needsAuth,
disabled,
}
// config for a stdio MCP server (the most common type)
class McpStdioConfig {
const McpStdioConfig({
required this.command,
this.args = const <String>[],
this.env,
});
factory McpStdioConfig.fromJson(Map<String, dynamic> json) {
return McpStdioConfig(
command: json["command"] as String,
args: (json["args"] as List<dynamic>?)?.cast<String>() ?? const <String>[],
env: (json["env"] as Map<String, dynamic>?)?.cast<String, String>(),
);
}
final String command;
final List<String> args;
final Map<String, String>? env;
Map<String, dynamic> toJson() => <String, dynamic>{
"type": "stdio",
"command": command,
"args": args,
if (env != null) "env": env,
};
}
// generic server config - can be stdio, sse, http, etc
// we only do full implmentation for stdio, others are stubs
class McpServerConfig {
const McpServerConfig({
required this.name,
required this.type,
this.stdioConfig,
this.url,
this.headers,
this.rawJson,
});
factory McpServerConfig.fromJson(String name, Map<String, dynamic> json) {
final type = (json["type"] as String?) ?? "stdio";
McpStdioConfig? stdioConfig;
if (type == "stdio" || json["command"] != null) {
stdioConfig = McpStdioConfig.fromJson(json);
}
return McpServerConfig(
name: name,
type: type,
stdioConfig: stdioConfig,
url: json["url"] as String?,
headers: (json["headers"] as Map<String, dynamic>?)?.cast<String, String>(),
rawJson: json,
);
}
final String name;
final String type;
final McpStdioConfig? stdioConfig;
final String? url;
final Map<String, String>? headers;
// keep the raw json around incase we need it
final Map<String, dynamic>? rawJson;
bool get isStdio => type == "stdio" || (rawJson?["command"] != null && type == "stdio");
}
// a tool exposed by an MCP server
class McpTool {
const McpTool({
required this.name,
required this.serverName,
this.description,
this.inputSchema,
});
factory McpTool.fromJson(String serverName, Map<String, dynamic> json) {
return McpTool(
name: json["name"] as String,
serverName: serverName,
description: json["description"] as String?,
inputSchema: json["inputSchema"] as Map<String, dynamic>?,
);
}
final String name;
// which server this tool belongs to
final String serverName;
final String? description;
final Map<String, dynamic>? inputSchema;
// full qualified name like mcp__serverName__toolName
String get qualifiedName => "mcp__${serverName}__$name";
Map<String, dynamic> toJson() => <String, dynamic>{
"name": name,
"serverName": serverName,
if (description != null) "description": description,
if (inputSchema != null) "inputSchema": inputSchema,
};
}
// a resource exposed by an MCP server
class McpResource {
const McpResource({
required this.uri,
required this.serverName,
this.name,
this.description,
this.mimeType,
});
factory McpResource.fromJson(String serverName, Map<String, dynamic> json) {
return McpResource(
uri: json["uri"] as String,
serverName: serverName,
name: json["name"] as String?,
description: json["description"] as String?,
mimeType: json["mimeType"] as String?,
);
}
final String uri;
final String serverName;
final String? name;
final String? description;
final String? mimeType;
}
// a prompt template exposed by an MCP server
class McpPrompt {
const McpPrompt({
required this.name,
required this.serverName,
this.description,
this.arguments,
});
factory McpPrompt.fromJson(String serverName, Map<String, dynamic> json) {
return McpPrompt(
name: json["name"] as String,
serverName: serverName,
description: json["description"] as String?,
arguments: (json["arguments"] as List<dynamic>?)
?.map((a) => Map<String, dynamic>.from(a as Map))
.toList(),
);
}
final String name;
final String serverName;
final String? description;
final List<Map<String, dynamic>>? arguments;
}
// state object for a single server connection
class McpServerState {
McpServerState({
required this.config,
required this.status,
this.tools = const <McpTool>[],
this.resources = const <McpResource>[],
this.prompts = const <McpPrompt>[],
this.error,
this.serverInfo,
});
final McpServerConfig config;
McpServerStatus status;
List<McpTool> tools;
List<McpResource> resources;
List<McpPrompt> prompts;
String? error;
Map<String, String>? serverInfo;
String get name => config.name;
}
// content item returned from a tool call
class McpToolContent {
const McpToolContent({
required this.type,
this.text,
this.data,
this.mimeType,
});
factory McpToolContent.fromJson(Map<String, dynamic> json) {
return McpToolContent(
type: json["type"] as String,
text: json["text"] as String?,
data: json["data"] as String?,
mimeType: json["mimeType"] as String?,
);
}
final String type;
final String? text;
final String? data;
final String? mimeType;
}
// result of a tool call
class McpToolResult {
const McpToolResult({
required this.content,
this.isError = false,
});
factory McpToolResult.fromJson(Map<String, dynamic> json) {
final rawContent = json["content"] as List<dynamic>? ?? const <dynamic>[];
return McpToolResult(
content: rawContent
.map((c) => McpToolContent.fromJson(Map<String, dynamic>.from(c as Map)))
.toList(),
isError: json["isError"] as bool? ?? false,
);
}
final List<McpToolContent> content;
final bool isError;
String get textContent => content
.where((c) => c.type == "text" && c.text != null)
.map((c) => c.text!)
.join("\n");
}
+34
View File
@@ -0,0 +1,34 @@
class LegacySubsystemStat {
const LegacySubsystemStat(this.name, this.fileCount);
final String name;
final int fileCount;
}
const legacySubsystemStats = <LegacySubsystemStat>[
LegacySubsystemStat('utils', 564),
LegacySubsystemStat('components', 389),
LegacySubsystemStat('commands', 207),
LegacySubsystemStat('tools', 184),
LegacySubsystemStat('services', 130),
LegacySubsystemStat('hooks', 104),
LegacySubsystemStat('ink', 96),
LegacySubsystemStat('bridge', 31),
LegacySubsystemStat('constants', 21),
LegacySubsystemStat('skills', 20),
LegacySubsystemStat('cli', 19),
LegacySubsystemStat('keybindings', 14),
LegacySubsystemStat('tasks', 12),
LegacySubsystemStat('types', 11),
LegacySubsystemStat('migrations', 11),
];
const legacyHotspotImportMatches = 2283;
const migrationBlockers = <String>[
'Bun-specific behavior and build-time feature flags are spread across the entrypoints and runtime.',
'The interactive CLI is built on React plus a custom Ink renderer, not a simple text loop.',
'The command layer depends on Anthropic SDK types, MCP plumbing, auth state, and policy gates.',
'Bridge, daemon, remote-control, and background session flows are separate top-level runtimes.',
'A 1:1 port means reproducing behavior, not only file names or command names.',
];
+283
View File
@@ -0,0 +1,283 @@
import "dart:convert";
import "dart:io";
import "../local_state.dart";
import "migration_types.dart";
// path to the json file that tracks which migrations have run
String _migrationStatePath() {
final home = Platform.environment["HOME"] ?? "";
return "$home/.claude/migration_state.json";
}
// reads the completed migration ids from disk
Future<Set<String>> _loadCompletedIds() async {
final file = File(_migrationStatePath());
if (!await file.exists()) return {};
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! Map) return {};
final completed = decoded["completed"];
if (completed is! List) return {};
return completed
.map((e) {
if (e is Map<String, dynamic>) {
final rec = MigrationRecord.fromJson(e);
return rec.id;
}
return null;
})
.whereType<String>()
.toSet();
} catch (_) {
return {};
}
}
Future<void> _markComplete(String migrationId) async {
final path = _migrationStatePath();
final file = File(path);
Map<String, dynamic> state = {};
if (await file.exists()) {
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
state = decoded;
}
} catch (_) {}
}
final completed = state["completed"];
final List<dynamic> list = completed is List ? List.from(completed) : [];
final rec = MigrationRecord(id: migrationId, completedAt: DateTime.now().toUtc());
list.add(rec.toJson());
state["completed"] = list;
await file.parent.create(recursive: true);
const enc = JsonEncoder.withIndent(" ");
await file.writeAsString("${enc.convert(state)}\n");
}
// runs all pending migrations in order
Future<void> runMigrations(List<Migration> migrations) async {
final completed = await _loadCompletedIds();
for (final migration in migrations) {
if (completed.contains(migration.id)) continue;
try {
await migration.up();
await _markComplete(migration.id);
} catch (e) {
// dont crash startup on migration failure, just skip
stderr.writeln("[migration] ${migration.id} failed: $e");
}
}
}
// -- actual migration logic ported from old_repo/migrations/ --
// migrateAutoUpdatesToSettings: sets DISABLE_AUTOUPDATER env var in user settings
// if user had explicitly disabled auto updates in global config
Future<void> _migrateAutoUpdatesToSettings() async {
final settingsPath = getSettingsFilePath();
final file = File(settingsPath);
if (!await file.exists()) return;
// we dont have a concept of autoUpdates in the dart settings model
// so this is mostly a no-op port - log and move on
}
// migrateBypassPermissionsAccepted: moves bypassPermissionsModeAccepted to settings
Future<void> _migrateBypassPermissionsAccepted() async {
// the dart settings model uses permissionMode directly
// if we ever had a bypassPermissionsModeAccepted flag we'd migrate it here
}
// migrateReplBridgeEnabled: renames replBridgeEnabled -> remoteControlAtStartup
Future<void> _migrateReplBridgeEnabled() async {
final settingsPath = getSettingsFilePath();
final file = File(settingsPath);
if (!await file.exists()) return;
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return;
if (!decoded.containsKey("replBridgeEnabled")) return;
if (decoded.containsKey("remoteControlAtStartup")) {
// already migrated, just clean up old key
decoded.remove("replBridgeEnabled");
} else {
decoded["remoteControlAtStartup"] = decoded["replBridgeEnabled"] as bool? ?? false;
decoded.remove("replBridgeEnabled");
}
const enc = JsonEncoder.withIndent(" ");
await file.writeAsString("${enc.convert(decoded)}\n");
} catch (_) {}
}
// migrateFennecToOpus: updates stale model aliases in settings
Future<void> _migrateFennecToOpus() async {
final settingsPath = getSettingsFilePath();
final file = File(settingsPath);
if (!await file.exists()) return;
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return;
final model = decoded["model"];
if (model is! String) return;
String? newModel;
if (model.startsWith("fennec-latest[1m]")) {
newModel = "opus[1m]";
} else if (model.startsWith("fennec-latest")) {
newModel = "opus";
} else if (model.startsWith("fennec-fast-latest") || model.startsWith("opus-4-5-fast")) {
newModel = "opus[1m]";
}
if (newModel == null) return;
decoded["model"] = newModel;
const enc = JsonEncoder.withIndent(" ");
await file.writeAsString("${enc.convert(decoded)}\n");
} catch (_) {}
}
// migrateSonnet1mToSonnet45: pins sonnet[1m] users to the explicit 4.5 string
Future<void> _migrateSonnet1mToSonnet45() async {
final settingsPath = getSettingsFilePath();
final file = File(settingsPath);
if (!await file.exists()) return;
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return;
if (decoded["model"] == "sonnet[1m]") {
decoded["model"] = "sonnet-4-5-20250929[1m]";
const enc = JsonEncoder.withIndent(" ");
await file.writeAsString("${enc.convert(decoded)}\n");
}
} catch (_) {}
}
// migrateSonnet45ToSonnet46: moves explicit sonnet 4.5 strings back to alias
Future<void> _migrateSonnet45ToSonnet46() async {
final settingsPath = getSettingsFilePath();
final file = File(settingsPath);
if (!await file.exists()) return;
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return;
final model = decoded["model"];
if (model is! String) return;
const sonnet45Models = {
"claude-sonnet-4-5-20250929",
"claude-sonnet-4-5-20250929[1m]",
"sonnet-4-5-20250929",
"sonnet-4-5-20250929[1m]",
};
if (!sonnet45Models.contains(model)) return;
final has1m = model.endsWith("[1m]");
decoded["model"] = has1m ? "sonnet[1m]" : "sonnet";
const enc = JsonEncoder.withIndent(" ");
await file.writeAsString("${enc.convert(decoded)}\n");
} catch (_) {}
}
// migrateLegacyOpusToCurrent: remaps old explicit opus 4.0/4.1 model ids
Future<void> _migrateLegacyOpusToCurrent() async {
final settingsPath = getSettingsFilePath();
final file = File(settingsPath);
if (!await file.exists()) return;
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return;
final model = decoded["model"];
if (model is! String) return;
const legacyModels = {
"claude-opus-4-20250514",
"claude-opus-4-1-20250805",
"claude-opus-4-0",
"claude-opus-4-1",
};
if (!legacyModels.contains(model)) return;
decoded["model"] = "opus";
const enc = JsonEncoder.withIndent(" ");
await file.writeAsString("${enc.convert(decoded)}\n");
} catch (_) {}
}
// the full ordered migration list
List<Migration> get allMigrations => [
Migration(
id: "replBridgeEnabledToRemoteControlAtStartup",
description: "Rename replBridgeEnabled to remoteControlAtStartup in settings",
up: _migrateReplBridgeEnabled,
),
Migration(
id: "autoUpdatesToSettings",
description: "Move autoUpdates user preference to settings env vars",
up: _migrateAutoUpdatesToSettings,
),
Migration(
id: "bypassPermissionsAcceptedToSettings",
description: "Move bypassPermissionsModeAccepted to skipDangerousModePermissionPrompt in settings",
up: _migrateBypassPermissionsAccepted,
),
Migration(
id: "fennecToOpus",
description: "Rename fennec model aliases to opus equivalents",
up: _migrateFennecToOpus,
),
Migration(
id: "sonnet1mToSonnet45",
description: "Pin sonnet[1m] users to explicit sonnet-4-5-20250929[1m]",
up: _migrateSonnet1mToSonnet45,
),
Migration(
id: "sonnet45ToSonnet46",
description: "Move explicit sonnet 4.5 model strings back to sonnet alias",
up: _migrateSonnet45ToSonnet46,
),
Migration(
id: "legacyOpusToCurrent",
description: "Remap old opus 4.0/4.1 explicit model IDs to opus alias",
up: _migrateLegacyOpusToCurrent,
),
];
+43
View File
@@ -0,0 +1,43 @@
import "dart:async";
// a single migration - has an id, description, and the actual logic
class Migration {
const Migration({
required this.id,
required this.description,
required this.up,
});
// unique id used to track if this ran already
final String id;
final String description;
// the function that does the actual migration work
final FutureOr<void> Function() up;
}
// state for a single migration tracked in migration_state.json
class MigrationRecord {
MigrationRecord({
required this.id,
required this.completedAt,
});
factory MigrationRecord.fromJson(Map<String, dynamic> json) {
return MigrationRecord(
id: json["id"] as String,
completedAt: DateTime.parse(json["completedAt"] as String),
);
}
final String id;
final DateTime completedAt;
Map<String, dynamic> toJson() {
return {
"id": id,
"completedAt": completedAt.toIso8601String(),
};
}
}
+43
View File
@@ -0,0 +1,43 @@
// Typed ID wrappers — Dart doesnt have branded types like TS, so we use
// simple wrappers to prevent mixing up session/agent IDs at the call site
class SessionId {
final String value;
const SessionId(this.value);
@override
String toString() => value;
@override
bool operator ==(Object other) => other is SessionId && other.value == value;
@override
int get hashCode => value.hashCode;
}
class AgentId {
final String value;
const AgentId(this.value);
@override
String toString() => value;
@override
bool operator ==(Object other) => other is AgentId && other.value == value;
@override
int get hashCode => value.hashCode;
}
final _agentIdPattern = RegExp(r"^a(?:.+-)?[0-9a-f]{16}$");
// returns null if the string doesnt match agent ID format
AgentId? toAgentId(String s) {
return _agentIdPattern.hasMatch(s) ? AgentId(s) : null;
}
SessionId asSessionId(String id) => SessionId(id);
AgentId asAgentId(String id) => AgentId(id);
+236
View File
@@ -0,0 +1,236 @@
// Log/session data types ported from old_repo/types/logs.ts
import "ids.dart";
class SerializedMessage {
final String cwd;
final String userType;
final String? entrypoint;
final String sessionId;
final String timestamp;
final String version;
final String? gitBranch;
final String? slug;
// message content is stored as raw json, we dont re-model the full
// Anthropic message shape here -- thats an API concern
final Map<String, dynamic> raw;
const SerializedMessage({
required this.cwd,
required this.userType,
required this.sessionId,
required this.timestamp,
required this.version,
required this.raw,
this.entrypoint,
this.gitBranch,
this.slug,
});
factory SerializedMessage.fromJson(Map<String, dynamic> json) {
return SerializedMessage(
cwd: json["cwd"] as String,
userType: json["userType"] as String,
entrypoint: json["entrypoint"] as String?,
sessionId: json["sessionId"] as String,
timestamp: json["timestamp"] as String,
version: json["version"] as String,
gitBranch: json["gitBranch"] as String?,
slug: json["slug"] as String?,
raw: json,
);
}
Map<String, dynamic> toJson() => {
"cwd": cwd,
"userType": userType,
if (entrypoint != null) "entrypoint": entrypoint,
"sessionId": sessionId,
"timestamp": timestamp,
"version": version,
if (gitBranch != null) "gitBranch": gitBranch,
if (slug != null) "slug": slug,
};
}
class LogOption {
final String date;
final List<SerializedMessage> messages;
final String? fullPath;
final int value;
final DateTime created;
final DateTime modified;
final String firstPrompt;
final int messageCount;
final int? fileSize;
final bool isSidechain;
final bool? isLite;
final String? sessionId;
final String? teamName;
final String? agentName;
final String? agentColor;
final String? agentSetting;
final bool? isTeammate;
final String? summary;
final String? customTitle;
final String? tag;
final String? gitBranch;
final String? projectPath;
final int? prNumber;
final String? prUrl;
final String? prRepository;
const LogOption({
required this.date,
required this.messages,
required this.value,
required this.created,
required this.modified,
required this.firstPrompt,
required this.messageCount,
required this.isSidechain,
this.fullPath,
this.fileSize,
this.isLite,
this.sessionId,
this.teamName,
this.agentName,
this.agentColor,
this.agentSetting,
this.isTeammate,
this.summary,
this.customTitle,
this.tag,
this.gitBranch,
this.projectPath,
this.prNumber,
this.prUrl,
this.prRepository,
});
factory LogOption.fromJson(Map<String, dynamic> json) {
final rawMessages = json["messages"] as List<dynamic>? ?? [];
return LogOption(
date: json["date"] as String,
messages: rawMessages
.map((m) => SerializedMessage.fromJson(m as Map<String, dynamic>))
.toList(),
fullPath: json["fullPath"] as String?,
value: (json["value"] as num).toInt(),
created: DateTime.parse(json["created"] as String),
modified: DateTime.parse(json["modified"] as String),
firstPrompt: json["firstPrompt"] as String,
messageCount: (json["messageCount"] as num).toInt(),
fileSize: (json["fileSize"] as num?)?.toInt(),
isSidechain: json["isSidechain"] as bool,
isLite: json["isLite"] as bool?,
sessionId: json["sessionId"] as String?,
teamName: json["teamName"] as String?,
agentName: json["agentName"] as String?,
agentColor: json["agentColor"] as String?,
agentSetting: json["agentSetting"] as String?,
isTeammate: json["isTeammate"] as bool?,
summary: json["summary"] as String?,
customTitle: json["customTitle"] as String?,
tag: json["tag"] as String?,
gitBranch: json["gitBranch"] as String?,
projectPath: json["projectPath"] as String?,
prNumber: (json["prNumber"] as num?)?.toInt(),
prUrl: json["prUrl"] as String?,
prRepository: json["prRepository"] as String?,
);
}
Map<String, dynamic> toJson() {
return {
"date": date,
"messages": messages.map((m) => m.toJson()).toList(),
if (fullPath != null) "fullPath": fullPath,
"value": value,
"created": created.toIso8601String(),
"modified": modified.toIso8601String(),
"firstPrompt": firstPrompt,
"messageCount": messageCount,
if (fileSize != null) "fileSize": fileSize,
"isSidechain": isSidechain,
if (isLite != null) "isLite": isLite,
if (sessionId != null) "sessionId": sessionId,
if (teamName != null) "teamName": teamName,
if (agentName != null) "agentName": agentName,
if (agentColor != null) "agentColor": agentColor,
if (agentSetting != null) "agentSetting": agentSetting,
if (isTeammate != null) "isTeammate": isTeammate,
if (summary != null) "summary": summary,
if (customTitle != null) "customTitle": customTitle,
if (tag != null) "tag": tag,
if (gitBranch != null) "gitBranch": gitBranch,
if (projectPath != null) "projectPath": projectPath,
if (prNumber != null) "prNumber": prNumber,
if (prUrl != null) "prUrl": prUrl,
if (prRepository != null) "prRepository": prRepository,
};
}
}
// sort logs by modified date descending, then created descending
List<LogOption> sortLogs(List<LogOption> logs) {
final copy = List<LogOption>.from(logs);
copy.sort((a, b) {
final diff = b.modified.compareTo(a.modified);
if (diff != 0) return diff;
return b.created.compareTo(a.created);
});
return copy;
}
class PersistedWorktreeSession {
final String originalCwd;
final String worktreePath;
final String worktreeName;
final String? worktreeBranch;
final String? originalBranch;
final String? originalHeadCommit;
final String sessionId;
final String? tmuxSessionName;
final bool? hookBased;
const PersistedWorktreeSession({
required this.originalCwd,
required this.worktreePath,
required this.worktreeName,
required this.sessionId,
this.worktreeBranch,
this.originalBranch,
this.originalHeadCommit,
this.tmuxSessionName,
this.hookBased,
});
factory PersistedWorktreeSession.fromJson(Map<String, dynamic> json) {
return PersistedWorktreeSession(
originalCwd: json["originalCwd"] as String,
worktreePath: json["worktreePath"] as String,
worktreeName: json["worktreeName"] as String,
worktreeBranch: json["worktreeBranch"] as String?,
originalBranch: json["originalBranch"] as String?,
originalHeadCommit: json["originalHeadCommit"] as String?,
sessionId: json["sessionId"] as String,
tmuxSessionName: json["tmuxSessionName"] as String?,
hookBased: json["hookBased"] as bool?,
);
}
Map<String, dynamic> toJson() => {
"originalCwd": originalCwd,
"worktreePath": worktreePath,
"worktreeName": worktreeName,
if (worktreeBranch != null) "worktreeBranch": worktreeBranch,
if (originalBranch != null) "originalBranch": originalBranch,
if (originalHeadCommit != null) "originalHeadCommit": originalHeadCommit,
"sessionId": sessionId,
if (tmuxSessionName != null) "tmuxSessionName": tmuxSessionName,
if (hookBased != null) "hookBased": hookBased,
};
}
+124
View File
@@ -0,0 +1,124 @@
// Permission types ported from old_repo/types/permissions.ts
enum ExternalPermissionMode {
acceptEdits,
bypassPermissions,
defaultMode, // "default" is a reserved word in dart
dontAsk,
plan,
}
enum InternalPermissionMode {
acceptEdits,
bypassPermissions,
defaultMode,
dontAsk,
plan,
auto,
bubble,
}
typedef PermissionMode = InternalPermissionMode;
enum PermissionBehavior { allow, deny, ask }
enum PermissionRuleSource {
userSettings,
projectSettings,
localSettings,
flagSettings,
policySettings,
cliArg,
command,
session,
}
class PermissionRuleValue {
final String toolName;
final String? ruleContent;
const PermissionRuleValue({required this.toolName, this.ruleContent});
factory PermissionRuleValue.fromJson(Map<String, dynamic> json) {
return PermissionRuleValue(
toolName: json["toolName"] as String,
ruleContent: json["ruleContent"] as String?,
);
}
Map<String, dynamic> toJson() => {
"toolName": toolName,
if (ruleContent != null) "ruleContent": ruleContent,
};
}
class PermissionRule {
final PermissionRuleSource source;
final PermissionBehavior ruleBehavior;
final PermissionRuleValue ruleValue;
const PermissionRule({
required this.source,
required this.ruleBehavior,
required this.ruleValue,
});
}
enum PermissionUpdateDestination {
userSettings,
projectSettings,
localSettings,
session,
cliArg,
}
class AdditionalWorkingDirectory {
final String path;
final PermissionRuleSource source;
const AdditionalWorkingDirectory({required this.path, required this.source});
factory AdditionalWorkingDirectory.fromJson(Map<String, dynamic> json) {
return AdditionalWorkingDirectory(
path: json["path"] as String,
source: PermissionRuleSource.values.byName(json["source"] as String),
);
}
Map<String, dynamic> toJson() => {
"path": path,
"source": source.name,
};
}
enum RiskLevel { low, medium, high }
class PermissionExplanation {
final RiskLevel riskLevel;
final String explanation;
final String reasoning;
final String risk;
const PermissionExplanation({
required this.riskLevel,
required this.explanation,
required this.reasoning,
required this.risk,
});
factory PermissionExplanation.fromJson(Map<String, dynamic> json) {
return PermissionExplanation(
riskLevel: RiskLevel.values.byName((json["riskLevel"] as String).toLowerCase()),
explanation: json["explanation"] as String,
reasoning: json["reasoning"] as String,
risk: json["risk"] as String,
);
}
Map<String, dynamic> toJson() => {
"riskLevel": riskLevel.name.toUpperCase(),
"explanation": explanation,
"reasoning": reasoning,
"risk": risk,
};
}
+201
View File
@@ -0,0 +1,201 @@
/// Plugin discovery and loading from disk
///
/// Discovers plugins from:
/// - ~/.claude/plugins/ (user installed)
/// - Project .claude/plugins/
/// - Inline/bundled plugins
///
/// Loads and validates plugin manifests (plugin.json, manifest.json)
import "dart:io";
import "dart:convert";
import "../utils/path_utils.dart";
import "plugin_types.dart";
/// Discovers and loads all plugins from default locations
Future<PluginLoadResult> loadAllPlugins({
bool includeDisabled = true,
}) async {
final enabled = <LoadedPlugin>[];
final disabled = <LoadedPlugin>[];
final errors = <PluginError>[];
try {
// Load from ~/.claude/plugins/
final userPluginsDir = expandPath("~/.claude/plugins");
final userPlugins =
await _loadPluginsFromDirectory(userPluginsDir);
for (final plugin in userPlugins.enabled) {
enabled.add(plugin);
}
for (final plugin in userPlugins.disabled) {
disabled.add(plugin);
}
errors.addAll(userPlugins.errors);
// TODO: Load from .claude/plugins/ in current directory
// TODO: Load from marketplace sources configured in settings
// TODO: Apply blocklist/policy filters
} catch (e) {
errors.add(PluginError(
code: "plugin-discovery-failed",
message: "Failed to discover plugins: $e",
));
}
return PluginLoadResult(
enabled: enabled,
disabled: disabled,
errors: errors,
);
}
/// Load plugins from a specific directory
Future<PluginLoadResult> _loadPluginsFromDirectory(String dirPath) async {
final enabled = <LoadedPlugin>[];
final disabled = <LoadedPlugin>[];
final errors = <PluginError>[];
final dir = Directory(dirPath);
if (!dir.existsSync()) {
return PluginLoadResult(
enabled: [],
disabled: [],
errors: [],
);
}
try {
final entries = dir.listSync();
for (final entry in entries) {
if (entry is! Directory) continue;
final pluginName = entry.path.split(Platform.pathSeparator).last;
try {
final manifest = await _loadManifest(entry.path);
if (manifest == null) continue;
final plugin = Plugin(
name: manifest["name"] as String,
version: manifest["version"] as String?,
description: manifest["description"] as String?,
author: manifest["author"] != null
? PluginAuthor.fromJson(manifest["author"] as Map<String, dynamic>)
: null,
entrypoint: manifest["entrypoint"] as String?,
permissions: List<String>.from(
(manifest["permissions"] as List?)?.cast<String>() ?? [],
),
config: manifest["config"] as Map<String, dynamic>?,
commandsPath: manifest["commandsPath"] as String?,
commandsPaths: List<String>.from(
(manifest["commandsPaths"] as List?)?.cast<String>() ?? [],
),
agentsPath: manifest["agentsPath"] as String?,
agentsPaths: List<String>.from(
(manifest["agentsPaths"] as List?)?.cast<String>() ?? [],
),
skillsPath: manifest["skillsPath"] as String?,
skillsPaths: List<String>.from(
(manifest["skillsPaths"] as List?)?.cast<String>() ?? [],
),
hooksPath: manifest["hooksPath"] as String?,
hooksConfig: manifest["hooksConfig"] as Map<String, dynamic>?,
mcpServers: manifest["mcpServers"] as Map<String, dynamic>?,
);
// TODO: check plugin blocklist and policy
final loaded = LoadedPlugin(
plugin: plugin,
path: entry.path,
source: "local",
enabled: true,
);
enabled.add(loaded);
} catch (e) {
errors.add(PluginError(
code: "plugin-load-error",
message: "Failed to load plugin '$pluginName': $e",
pluginName: pluginName,
));
}
}
} catch (e) {
errors.add(PluginError(
code: "directory-read-error",
message: "Failed to read plugin directory '$dirPath': $e",
));
}
return PluginLoadResult(
enabled: enabled,
disabled: disabled,
errors: errors,
);
}
/// Load manifest from a plugin directory
/// Looks for plugin.json or manifest.json
Future<Map<String, dynamic>?> _loadManifest(String pluginPath) async {
// Try plugin.json first
final pluginJsonFile =
File("$pluginPath${Platform.pathSeparator}plugin.json");
if (pluginJsonFile.existsSync()) {
final content = await pluginJsonFile.readAsString();
try {
return json.decode(content) as Map<String, dynamic>;
} catch (e) {
throw Exception("Failed to parse plugin.json: $e");
}
}
// Try manifest.json
final manifestFile =
File("$pluginPath${Platform.pathSeparator}manifest.json");
if (manifestFile.existsSync()) {
final content = await manifestFile.readAsString();
try {
return json.decode(content) as Map<String, dynamic>;
} catch (e) {
throw Exception("Failed to parse manifest.json: $e");
}
}
return null;
}
/// Find plugin by name
LoadedPlugin? findPlugin(
List<LoadedPlugin> plugins,
String name,
) {
try {
return plugins.firstWhere((p) => p.plugin.name == name);
} catch (e) {
return null;
}
}
/// Find plugins by source
List<LoadedPlugin> findPluginsBySource(
List<LoadedPlugin> plugins,
String source,
) {
return plugins.where((p) => p.source == source).toList();
}
/// Find enabled plugins
List<LoadedPlugin> getEnabledPlugins(List<LoadedPlugin> plugins) {
return plugins.where((p) => p.enabled).toList();
}
/// Find disabled plugins
List<LoadedPlugin> getDisabledPlugins(List<LoadedPlugin> plugins) {
return plugins.where((p) => !p.enabled).toList();
}
+242
View File
@@ -0,0 +1,242 @@
/// Plugin management - enable/disable, lookup, configuration
///
/// Manages the lifecycle of loaded plugins:
/// - lookup by name
/// - enable/disable
/// - access to commands, agents, skills
/// - TODO: plugin execution and sandboxing
import "plugin_types.dart";
import "plugin_loader.dart";
/// Manages active plugins and their state
class PluginManager {
final List<LoadedPlugin> _plugins = [];
final Map<String, bool> _enabledState = {};
PluginManager();
/// Initialize manager with loaded plugins
void initialize(PluginLoadResult loadResult) {
_plugins.clear();
_plugins.addAll(loadResult.all);
// Initialize enabled state from loaded plugins
for (final plugin in _plugins) {
_enabledState[plugin.plugin.name] = plugin.enabled;
}
}
/// Get all loaded plugins
List<LoadedPlugin> get all => List.unmodifiable(_plugins);
/// Get all enabled plugins
List<LoadedPlugin> get enabled {
return _plugins.where((p) => isPluginEnabled(p.plugin.name)).toList();
}
/// Get all disabled plugins
List<LoadedPlugin> get disabled {
return _plugins.where((p) => !isPluginEnabled(p.plugin.name)).toList();
}
/// Get plugin count
int get count => _plugins.length;
/// Look up plugin by name
LoadedPlugin? getPlugin(String name) {
try {
return _plugins.firstWhere((p) => p.plugin.name == name);
} catch (e) {
return null;
}
}
/// Check if plugin exists
bool hasPlugin(String name) => getPlugin(name) != null;
/// Check if plugin is enabled
bool isPluginEnabled(String name) {
return _enabledState[name] ?? false;
}
/// Enable a plugin
void enablePlugin(String name) {
if (hasPlugin(name)) {
_enabledState[name] = true;
}
}
/// Disable a plugin
void disablePlugin(String name) {
if (hasPlugin(name)) {
_enabledState[name] = false;
}
}
/// Toggle plugin enabled state
void togglePlugin(String name) {
final current = isPluginEnabled(name);
if (hasPlugin(name)) {
_enabledState[name] = !current;
}
}
/// Get all command paths from enabled plugins
Map<String, List<String>> getAllCommandPaths() {
final paths = <String, List<String>>{};
for (final plugin in enabled) {
paths[plugin.plugin.name] = plugin.getAllCommandPaths();
}
return paths;
}
/// Get all agent paths from enabled plugins
Map<String, List<String>> getAllAgentPaths() {
final paths = <String, List<String>>{};
for (final plugin in enabled) {
paths[plugin.plugin.name] = plugin.getAllAgentPaths();
}
return paths;
}
/// Get all skill paths from enabled plugins
Map<String, List<String>> getAllSkillPaths() {
final paths = <String, List<String>>{};
for (final plugin in enabled) {
paths[plugin.plugin.name] = plugin.getAllSkillPaths();
}
return paths;
}
/// Get all MCP servers from enabled plugins
Map<String, Map<String, dynamic>> getAllMcpServers() {
final servers = <String, Map<String, dynamic>>{};
for (final plugin in enabled) {
if (plugin.plugin.mcpServers != null) {
servers[plugin.plugin.name] = plugin.plugin.mcpServers!;
}
}
return servers;
}
/// Get hook config from all enabled plugins
Map<String, dynamic> getAllHooksConfig() {
final hooks = <String, dynamic>{};
for (final plugin in enabled) {
if (plugin.plugin.hooksConfig != null) {
// Merge hook configs
// TODO: handle conflicts/ordering
hooks.addAll(plugin.plugin.hooksConfig!);
}
}
return hooks;
}
/// Get plugins by source
List<LoadedPlugin> getPluginsBySource(String source) {
return _plugins.where((p) => p.source == source).toList();
}
/// Get plugins that require a specific permission
List<LoadedPlugin> getPluginsRequiringPermission(String permission) {
return _plugins
.where((p) => p.plugin.permissions.contains(permission))
.toList();
}
/// Get enabled plugins that require a specific permission
List<LoadedPlugin> getEnabledPluginsRequiringPermission(
String permission,
) {
return enabled
.where((p) => p.plugin.permissions.contains(permission))
.toList();
}
/// Get plugin info summary
Map<String, dynamic> getPluginInfo(String name) {
final plugin = getPlugin(name);
if (plugin == null) return {};
return {
"name": plugin.plugin.name,
"version": plugin.plugin.version,
"description": plugin.plugin.description,
"author": plugin.plugin.author?.name,
"path": plugin.path,
"source": plugin.source,
"enabled": isPluginEnabled(name),
"permissions": plugin.plugin.permissions,
"builtIn": plugin.isBuiltin,
};
}
/// Get all plugin info summaries
List<Map<String, dynamic>> getAllPluginInfo() {
return _plugins.map((p) => getPluginInfo(p.plugin.name)).toList();
}
/// Reset to initial state
void reset() {
_plugins.clear();
_enabledState.clear();
}
/// Reload plugins from disk
/// TODO: implement full reload with discovery
Future<void> reload() async {
// For now, this is a stub
// In full implementation, would call loadAllPlugins() and reinitialize
}
/// TODO: Execute plugin code (requires sandboxing implementation)
/// This is a placeholder for future plugin execution
Future<dynamic> executePlugin({
required String pluginName,
required String entrypoint,
Map<String, dynamic>? args,
}) async {
final plugin = getPlugin(pluginName);
if (plugin == null) {
throw Exception("Plugin '$pluginName' not found");
}
if (!isPluginEnabled(pluginName)) {
throw Exception("Plugin '$pluginName' is disabled");
}
// TODO: Implement plugin execution with sandboxing
// This would involve:
// 1. Loading plugin entrypoint from disk
// 2. Setting up sandboxed environment
// 3. Injecting permissions/context
// 4. Executing code
// 5. Returning result
throw UnimplementedError(
"Plugin execution not yet implemented. "
"See plugin_manager.dart for TODO.",
);
}
@override
String toString() =>
"PluginManager(total=${count}, enabled=${enabled.length}, disabled=${disabled.length})";
}
/// Global plugin manager instance
PluginManager? _globalPluginManager;
/// Get or create the global plugin manager
PluginManager getGlobalPluginManager() {
return _globalPluginManager ??= PluginManager();
}
/// Initialize the global plugin manager with loaded plugins
Future<void> initializePluginManager() async {
final manager = getGlobalPluginManager();
final result = await loadAllPlugins();
manager.initialize(result);
}
+253
View File
@@ -0,0 +1,253 @@
/// Plugin system types and models
///
/// Defines the structure of plugins, manifests, and plugin metadata
class Plugin {
/// Unique identifier for the plugin (kebab-case)
final String name;
/// Semantic version (e.g., "1.0.0")
final String? version;
/// Human-readable description
final String? description;
/// Plugin author information
final PluginAuthor? author;
/// Main plugin entrypoint (command/module to load)
final String? entrypoint;
/// Required permissions (e.g., ["Bash", "Read"])
final List<String> permissions;
/// Plugin configuration (from plugin.json or manifest)
final Map<String, dynamic>? config;
/// Directory paths for plugin components
final String? commandsPath;
final List<String>? commandsPaths;
final String? agentsPath;
final List<String>? agentsPaths;
final String? skillsPath;
final List<String>? skillsPaths;
final String? hooksPath;
final Map<String, dynamic>? hooksConfig;
/// MCP server configurations
final Map<String, dynamic>? mcpServers;
Plugin({
required this.name,
this.version,
this.description,
this.author,
this.entrypoint,
this.permissions = const [],
this.config,
this.commandsPath,
this.commandsPaths,
this.agentsPath,
this.agentsPaths,
this.skillsPath,
this.skillsPaths,
this.hooksPath,
this.hooksConfig,
this.mcpServers,
});
/// Convert to JSON for serialization
Map<String, dynamic> toJson() => {
"name": name,
if (version != null) "version": version,
if (description != null) "description": description,
if (author != null) "author": author?.toJson(),
if (entrypoint != null) "entrypoint": entrypoint,
if (permissions.isNotEmpty) "permissions": permissions,
if (config != null) "config": config,
if (commandsPath != null) "commandsPath": commandsPath,
if (commandsPaths != null) "commandsPaths": commandsPaths,
if (agentsPath != null) "agentsPath": agentsPath,
if (agentsPaths != null) "agentsPaths": agentsPaths,
if (skillsPath != null) "skillsPath": skillsPath,
if (skillsPaths != null) "skillsPaths": skillsPaths,
if (hooksPath != null) "hooksPath": hooksPath,
if (hooksConfig != null) "hooksConfig": hooksConfig,
if (mcpServers != null) "mcpServers": mcpServers,
};
@override
String toString() => "Plugin(name=$name, version=$version)";
}
/// Plugin author information
class PluginAuthor {
final String? name;
final String? email;
final String? url;
PluginAuthor({
this.name,
this.email,
this.url,
});
factory PluginAuthor.fromJson(Map<String, dynamic> json) {
return PluginAuthor(
name: json["name"] as String?,
email: json["email"] as String?,
url: json["url"] as String?,
);
}
Map<String, dynamic> toJson() => {
if (name != null) "name": name,
if (email != null) "email": email,
if (url != null) "url": url,
};
@override
String toString() => "PluginAuthor(name=$name)";
}
/// Metadata about a loaded plugin instance
class LoadedPlugin {
/// Plugin information
final Plugin plugin;
/// Filesystem path where plugin is installed
final String path;
/// Source identifier (e.g., "github:owner/repo", "local", "builtin")
final String source;
/// Repository identifier
final String? repository;
/// Whether plugin is enabled/disabled
final bool enabled;
/// Whether this is a built-in plugin
final bool isBuiltin;
/// Git commit SHA for version pinning
final String? sha;
LoadedPlugin({
required this.plugin,
required this.path,
required this.source,
this.repository,
this.enabled = true,
this.isBuiltin = false,
this.sha,
});
/// Display identifier for the plugin
String get displayId {
if (isBuiltin) return "${plugin.name}@builtin";
return plugin.name;
}
/// Get all command paths (unified from singular + plural)
List<String> getAllCommandPaths() {
final paths = <String>[];
if (plugin.commandsPath != null) {
paths.add(plugin.commandsPath!);
}
if (plugin.commandsPaths != null) {
paths.addAll(plugin.commandsPaths!);
}
return paths;
}
/// Get all agent paths
List<String> getAllAgentPaths() {
final paths = <String>[];
if (plugin.agentsPath != null) {
paths.add(plugin.agentsPath!);
}
if (plugin.agentsPaths != null) {
paths.addAll(plugin.agentsPaths!);
}
return paths;
}
/// Get all skill paths
List<String> getAllSkillPaths() {
final paths = <String>[];
if (plugin.skillsPath != null) {
paths.add(plugin.skillsPath!);
}
if (plugin.skillsPaths != null) {
paths.addAll(plugin.skillsPaths!);
}
return paths;
}
Map<String, dynamic> toJson() => {
"plugin": plugin.toJson(),
"path": path,
"source": source,
if (repository != null) "repository": repository,
"enabled": enabled,
"isBuiltin": isBuiltin,
if (sha != null) "sha": sha,
};
@override
String toString() => "LoadedPlugin(${plugin.name}, enabled=$enabled)";
}
/// Result of loading plugins
class PluginLoadResult {
/// Successfully loaded and enabled plugins
final List<LoadedPlugin> enabled;
/// Successfully loaded but disabled plugins
final List<LoadedPlugin> disabled;
/// Errors encountered during loading
final List<PluginError> errors;
PluginLoadResult({
required this.enabled,
required this.disabled,
required this.errors,
});
/// All loaded plugins (enabled + disabled)
List<LoadedPlugin> get all => [...enabled, ...disabled];
/// Total plugin count
int get totalCount => enabled.length + disabled.length;
/// Whether loading succeeded (no critical errors)
bool get isSuccess => errors.isEmpty;
@override
String toString() =>
"PluginLoadResult(enabled=${enabled.length}, disabled=${disabled.length}, errors=${errors.length})";
}
/// Plugin loading error
class PluginError {
final String code;
final String message;
final String? pluginName;
final Map<String, dynamic>? details;
PluginError({
required this.code,
required this.message,
this.pluginName,
this.details,
});
@override
String toString() => "PluginError($code): $message${pluginName != null ? ' (${pluginName})' : ''}";
}
+131
View File
@@ -0,0 +1,131 @@
import "dart:convert";
import "dart:io";
import "local_state.dart";
String getProjectsFilePath() {
return joinPath(getConfigHomeDir(), "projects.json");
}
class ProjectRecord {
const ProjectRecord({
required this.id,
required this.name,
required this.workingDirectory,
required this.createdAt,
});
factory ProjectRecord.fromJson(Map<String, dynamic> json) {
return ProjectRecord(
id: _readJsonString(json, "id") ?? "",
name: _readJsonString(json, "name") ?? "",
workingDirectory: _readJsonString(json, "workingDirectory") ?? "",
createdAt:
DateTime.tryParse(_readJsonString(json, "createdAt") ?? "")?.toUtc() ??
DateTime.now().toUtc(),
);
}
final String id;
final String name;
final String workingDirectory;
final DateTime createdAt;
ProjectRecord copyWith({
String? id,
String? name,
String? workingDirectory,
DateTime? createdAt,
}) {
return ProjectRecord(
id: id ?? this.id,
name: name ?? this.name,
workingDirectory: workingDirectory ?? this.workingDirectory,
createdAt: createdAt ?? this.createdAt,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
"id": id,
"name": name,
"workingDirectory": workingDirectory,
"createdAt": createdAt.toIso8601String(),
};
}
}
String? _readJsonString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is String && value.isNotEmpty) {
return value;
}
return null;
}
class ProjectStore {
ProjectStore._({required this.path, required this.projects});
static const _encoder = JsonEncoder.withIndent(" ");
final String path;
List<ProjectRecord> projects;
static Future<ProjectStore> load() async {
final path = getProjectsFilePath();
final file = File(path);
if (!await file.exists()) {
final store = ProjectStore._(path: path, projects: const <ProjectRecord>[]);
await store.save();
return store;
}
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is List) {
return ProjectStore._(
path: path,
projects: decoded
.whereType<Map>()
.map(
(project) => ProjectRecord.fromJson(
project.map(
(key, value) => MapEntry(key.toString(), value),
),
),
)
.where(
(project) =>
project.id.isNotEmpty &&
project.name.isNotEmpty &&
project.workingDirectory.isNotEmpty,
)
.toList(growable: false),
);
}
} catch (_) {
// Fall back to an empty project list if the file is unreadable.
}
final store = ProjectStore._(path: path, projects: const <ProjectRecord>[]);
await store.save();
return store;
}
Future<void> save() async {
final file = File(path);
await file.parent.create(recursive: true);
await file.writeAsString(
"${_encoder.convert(projects.map((project) => project.toJson()).toList())}\n",
);
}
Future<void> update(
List<ProjectRecord> Function(List<ProjectRecord> current) transform,
) async {
projects = transform(List<ProjectRecord>.from(projects));
await save();
}
}
+367
View File
@@ -0,0 +1,367 @@
// QueryEngine — core query lifecycle and session state manager.
// Routes user input to tools/API, manages conversation turns.
// Network/API calls are TODO stubs; structure + local dispatch is fully ported.
import "dart:async";
import "package:clawd_code/src/services/cost_tracker.dart";
import "package:clawd_code/src/system_prompt/system_prompt_builder.dart";
import "package:clawd_code/src/utils/uuid_utils.dart";
// ---- config ---
class QueryEngineConfig {
final String cwd;
final List<String> tools;
final List<String> commands;
final List<Map<String, dynamic>> mcpClients;
final List<Map<String, dynamic>> agents;
// optional
final String? customSystemPrompt;
final String? appendSystemPrompt;
final String? userSpecifiedModel;
final String? fallbackModel;
final int? maxTurns;
final double? maxBudgetUsd;
final bool verbose;
final bool replayUserMessages;
final bool includePartialMessages;
const QueryEngineConfig({
required this.cwd,
required this.tools,
required this.commands,
required this.mcpClients,
required this.agents,
this.customSystemPrompt,
this.appendSystemPrompt,
this.userSpecifiedModel,
this.fallbackModel,
this.maxTurns,
this.maxBudgetUsd,
this.verbose = false,
this.replayUserMessages = false,
this.includePartialMessages = false,
});
}
// ---- message types ----
enum SdkMessageType {
system,
user,
assistant,
result,
streamEvent,
progress,
attachment,
}
class SdkMessage {
final SdkMessageType type;
final String? subtype;
final String sessionId;
final String uuid;
final Map<String, dynamic> data;
const SdkMessage({
required this.type,
this.subtype,
required this.sessionId,
required this.uuid,
required this.data,
});
}
class SdkResultMessage extends SdkMessage {
final bool isError;
final int durationMs;
final int numTurns;
final String result;
final double totalCostUsd;
const SdkResultMessage({
required super.sessionId,
required super.uuid,
required this.isError,
required this.durationMs,
required this.numTurns,
required this.result,
required this.totalCostUsd,
super.subtype,
super.data = const {},
}) : super(type: SdkMessageType.result);
}
// ---- permission denial tracking ----
class PermissionDenial {
final String toolName;
final String toolUseId;
final Map<String, dynamic> toolInput;
const PermissionDenial({
required this.toolName,
required this.toolUseId,
required this.toolInput,
});
Map<String, dynamic> toJson() => {
"tool_name": toolName,
"tool_use_id": toolUseId,
"tool_input": toolInput,
};
}
// ---- slash command result ----
class SlashCommandResult {
// true = we handled this as a slash command, dont send to API
final bool handled;
final String? outputText;
final bool shouldQuery;
const SlashCommandResult({
required this.handled,
this.outputText,
this.shouldQuery = false,
});
}
// ---- the engine ----
class QueryEngine {
final QueryEngineConfig config;
final List<Map<String, dynamic>> _messages = [];
final List<PermissionDenial> _permissionDenials = [];
// session ID - lazily generated
late final String _sessionId = generateUuid();
QueryEngine(this.config);
String get sessionId => _sessionId;
List<Map<String, dynamic>> get messages => List.unmodifiable(_messages);
// Parse a slash command from user input.
// Returns null if not a slash command.
SlashCommandResult? _tryParseSlashCommand(String input) {
final trimmed = input.trim();
if (!trimmed.startsWith("/")) return null;
// split on first space
final parts = trimmed.split(RegExp(r"\s+"));
final cmd = parts[0].substring(1); // strip the /
final args = parts.length > 1 ? parts.sublist(1).join(" ") : "";
switch (cmd) {
case "clear":
_messages.clear();
return const SlashCommandResult(
handled: true,
outputText: "Conversation cleared.",
);
case "help":
return const SlashCommandResult(
handled: true,
shouldQuery: false,
outputText:
"Available commands: /clear, /help, /model, /status, /exit",
);
case "model":
if (args.isNotEmpty) {
return SlashCommandResult(
handled: true,
outputText: "Model set to: $args",
);
}
return SlashCommandResult(
handled: true,
outputText:
"Current model: ${config.userSpecifiedModel ?? "default"}",
);
default:
// unknown slash command — pass through to query
return null;
}
}
// main entry point — submit a message and get back a stream of SDK messages
Stream<SdkMessage> submitMessage(
String prompt, {
String? uuid,
bool isMeta = false,
}) async* {
final startTime = DateTime.now().millisecondsSinceEpoch;
final msgUuid = uuid ?? generateUuid();
// try slash command first
final slashResult = _tryParseSlashCommand(prompt);
if (slashResult != null) {
if (slashResult.outputText != null) {
yield SdkMessage(
type: SdkMessageType.assistant,
sessionId: _sessionId,
uuid: generateUuid(),
data: {"content": slashResult.outputText},
);
}
if (!slashResult.shouldQuery) {
yield SdkResultMessage(
sessionId: _sessionId,
uuid: generateUuid(),
isError: false,
durationMs: DateTime.now().millisecondsSinceEpoch - startTime,
numTurns: 0,
result: slashResult.outputText ?? "",
totalCostUsd: 0.0,
subtype: "success",
);
return;
}
}
// add user message to history
if (!isMeta) {
_messages.add({
"role": "user",
"content": prompt,
"uuid": msgUuid,
"timestamp": startTime,
});
}
// check budget
if (config.maxBudgetUsd != null) {
final totalCost = getTotalCostUsd();
if (totalCost >= config.maxBudgetUsd!) {
yield SdkResultMessage(
sessionId: _sessionId,
uuid: generateUuid(),
isError: true,
subtype: "error_budget_exceeded",
durationMs: DateTime.now().millisecondsSinceEpoch - startTime,
numTurns: _messages.length,
result: "",
totalCostUsd: totalCost,
);
return;
}
}
// check max turns
if (config.maxTurns != null && _messages.length >= config.maxTurns!) {
yield SdkResultMessage(
sessionId: _sessionId,
uuid: generateUuid(),
isError: true,
subtype: "error_max_turns",
durationMs: DateTime.now().millisecondsSinceEpoch - startTime,
numTurns: _messages.length,
result: "",
totalCostUsd: getTotalCostUsd(),
);
return;
}
// build system prompt
final systemPrompt = _buildSystemPrompt();
// TODO: actually call the Anthropic API here
// For now, stub the response
final apiResult = await _callApi(
messages: _messages,
systemPrompt: systemPrompt,
);
if (apiResult.isError) {
yield SdkResultMessage(
sessionId: _sessionId,
uuid: generateUuid(),
isError: true,
subtype: "error_during_execution",
durationMs: DateTime.now().millisecondsSinceEpoch - startTime,
numTurns: _messages.length,
result: apiResult.errorText ?? "",
totalCostUsd: getTotalCostUsd(),
);
return;
}
// push the assistant response
final assistantMsg = {
"role": "assistant",
"content": apiResult.content ?? "",
"uuid": generateUuid(),
"timestamp": DateTime.now().millisecondsSinceEpoch,
};
_messages.add(assistantMsg);
yield SdkMessage(
type: SdkMessageType.assistant,
sessionId: _sessionId,
uuid: generateUuid(),
data: assistantMsg,
);
yield SdkResultMessage(
sessionId: _sessionId,
uuid: generateUuid(),
isError: false,
subtype: "success",
durationMs: DateTime.now().millisecondsSinceEpoch - startTime,
numTurns: _messages.length,
result: apiResult.content ?? "",
totalCostUsd: getTotalCostUsd(),
);
}
String _buildSystemPrompt() {
return buildDefaultSystemPrompt(
customSystemPrompt: config.customSystemPrompt,
appendSystemPrompt: config.appendSystemPrompt,
);
}
Future<_ApiResult> _callApi({
required List<Map<String, dynamic>> messages,
required String systemPrompt,
}) async {
// TODO: implement actual Anthropic API call
// This stub returns an error to indicate the network path is not yet implemented
return _ApiResult.error("API call not implemented — TODO");
}
// extract just the text content from messages for display
List<String> getConversationTexts() {
return _messages
.map((m) => m["content"]?.toString() ?? "")
.where((t) => t.isNotEmpty)
.toList();
}
// reset the engine state (messages, denials) but keep config
void reset() {
_messages.clear();
_permissionDenials.clear();
}
}
class _ApiResult {
final bool isError;
final String? content;
final String? errorText;
const _ApiResult({required this.isError, this.errorText}) : content = null;
factory _ApiResult.error(String msg) =>
_ApiResult(isError: true, errorText: msg);
}
+281
View File
@@ -0,0 +1,281 @@
import 'dart:convert';
import 'dart:io';
import 'local_state.dart';
const supportedSubscriptionTypes = <String>[
'api',
'enterprise',
'free',
'max',
'pro',
'team',
'team-premium',
];
class AuthState {
const AuthState({
required this.email,
required this.loggedInAt,
this.rateLimitTier,
this.subscriptionType = 'pro',
});
factory AuthState.fromJson(Map<String, dynamic> json) {
final email = _readString(json, 'email');
final loggedInAt = _readString(json, 'loggedInAt');
if (email == null || loggedInAt == null) {
throw const FormatException('Auth state is missing required fields.');
}
return AuthState(
email: email,
loggedInAt: loggedInAt,
rateLimitTier: _readString(json, 'rateLimitTier'),
subscriptionType: _readString(json, 'subscriptionType') ?? 'pro',
);
}
final String email;
final String loggedInAt;
final String? rateLimitTier;
final String subscriptionType;
AuthState copyWith({
String? email,
String? loggedInAt,
Object? rateLimitTier = _sentinel,
String? subscriptionType,
}) {
return AuthState(
email: email ?? this.email,
loggedInAt: loggedInAt ?? this.loggedInAt,
rateLimitTier: identical(rateLimitTier, _sentinel)
? this.rateLimitTier
: rateLimitTier as String?,
subscriptionType: subscriptionType ?? this.subscriptionType,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'email': email,
'loggedInAt': loggedInAt,
'rateLimitTier': rateLimitTier,
'subscriptionType': subscriptionType,
};
}
}
class RuntimeStats {
const RuntimeStats({
this.commandCounts = const <String, int>{},
this.commandsExecuted = 0,
this.interactiveSessionsStarted = 0,
this.lastCommandAt,
this.lastCommandName,
this.lastLaunchAt,
this.sessionsStarted = 0,
});
factory RuntimeStats.fromJson(Map<String, dynamic> json) {
final rawCounts = json['commandCounts'];
var commandCounts = const <String, int>{};
if (rawCounts is Map) {
commandCounts = rawCounts.map((key, value) {
final normalizedKey = key.toString();
final normalizedValue = value is int
? value
: int.tryParse(value.toString()) ?? 0;
return MapEntry(normalizedKey, normalizedValue);
});
}
return RuntimeStats(
commandCounts: commandCounts,
commandsExecuted: _readInt(json, 'commandsExecuted') ?? 0,
interactiveSessionsStarted:
_readInt(json, 'interactiveSessionsStarted') ?? 0,
lastCommandAt: _readString(json, 'lastCommandAt'),
lastCommandName: _readString(json, 'lastCommandName'),
lastLaunchAt: _readString(json, 'lastLaunchAt'),
sessionsStarted: _readInt(json, 'sessionsStarted') ?? 0,
);
}
final Map<String, int> commandCounts;
final int commandsExecuted;
final int interactiveSessionsStarted;
final String? lastCommandAt;
final String? lastCommandName;
final String? lastLaunchAt;
final int sessionsStarted;
RuntimeStats copyWith({
Map<String, int>? commandCounts,
int? commandsExecuted,
int? interactiveSessionsStarted,
Object? lastCommandAt = _sentinel,
Object? lastCommandName = _sentinel,
Object? lastLaunchAt = _sentinel,
int? sessionsStarted,
}) {
return RuntimeStats(
commandCounts: commandCounts ?? this.commandCounts,
commandsExecuted: commandsExecuted ?? this.commandsExecuted,
interactiveSessionsStarted:
interactiveSessionsStarted ?? this.interactiveSessionsStarted,
lastCommandAt: identical(lastCommandAt, _sentinel)
? this.lastCommandAt
: lastCommandAt as String?,
lastCommandName: identical(lastCommandName, _sentinel)
? this.lastCommandName
: lastCommandName as String?,
lastLaunchAt: identical(lastLaunchAt, _sentinel)
? this.lastLaunchAt
: lastLaunchAt as String?,
sessionsStarted: sessionsStarted ?? this.sessionsStarted,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'commandCounts': commandCounts,
'commandsExecuted': commandsExecuted,
'interactiveSessionsStarted': interactiveSessionsStarted,
'lastCommandAt': lastCommandAt,
'lastCommandName': lastCommandName,
'lastLaunchAt': lastLaunchAt,
'sessionsStarted': sessionsStarted,
};
}
}
class RuntimeState {
const RuntimeState({this.auth, this.stats = const RuntimeStats()});
factory RuntimeState.fromJson(Map<String, dynamic> json) {
final rawAuth = json['auth'];
AuthState? auth;
if (rawAuth is Map<String, dynamic>) {
auth = AuthState.fromJson(rawAuth);
} else if (rawAuth is Map) {
auth = AuthState.fromJson(
rawAuth.map((key, value) => MapEntry(key.toString(), value)),
);
}
final rawStats = json['stats'];
RuntimeStats stats = const RuntimeStats();
if (rawStats is Map<String, dynamic>) {
stats = RuntimeStats.fromJson(rawStats);
} else if (rawStats is Map) {
stats = RuntimeStats.fromJson(
rawStats.map((key, value) => MapEntry(key.toString(), value)),
);
}
return RuntimeState(auth: auth, stats: stats);
}
final AuthState? auth;
final RuntimeStats stats;
RuntimeState copyWith({Object? auth = _sentinel, RuntimeStats? stats}) {
return RuntimeState(
auth: identical(auth, _sentinel) ? this.auth : auth as AuthState?,
stats: stats ?? this.stats,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{'auth': auth?.toJson(), 'stats': stats.toJson()};
}
}
class RuntimeStateStore {
RuntimeStateStore._({required this.path, required this.state});
static const _encoder = JsonEncoder.withIndent(' ');
final String path;
RuntimeState state;
static Future<RuntimeStateStore> load() async {
final path = getRuntimeStateFilePath();
final file = File(path);
if (!await file.exists()) {
final store = RuntimeStateStore._(
path: path,
state: const RuntimeState(),
);
await store.save();
return store;
}
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
return RuntimeStateStore._(
path: path,
state: RuntimeState.fromJson(decoded),
);
}
if (decoded is Map) {
return RuntimeStateStore._(
path: path,
state: RuntimeState.fromJson(
decoded.map((key, value) => MapEntry(key.toString(), value)),
),
);
}
} catch (_) {
// Fall back to defaults if the file is unreadable.
}
final store = RuntimeStateStore._(path: path, state: const RuntimeState());
await store.save();
return store;
}
Future<void> save() async {
final file = File(path);
await file.parent.create(recursive: true);
await file.writeAsString('${_encoder.convert(state.toJson())}\n');
}
Future<void> update(
RuntimeState Function(RuntimeState current) transform,
) async {
state = transform(state);
await save();
}
}
String getRuntimeStateFilePath() {
return joinPath(getConfigHomeDir(), 'state.json');
}
int? _readInt(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is int) {
return value;
}
if (value is String) {
return int.tryParse(value);
}
return null;
}
String? _readString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is String && value.isNotEmpty) {
return value;
}
return null;
}
const _sentinel = Object();
+40
View File
@@ -0,0 +1,40 @@
// Analytics configuration — determines when to disable analytics
// Ported from old_repo/services/analytics/config.ts
import "dart:io";
bool isAnalyticsDisabled() {
final env = Platform.environment;
// disabled in test env
if (env["NODE_ENV"] == "test" || env["DART_ENV"] == "test") {
return true;
}
// disabled for 3rd party cloud providers
if (_isTruthy(env["CLAUDE_CODE_USE_BEDROCK"])) return true;
if (_isTruthy(env["CLAUDE_CODE_USE_VERTEX"])) return true;
if (_isTruthy(env["CLAUDE_CODE_USE_FOUNDRY"])) return true;
return isTelemetryDisabled();
}
// survey suppression — doesnt block on 3P providers unlike isAnalyticsDisabled
bool isFeedbackSurveyDisabled() {
final env = Platform.environment;
if (env["NODE_ENV"] == "test" || env["DART_ENV"] == "test") {
return true;
}
return isTelemetryDisabled();
}
bool isTelemetryDisabled() {
final level = Platform.environment["CLAUDE_CODE_PRIVACY_LEVEL"] ?? "";
return level == "no-telemetry" || level == "essential-traffic-only";
}
bool _isTruthy(String? val) {
if (val == null) return false;
final lower = val.toLowerCase();
return lower == "1" || lower == "true" || lower == "yes";
}
+68
View File
@@ -0,0 +1,68 @@
// Anthropic API client stub
// Ported from old_repo/services/api/client.ts
// Full implementation requires HTTP + auth — stubbed with TODOs
import "dart:io";
enum ApiProvider { anthropic, bedrock, vertex, foundry }
ApiProvider getApiProvider() {
final env = Platform.environment;
if (_isTruthy(env["CLAUDE_CODE_USE_BEDROCK"])) return ApiProvider.bedrock;
if (_isTruthy(env["CLAUDE_CODE_USE_VERTEX"])) return ApiProvider.vertex;
if (_isTruthy(env["CLAUDE_CODE_USE_FOUNDRY"])) return ApiProvider.foundry;
return ApiProvider.anthropic;
}
bool _isTruthy(String? v) {
if (v == null) return false;
final l = v.toLowerCase();
return l == "1" || l == "true" || l == "yes";
}
// Configuration the API client needs — resolved at creation time
class ApiClientConfig {
final String apiKey;
final String baseUrl;
final ApiProvider provider;
final int maxRetries;
final String? model;
final String? source;
const ApiClientConfig({
required this.apiKey,
required this.baseUrl,
required this.provider,
this.maxRetries = 2,
this.model,
this.source,
});
}
// TODO: port getAnthropicClient() — requires real HTTP client + OAuth token exchange
// Future<ApiClientConfig> getAnthropicClient({
// int maxRetries = 2,
// String? model,
// String? source,
// }) async { ... }
// TODO: port API request methods (messages.create, beta.messages.countTokens, etc.)
// These require the Anthropic SDK or equivalent HTTP layer.
// Thin wrapper around api key resolution — reads env vars in priority order
String? resolveApiKey() {
final env = Platform.environment;
return env["ANTHROPIC_API_KEY"] ??
env["CLAUDE_API_KEY"] ??
env["CLAUDE_CODE_API_KEY"];
}
String resolveBaseUrl() {
final env = Platform.environment;
final override = env["ANTHROPIC_BASE_URL"] ?? env["CLAUDE_CODE_BASE_URL"];
if (override != null && override.isNotEmpty) return override;
return "https://api.anthropic.com";
}
+95
View File
@@ -0,0 +1,95 @@
// Claude AI quota/rate-limit tracking
// Ported from old_repo/services/claudeAiLimits.ts
// Network-hitting methods (checkQuotaStatus, makeTestQuery) are stubbed
enum QuotaStatus { allowed, allowedWarning, rejected }
enum RateLimitType {
fiveHour,
sevenDay,
sevenDayOpus,
sevenDaySonnet,
overage,
}
enum OverageDisabledReason {
overageNotProvisioned,
orgLevelDisabled,
orgLevelDisabledUntil,
outOfCredits,
seatTierLevelDisabled,
memberLevelDisabled,
seatTierZeroCreditLimit,
groupZeroCreditLimit,
memberZeroCreditLimit,
orgServiceLevelDisabled,
orgServiceZeroCreditLimit,
noLimitsConfigured,
unknown,
}
class ClaudeAILimits {
final QuotaStatus status;
final bool unifiedRateLimitFallbackAvailable;
final int? resetsAt; // unix epoch seconds
final RateLimitType? rateLimitType;
final double? utilization; // 0-1
final QuotaStatus? overageStatus;
final int? overageResetsAt;
final OverageDisabledReason? overageDisabledReason;
final bool isUsingOverage;
final double? surpassedThreshold;
const ClaudeAILimits({
required this.status,
required this.unifiedRateLimitFallbackAvailable,
this.resetsAt,
this.rateLimitType,
this.utilization,
this.overageStatus,
this.overageResetsAt,
this.overageDisabledReason,
this.isUsingOverage = false,
this.surpassedThreshold,
});
factory ClaudeAILimits.defaultAllowed() {
return const ClaudeAILimits(
status: QuotaStatus.allowed,
unifiedRateLimitFallbackAvailable: false,
isUsingOverage: false,
);
}
}
const _rateLimitDisplayNames = {
RateLimitType.fiveHour: "session limit",
RateLimitType.sevenDay: "weekly limit",
RateLimitType.sevenDayOpus: "Opus limit",
RateLimitType.sevenDaySonnet: "Sonnet limit",
RateLimitType.overage: "extra usage limit",
};
String getRateLimitDisplayName(RateLimitType type) {
return _rateLimitDisplayNames[type] ?? type.name;
}
// In-memory state — updated by extractQuotaStatusFromHeaders
ClaudeAILimits currentLimits = ClaudeAILimits.defaultAllowed();
final Set<void Function(ClaudeAILimits)> statusListeners = {};
void emitStatusChange(ClaudeAILimits limits) {
currentLimits = limits;
for (final listener in statusListeners) {
listener(limits);
}
}
// TODO: port checkQuotaStatus — requires live Anthropic API client
// Future<void> checkQuotaStatus() async { ... }
// TODO: port extractQuotaStatusFromHeaders — requires HTTP response header parsing
// void extractQuotaStatusFromHeaders(Map<String, String> headers) { ... }
+242
View File
@@ -0,0 +1,242 @@
// Cost tracker — accumulates token usage + cost across a session
// Ported from old_repo/cost-tracker.ts
// Network/OTel parts are stubbed
class ModelUsage {
int inputTokens;
int outputTokens;
int cacheReadInputTokens;
int cacheCreationInputTokens;
int webSearchRequests;
double costUsd;
int contextWindow;
int maxOutputTokens;
ModelUsage({
this.inputTokens = 0,
this.outputTokens = 0,
this.cacheReadInputTokens = 0,
this.cacheCreationInputTokens = 0,
this.webSearchRequests = 0,
this.costUsd = 0.0,
this.contextWindow = 0,
this.maxOutputTokens = 0,
});
Map<String, dynamic> toJson() => {
"inputTokens": inputTokens,
"outputTokens": outputTokens,
"cacheReadInputTokens": cacheReadInputTokens,
"cacheCreationInputTokens": cacheCreationInputTokens,
"webSearchRequests": webSearchRequests,
"costUsd": costUsd,
};
factory ModelUsage.fromJson(Map<String, dynamic> json) => ModelUsage(
inputTokens: (json["inputTokens"] as num?)?.toInt() ?? 0,
outputTokens: (json["outputTokens"] as num?)?.toInt() ?? 0,
cacheReadInputTokens: (json["cacheReadInputTokens"] as num?)?.toInt() ?? 0,
cacheCreationInputTokens: (json["cacheCreationInputTokens"] as num?)?.toInt() ?? 0,
webSearchRequests: (json["webSearchRequests"] as num?)?.toInt() ?? 0,
costUsd: (json["costUsd"] as num?)?.toDouble() ?? 0.0,
);
}
// Session-level cost state
class _CostState {
double totalCostUsd = 0;
int totalInputTokens = 0;
int totalOutputTokens = 0;
int totalCacheReadInputTokens = 0;
int totalCacheCreationInputTokens = 0;
int totalWebSearchRequests = 0;
int totalApiDurationMs = 0;
int totalApiDurationWithoutRetriesMs = 0;
int totalToolDurationMs = 0;
int totalDurationMs = 0;
int totalLinesAdded = 0;
int totalLinesRemoved = 0;
bool hasUnknownModelCost = false;
final Map<String, ModelUsage> modelUsage = {};
}
final _state = _CostState();
double getTotalCostUsd() => _state.totalCostUsd;
int getTotalInputTokens() => _state.totalInputTokens;
int getTotalOutputTokens() => _state.totalOutputTokens;
int getTotalCacheReadInputTokens() => _state.totalCacheReadInputTokens;
int getTotalCacheCreationInputTokens() => _state.totalCacheCreationInputTokens;
int getTotalWebSearchRequests() => _state.totalWebSearchRequests;
int getTotalApiDurationMs() => _state.totalApiDurationMs;
int getTotalApiDurationWithoutRetriesMs() => _state.totalApiDurationWithoutRetriesMs;
int getTotalToolDurationMs() => _state.totalToolDurationMs;
int getTotalDurationMs() => _state.totalDurationMs;
int getTotalLinesAdded() => _state.totalLinesAdded;
int getTotalLinesRemoved() => _state.totalLinesRemoved;
bool hasUnknownModelCost() => _state.hasUnknownModelCost;
Map<String, ModelUsage> getModelUsage() => Map.unmodifiable(_state.modelUsage);
ModelUsage? getUsageForModel(String model) => _state.modelUsage[model];
void addToTotalLinesChanged(int added, int removed) {
_state.totalLinesAdded += added;
_state.totalLinesRemoved += removed;
}
void setHasUnknownModelCost(bool val) {
_state.hasUnknownModelCost = val;
}
void resetCostState() {
_state.totalCostUsd = 0;
_state.totalInputTokens = 0;
_state.totalOutputTokens = 0;
_state.totalCacheReadInputTokens = 0;
_state.totalCacheCreationInputTokens = 0;
_state.totalWebSearchRequests = 0;
_state.totalApiDurationMs = 0;
_state.totalApiDurationWithoutRetriesMs = 0;
_state.totalToolDurationMs = 0;
_state.totalDurationMs = 0;
_state.totalLinesAdded = 0;
_state.totalLinesRemoved = 0;
_state.hasUnknownModelCost = false;
_state.modelUsage.clear();
}
// Restore state when resuming a previous session
void setCostStateForRestore({
required double totalCostUsd,
required int totalApiDurationMs,
required int totalApiDurationWithoutRetriesMs,
required int totalToolDurationMs,
required int totalLinesAdded,
required int totalLinesRemoved,
Map<String, ModelUsage>? modelUsage,
}) {
_state.totalCostUsd = totalCostUsd;
_state.totalApiDurationMs = totalApiDurationMs;
_state.totalApiDurationWithoutRetriesMs = totalApiDurationWithoutRetriesMs;
_state.totalToolDurationMs = totalToolDurationMs;
_state.totalLinesAdded = totalLinesAdded;
_state.totalLinesRemoved = totalLinesRemoved;
if (modelUsage != null) {
_state.modelUsage.clear();
_state.modelUsage.addAll(modelUsage);
}
}
// Add usage for a single API turn.
// Returns the total cost including any sub-model usage (e.g. advisor).
double addToTotalSessionCost({
required double cost,
required int inputTokens,
required int outputTokens,
required int cacheReadTokens,
required int cacheCreationTokens,
int webSearchRequests = 0,
required String model,
}) {
_state.totalCostUsd += cost;
_state.totalInputTokens += inputTokens;
_state.totalOutputTokens += outputTokens;
_state.totalCacheReadInputTokens += cacheReadTokens;
_state.totalCacheCreationInputTokens += cacheCreationTokens;
_state.totalWebSearchRequests += webSearchRequests;
final existing = _state.modelUsage.putIfAbsent(model, ModelUsage.new);
existing.inputTokens += inputTokens;
existing.outputTokens += outputTokens;
existing.cacheReadInputTokens += cacheReadTokens;
existing.cacheCreationInputTokens += cacheCreationTokens;
existing.webSearchRequests += webSearchRequests;
existing.costUsd += cost;
return _state.totalCostUsd;
}
void recordApiDuration(int durationMs, {bool isRetry = false}) {
_state.totalApiDurationMs += durationMs;
if (!isRetry) {
_state.totalApiDurationWithoutRetriesMs += durationMs;
}
}
void recordToolDuration(int durationMs) {
_state.totalToolDurationMs += durationMs;
}
void recordWallDuration(int durationMs) {
_state.totalDurationMs += durationMs;
}
// Pretty-print cost for display ("$0.0042" or "$1.23")
String formatCost(double cost, {int maxDecimalPlaces = 4}) {
if (cost > 0.5) {
return "\$${(cost * 100).round() / 100}";
}
return "\$${cost.toStringAsFixed(maxDecimalPlaces)}";
}
String formatTotalCost() {
final costStr = formatCost(getTotalCostUsd()) +
(hasUnknownModelCost()
? " (costs may be inaccurate due to usage of unknown models)"
: "");
final linesAdded = getTotalLinesAdded();
final linesRemoved = getTotalLinesRemoved();
final usage = getModelUsage();
String usageStr;
if (usage.isEmpty) {
usageStr = "Usage: 0 input, 0 output, 0 cache read, 0 cache write";
} else {
final buf = StringBuffer("Usage by model:");
for (final entry in usage.entries) {
final u = entry.value;
final line = " ${_fmt(u.inputTokens)} input, "
"${_fmt(u.outputTokens)} output, "
"${_fmt(u.cacheReadInputTokens)} cache read, "
"${_fmt(u.cacheCreationInputTokens)} cache write"
"${u.webSearchRequests > 0 ? ", ${_fmt(u.webSearchRequests)} web search" : ""}"
" (${formatCost(u.costUsd)})";
final label = "${entry.key}:".padLeft(21);
buf.write("\n$label$line");
}
usageStr = buf.toString();
}
return "Total cost: $costStr\n"
"Total duration (API): ${_fmtDuration(getTotalApiDurationMs())}\n"
"Total duration (wall): ${_fmtDuration(getTotalDurationMs())}\n"
"Total code changes: $linesAdded ${linesAdded == 1 ? "line" : "lines"} added, "
"$linesRemoved ${linesRemoved == 1 ? "line" : "lines"} removed\n"
"$usageStr";
}
String _fmt(int n) {
// simple comma-formatted number
return n.toString().replaceAllMapped(
RegExp(r"(\d)(?=(\d{3})+$)"),
(m) => "${m[1]},",
);
}
String _fmtDuration(int ms) {
if (ms < 1000) return "${ms}ms";
final secs = ms / 1000;
if (secs < 60) return "${secs.toStringAsFixed(1)}s";
final mins = (secs / 60).floor();
final remainSecs = (secs % 60).toStringAsFixed(0);
return "${mins}m${remainSecs}s";
}
+136
View File
@@ -0,0 +1,136 @@
// Diagnostic tracking service stub
// Ported from old_repo/services/diagnosticTracking.ts
// IDE/LSP integration methods are stubbed (need MCP client)
class Diagnostic {
final String message;
final DiagnosticSeverity severity;
final DiagnosticRange range;
final String? source;
final String? code;
const Diagnostic({
required this.message,
required this.severity,
required this.range,
this.source,
this.code,
});
factory Diagnostic.fromJson(Map<String, dynamic> json) {
return Diagnostic(
message: json["message"] as String,
severity: DiagnosticSeverity.values.byName(
(json["severity"] as String).toLowerCase(),
),
range: DiagnosticRange.fromJson(json["range"] as Map<String, dynamic>),
source: json["source"] as String?,
code: json["code"]?.toString(),
);
}
Map<String, dynamic> toJson() => {
"message": message,
"severity": severity.name,
"range": range.toJson(),
if (source != null) "source": source,
if (code != null) "code": code,
};
}
enum DiagnosticSeverity { error, warning, info, hint }
class DiagnosticPosition {
final int line;
final int character;
const DiagnosticPosition({required this.line, required this.character});
factory DiagnosticPosition.fromJson(Map<String, dynamic> json) {
return DiagnosticPosition(
line: (json["line"] as num).toInt(),
character: (json["character"] as num).toInt(),
);
}
Map<String, dynamic> toJson() => {"line": line, "character": character};
}
class DiagnosticRange {
final DiagnosticPosition start;
final DiagnosticPosition end;
const DiagnosticRange({required this.start, required this.end});
factory DiagnosticRange.fromJson(Map<String, dynamic> json) {
return DiagnosticRange(
start: DiagnosticPosition.fromJson(json["start"] as Map<String, dynamic>),
end: DiagnosticPosition.fromJson(json["end"] as Map<String, dynamic>),
);
}
Map<String, dynamic> toJson() => {
"start": start.toJson(),
"end": end.toJson(),
};
}
class DiagnosticFile {
final String uri;
final List<Diagnostic> diagnostics;
const DiagnosticFile({required this.uri, required this.diagnostics});
factory DiagnosticFile.fromJson(Map<String, dynamic> json) {
final rawDiags = json["diagnostics"] as List<dynamic>? ?? [];
return DiagnosticFile(
uri: json["uri"] as String,
diagnostics: rawDiags
.map((d) => Diagnostic.fromJson(d as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() => {
"uri": uri,
"diagnostics": diagnostics.map((d) => d.toJson()).toList(),
};
}
// max chars for dignostic summary in tool results
const int maxDiagnosticsSummaryChars = 4000;
class DiagnosticTrackingService {
static DiagnosticTrackingService? _instance;
final Map<String, List<Diagnostic>> _baseline = {};
bool _initialized = false;
DiagnosticTrackingService._();
static DiagnosticTrackingService getInstance() {
_instance ??= DiagnosticTrackingService._();
return _instance!;
}
bool get isInitialized => _initialized;
// TODO: initialize with MCP client when available
void initialize() {
if (_initialized) return;
_initialized = true;
}
void reset() {
_baseline.clear();
}
Future<void> shutdown() async {
_initialized = false;
_baseline.clear();
}
// TODO: port IDE/LSP-dependent methods (fetchDiagnosticsForFile, etc.)
// These require MCP client integration
}
+125
View File
@@ -0,0 +1,125 @@
// OAuth service stub
// Ported from old_repo/services/oauth/
// Full implementation requires browser + HTTP — stubbed with TODOs
import "dart:convert";
import "dart:io";
import "../constants/oauth.dart";
// Represents a stored OAuth token pair
class OauthTokens {
final String accessToken;
final String? refreshToken;
final int? expiresAt; // unix epoch seconds
// optional profile stuff
final String? email;
final String? subscriptionType;
final String? rateLimitTier;
const OauthTokens({
required this.accessToken,
this.refreshToken,
this.expiresAt,
this.email,
this.subscriptionType,
this.rateLimitTier,
});
bool get isExpired {
if (expiresAt == null) return false;
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
// 60s buffer so we refresh before it actually expires
return now >= expiresAt! - 60;
}
factory OauthTokens.fromJson(Map<String, dynamic> json) => OauthTokens(
accessToken: json["access_token"] as String,
refreshToken: json["refresh_token"] as String?,
expiresAt: (json["expires_at"] as num?)?.toInt(),
email: json["email"] as String?,
subscriptionType: json["subscription_type"] as String?,
rateLimitTier: json["rate_limit_tier"] as String?,
);
Map<String, dynamic> toJson() => {
"access_token": accessToken,
if (refreshToken != null) "refresh_token": refreshToken,
if (expiresAt != null) "expires_at": expiresAt,
if (email != null) "email": email,
if (subscriptionType != null) "subscription_type": subscriptionType,
if (rateLimitTier != null) "rate_limit_tier": rateLimitTier,
};
}
// Returns the path where oauth tokens are stored on disk
String oauthTokenFilePath() {
final home = Platform.environment["HOME"] ?? "";
final suffix = fileSuffixForOauthConfig();
return "$home/.claude/.credentials$suffix.json";
}
// read stored tokens from disk, returns null if not found or parse fails
Future<OauthTokens?> loadStoredTokens() async {
final path = oauthTokenFilePath();
final file = File(path);
if (!await file.exists()) return null;
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return OauthTokens.fromJson(decoded);
} catch (_) {
// corrupted file or somthing, just pretend not logged in
return null;
}
}
// write tokens to disk
Future<void> saveTokens(OauthTokens tokens) async {
final path = oauthTokenFilePath();
final file = File(path);
// make sure the dir exists
await file.parent.create(recursive: true);
final encoded = const JsonEncoder.withIndent(" ").convert(tokens.toJson());
await file.writeAsString(encoded);
}
// delete the credentials file (logout)
Future<void> deleteTokens() async {
final file = File(oauthTokenFilePath());
if (await file.exists()) {
await file.delete();
}
}
// returns basic user info from the stored token, or null if not logged in
Future<Map<String, String?>?> getCurrentUser() async {
final tokens = await loadStoredTokens();
if (tokens == null) return null;
return {
"email": tokens.email,
"subscriptionType": tokens.subscriptionType,
"rateLimitTier": tokens.rateLimitTier,
};
}
// TODO: port loginWithBrowser() — requires launching a local HTTP server for the redirect
// and opening the OAuth authorize URL in the system browser
// Future<OauthTokens> loginWithBrowser({bool useClaudeAi = false}) async { ... }
// TODO: port refreshAccessToken() — requires HTTP POST to token URL
// Future<OauthTokens?> refreshAccessToken(String refreshToken) async { ... }
// TODO: port revokeTokens() — HTTP DELETE to revoke endpoint
// Future<void> revokeTokens() async { ... }
+38
View File
@@ -0,0 +1,38 @@
// Token estimation service
// Network-requiring methods (countTokensWithApi etc) are stubbed with TODOs
// The rough estimation functions are fully ported since they're pure math
import "../constants/tool_limits.dart";
/// Rough estimate of token count from character count
/// Default bytesPerToken is 4 (conservative)
int roughTokenCountEstimation(String content, {int bpt = 4}) {
return (content.length / bpt).round();
}
/// Returns bytes-per-token ratio for a given file extension.
/// JSON has lots of single-char tokens so we use 2 instead of 4
int bytesPerTokenForFileType(String fileExtension) {
switch (fileExtension) {
case "json":
case "jsonl":
case "jsonc":
return 2;
default:
return 4;
}
}
int roughTokenCountEstimationForFileType(String content, String fileExtension) {
return roughTokenCountEstimation(
content,
bpt: bytesPerTokenForFileType(fileExtension),
);
}
// TODO: port countTokensWithApi — requires live Anthropic client
// Future<int?> countTokensWithApi(String content) async { ... }
// TODO: port countMessagesTokensWithApi — requires live Anthropic client
// Future<int?> countMessagesTokensWithApi(List<Map> messages, List<Map> tools) async { ... }
+90
View File
@@ -0,0 +1,90 @@
import "dart:convert";
import "session_types.dart";
// in-memory conversation history for the current session
// does not auto-persist - caller has to call SessionStore.saveSession
class ConversationHistory {
ConversationHistory({ConversationSession? session}) : _session = session;
ConversationSession? _session;
ConversationSession? get session => _session;
bool get hasSession => _session != null;
List<Message> getMessages() {
return _session?.messages ?? <Message>[];
}
void setSession(ConversationSession s) {
_session = s;
}
void addMessage(String role, String content, {int? tokens}) {
if (_session == null) return;
final msg = Message(role: role, content: content, tokens: tokens);
_session!.messages.add(msg);
_session!.updated = DateTime.now().toUtc();
}
void removeLastMessage() {
if (_session == null || _session!.messages.isEmpty) {
return;
}
_session!.messages.removeLast();
_session!.updated = DateTime.now().toUtc();
}
void truncateMessages(int length) {
if (_session == null) {
return;
}
final targetLength = length.clamp(0, _session!.messages.length);
_session!.messages.removeRange(targetLength, _session!.messages.length);
_session!.updated = DateTime.now().toUtc();
}
// remove all messages but keep the session metadata
void clear() {
if (_session == null) return;
_session!.messages.clear();
_session!.updated = DateTime.now().toUtc();
}
// export as plain text - one [role]: content block per message
String exportToText() {
final buf = StringBuffer();
if (_session != null) {
buf.writeln("Session: ${_session!.name}");
buf.writeln("ID: ${_session!.id}");
buf.writeln("Created: ${_session!.created.toIso8601String()}");
buf.writeln("Messages: ${_session!.messageCount}");
buf.writeln("---");
buf.writeln();
}
for (final msg in getMessages()) {
buf.writeln("[${msg.role}]");
buf.writeln(msg.content);
buf.writeln();
}
return buf.toString();
}
String exportToJson() {
if (_session == null) {
return const JsonEncoder.withIndent(
" ",
).convert(<String, dynamic>{"messages": <dynamic>[]});
}
return const JsonEncoder.withIndent(" ").convert(_session!.toJson());
}
}
+114
View File
@@ -0,0 +1,114 @@
import "dart:convert";
import "dart:io";
import "../local_state.dart";
import "session_types.dart";
const _encoder = JsonEncoder.withIndent(" ");
// sessions live in ~/.clawd_code/sessions/{id}.json
String getSessionsDir() {
return joinPath(getConfigHomeDir(), "sessions");
}
String _sessionPath(String id) {
return joinPath(getSessionsDir(), "$id.json");
}
class SessionStore {
SessionStore._();
static final SessionStore instance = SessionStore._();
Future<void> saveSession(ConversationSession session) async {
final dir = Directory(getSessionsDir());
if (!await dir.exists()) {
await dir.create(recursive: true);
}
final file = File(_sessionPath(session.id));
final json = _encoder.convert(session.toJson());
await file.writeAsString("$json\n");
}
Future<ConversationSession?> loadSession(String id) async {
final file = File(_sessionPath(id));
if (!await file.exists()) return null;
try {
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
return ConversationSession.fromJson(decoded);
}
} catch (_) {
// corrupt file - just return null
}
return null;
}
// returns summaries sorted newest first
Future<List<SessionSummary>> listSessions() async {
final dir = Directory(getSessionsDir());
if (!await dir.exists()) return <SessionSummary>[];
final summaries = <SessionSummary>[];
await for (final entity in dir.list()) {
if (entity is! File) continue;
if (!entity.path.endsWith(".json")) continue;
try {
final raw = await entity.readAsString();
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
final sess = ConversationSession.fromJson(decoded);
summaries.add(SessionSummary.fromSession(sess));
}
} catch (_) {
// skip unreadable files
}
}
summaries.sort((a, b) => b.updated.compareTo(a.updated));
return summaries;
}
Future<bool> deleteSession(String id) async {
final file = File(_sessionPath(id));
if (!await file.exists()) return false;
await file.delete();
return true;
}
// case insensitive search by name
Future<ConversationSession?> findSessionByName(String name) async {
final dir = Directory(getSessionsDir());
if (!await dir.exists()) return null;
final lowerName = name.toLowerCase();
await for (final entity in dir.list()) {
if (entity is! File) continue;
if (!entity.path.endsWith(".json")) continue;
try {
final raw = await entity.readAsString();
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
final sess = ConversationSession.fromJson(decoded);
if (sess.name.toLowerCase() == lowerName) {
return sess;
}
}
} catch (_) {
continue;
}
}
return null;
}
}
+206
View File
@@ -0,0 +1,206 @@
// message roles - same as what the API uses
const validRoles = <String>["user", "assistant", "system", "tool"];
class Message {
Message({
required this.role,
required this.content,
DateTime? timestamp,
this.tokens,
}) : timestamp = timestamp ?? DateTime.now().toUtc();
factory Message.fromJson(Map<String, dynamic> json) {
return Message(
role: json["role"] as String,
content: json["content"] as String,
timestamp: json["timestamp"] != null
? DateTime.tryParse(json["timestamp"] as String)
: null,
tokens: json["tokens"] as int?,
);
}
final String role;
final String content;
final DateTime timestamp;
// approx token count - may be null if not tracked
final int? tokens;
Map<String, dynamic> toJson() {
return <String, dynamic>{
"role": role,
"content": content,
"timestamp": timestamp.toIso8601String(),
if (tokens != null) "tokens": tokens,
};
}
@override
String toString() => "[$role] $content";
}
class ConversationSession {
ConversationSession({
required this.id,
required this.name,
required this.created,
required this.updated,
List<Message>? messages,
this.cost,
this.model,
this.workingDirectory,
}) : messages = messages ?? <Message>[];
factory ConversationSession.fromJson(Map<String, dynamic> json) {
final rawMessages = json["messages"];
final msgs = <Message>[];
if (rawMessages is List) {
for (final m in rawMessages) {
if (m is Map<String, dynamic>) {
msgs.add(Message.fromJson(m));
}
}
}
return ConversationSession(
id: json["id"] as String,
name: json["name"] as String? ?? "Unnamed Session",
created:
DateTime.tryParse(json["created"] as String? ?? "") ??
DateTime.now().toUtc(),
updated:
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?,
);
}
final String id;
String name;
final DateTime created;
DateTime updated;
final List<Message> messages;
// total cost in USD - optional
double? cost;
String? model;
String? workingDirectory;
int get messageCount => messages.length;
// rough token total from tracked messages
int get totalTokens {
int t = 0;
for (final m in messages) {
t += m.tokens ?? 0;
}
return t;
}
ConversationSession copyWith({
String? name,
DateTime? updated,
List<Message>? messages,
double? cost,
String? model,
Object? workingDirectory = _sessionSentinel,
}) {
return ConversationSession(
id: id,
name: name ?? this.name,
created: created,
updated: updated ?? this.updated,
messages: messages ?? this.messages,
cost: cost ?? this.cost,
model: model ?? this.model,
workingDirectory: identical(workingDirectory, _sessionSentinel)
? this.workingDirectory
: workingDirectory as String?,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
"id": id,
"name": name,
"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,
};
}
}
// lightweight summary for listing - no messages loaded
class SessionSummary {
const SessionSummary({
required this.id,
required this.name,
required this.created,
required this.updated,
required this.messageCount,
this.cost,
this.model,
this.workingDirectory,
});
factory SessionSummary.fromJson(Map<String, dynamic> json) {
return SessionSummary(
id: json["id"] as String,
name: json["name"] as String? ?? "Unnamed Session",
created:
DateTime.tryParse(json["created"] as String? ?? "") ??
DateTime.now().toUtc(),
updated:
DateTime.tryParse(json["updated"] as String? ?? "") ??
DateTime.now().toUtc(),
messageCount: json["messageCount"] as int? ?? 0,
cost: (json["cost"] as num?)?.toDouble(),
model: json["model"] as String?,
workingDirectory: json["workingDirectory"] as String?,
);
}
final String id;
final String name;
final DateTime created;
final DateTime updated;
final int messageCount;
final double? cost;
final String? model;
final String? workingDirectory;
static SessionSummary fromSession(ConversationSession s) {
return SessionSummary(
id: s.id,
name: s.name,
created: s.created,
updated: s.updated,
messageCount: s.messageCount,
cost: s.cost,
model: s.model,
workingDirectory: s.workingDirectory,
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
"id": id,
"name": name,
"created": created.toIso8601String(),
"updated": updated.toIso8601String(),
"messageCount": messageCount,
if (cost != null) "cost": cost,
if (model != null) "model": model,
if (workingDirectory != null) "workingDirectory": workingDirectory,
};
}
}
const Object _sessionSentinel = Object();
+168
View File
@@ -0,0 +1,168 @@
import "dart:io";
import "skill_types.dart";
// where user skills live on disk
String getUserSkillsDir() {
final home = Platform.environment["HOME"] ?? "";
return "$home/.claude/skills";
}
// project skills live here relative to cwd
String getProjectSkillsDir() {
return "${Directory.current.path}/.claude/skills";
}
// parses very basic yaml-ish frontmatter out of a skill markdown file
// format is:
// ---
// key: value
// ---
// body content
//
// doesnt support nested keys or lists (those come later)
SkillFrontmatter _parseFrontmatter(String raw) {
if (!raw.startsWith("---")) {
return const SkillFrontmatter();
}
final end = raw.indexOf("\n---", 3);
if (end == -1) return const SkillFrontmatter();
final block = raw.substring(3, end).trim();
String? name;
String? description;
String? whenToUse;
String? argumentHint;
String? model;
String? context;
bool disableModelInvocation = false;
bool userInvocable = true;
final allowedTools = <String>[];
for (final line in block.split("\n")) {
final colonIdx = line.indexOf(":");
if (colonIdx == -1) continue;
final key = line.substring(0, colonIdx).trim();
final value = line.substring(colonIdx + 1).trim();
switch (key) {
case "name":
name = value;
case "description":
description = value;
case "when_to_use":
whenToUse = value;
case "argument-hint":
argumentHint = value;
case "model":
model = value;
case "context":
context = value;
case "disable-model-invocation":
disableModelInvocation = value == "true";
case "user-invocable":
userInvocable = value != "false";
case "allowed-tools":
// single line form: allowed-tools: Bash, Read
if (value.isNotEmpty) {
allowedTools.addAll(
value.split(",").map((s) => s.trim()).where((s) => s.isNotEmpty),
);
}
}
}
return SkillFrontmatter(
name: name,
description: description,
whenToUse: whenToUse,
argumentHint: argumentHint,
allowedTools: allowedTools,
model: model,
disableModelInvocation: disableModelInvocation,
userInvocable: userInvocable,
context: context,
);
}
// extracts body content after the closing --- of frontmatter
String _extractBody(String raw) {
if (!raw.startsWith("---")) return raw;
final end = raw.indexOf("\n---", 3);
if (end == -1) return raw;
return raw.substring(end + 4).trimLeft();
}
// loads a single skill file and returns a Skill or null on failure
Future<Skill?> _loadSkillFile(File file, SkillSource source) async {
try {
final raw = await file.readAsString();
final fm = _parseFrontmatter(raw);
final body = _extractBody(raw);
// derive name from filename if not in frontmatter
final fileName = file.parent.path.split(Platform.pathSeparator).last;
final skillName = fm.name ?? fileName;
final description =
fm.description ?? "Skill: $skillName";
return Skill(
name: skillName,
description: description,
source: source,
promptTemplate: body,
whenToUse: fm.whenToUse,
argumentHint: fm.argumentHint,
allowedTools: fm.allowedTools,
userInvocable: fm.userInvocable,
model: fm.model,
disableModelInvocation: fm.disableModelInvocation,
);
} catch (e) {
stderr.writeln("[skill_loader] failed to load ${file.path}: $e");
return null;
}
}
// discovers all skill files in a given directory
// looks for <dir>/<skill-name>/SKILL.md or <dir>/<skill-name>.md
Future<List<Skill>> loadSkillsFromDir(String dir, SkillSource source) async {
final directory = Directory(dir);
if (!await directory.exists()) return [];
final skills = <Skill>[];
await for (final entity in directory.list()) {
if (entity is Directory) {
// check for SKILL.md inside subdirectory
final skillFile = File("${entity.path}${Platform.pathSeparator}SKILL.md");
if (await skillFile.exists()) {
final skill = await _loadSkillFile(skillFile, source);
if (skill != null) skills.add(skill);
}
} else if (entity is File) {
final name = entity.path.split(Platform.pathSeparator).last;
if (name.endsWith(".md") && !name.startsWith(".")) {
final skill = await _loadSkillFile(entity, source);
if (skill != null) skills.add(skill);
}
}
}
return skills;
}
// loads user skills from ~/.claude/skills/
Future<List<Skill>> loadUserSkills() {
return loadSkillsFromDir(getUserSkillsDir(), SkillSource.user);
}
// loads project skills from .claude/skills/ relative to cwd
Future<List<Skill>> loadProjectSkills() {
return loadSkillsFromDir(getProjectSkillsDir(), SkillSource.project);
}
+436
View File
@@ -0,0 +1,436 @@
import "skill_loader.dart";
import "skill_types.dart";
// ported from old_repo/skills/bundled/*.ts
// each skill has a name, description, and a prompt template
// the prompts are trimmed down versions - dynamic sections (schema generation,
// keybinding tables) are replaced with static placeholders since we dont have
// the TS runtime context here
const _simplifyPrompt = """
# Simplify: Code Review and Cleanup
Review all changed files for reuse, quality, and efficiency. Fix any issues found.
## Phase 1: Identify Changes
Run `git diff` (or `git diff HEAD` if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation.
## Phase 2: Launch Three Review Agents in Parallel
Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context.
### Agent 1: Code Reuse Review
For each change:
1. **Search for existing utilities and helpers** that could replace newly written code.
2. **Flag any new function that duplicates existing functionality.**
3. **Flag any inline logic that could use an existing utility.**
### Agent 2: Code Quality Review
Review the same changes for hacky patterns:
1. **Redundant state**
2. **Parameter sprawl**
3. **Copy-paste with slight variation**
4. **Leaky abstractions**
5. **Stringly-typed code**
6. **Unnecessary nesting**
7. **Unnecessary comments**
### Agent 3: Efficiency Review
Review the same changes for efficiency:
1. **Unnecessary work**
2. **Missed concurrency**
3. **Hot-path bloat**
4. **Recurring no-op updates**
5. **Unnecessary existence checks**
6. **Memory leaks**
7. **Overly broad operations**
## Phase 3: Fix Issues
Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on.
When done, briefly summarize what was fixed (or confirm the code was already clean).
""";
const _updateConfigPrompt = """
# Update Config Skill
Modify Claude Code configuration by updating settings.json files.
## When Hooks Are Required (Not Memory)
If the user wants something to happen automatically in response to an EVENT, they need a **hook** configured in settings.json. Memory/preferences cannot trigger automated actions.
**These require hooks:**
- "Before compacting, ask me what to preserve" → PreCompact hook
- "After writing files, run prettier" → PostToolUse hook with Write|Edit matcher
- "When I run bash commands, log them" → PreToolUse hook with Bash matcher
- "Always run tests after code changes" → PostToolUse hook
**Hook events:** PreToolUse, PostToolUse, PreCompact, PostCompact, Stop, Notification, SessionStart
## CRITICAL: Read Before Write
**Always read the existing settings file before making changes.** Merge new settings with existing ones - never replace the entire file.
## Settings File Locations
| File | Scope | Use For |
|------|-------|---------|
| `~/.claude/settings.json` | Global | Personal preferences for all projects |
| `.claude/settings.json` | Project | Team-wide hooks, permissions, plugins |
| `.claude/settings.local.json` | Project | Personal overrides for this project |
## Workflow
1. **Clarify intent** - Ask if the request is ambiguous
2. **Read existing file** - Use Read tool on the target settings file
3. **Merge carefully** - Preserve existing settings, especially arrays
4. **Edit file** - Use Edit tool (if file doesn't exist, ask user to create it first)
5. **Confirm** - Tell user what was changed
## Common Mistakes to Avoid
1. **Replacing instead of merging** - Always preserve existing settings
2. **Wrong file** - Ask user if scope is unclear
3. **Invalid JSON** - Validate syntax after changes
4. **Forgetting to read first** - Always read before write
""";
const _keybindingsPrompt = """
# Keybindings Skill
Create or modify `~/.claude/keybindings.json` to customize keyboard shortcuts.
## CRITICAL: Read Before Write
**Always read `~/.claude/keybindings.json` first** (it may not exist yet). Merge changes with existing bindings — never replace the entire file.
## File Format
```json
{
"\$schema": "https://www.schemastore.org/claude-code-keybindings.json",
"bindings": [
{
"context": "Chat",
"bindings": {
"ctrl+e": "chat:externalEditor"
}
}
]
}
```
## Keystroke Syntax
**Modifiers** (combine with `+`): `ctrl`, `alt`, `shift`, `meta`
**Special keys**: `escape`, `enter`, `tab`, `space`, `backspace`, `delete`, arrow keys
**Chords**: Space-separated keystrokes, e.g. `ctrl+k ctrl+s`
## How User Bindings Interact with Defaults
- User bindings are **additive** — appended after the default bindings
- To **move** a binding: unbind the old key (`null`) AND add the new binding
- Set a key to `null` to remove its default binding
## Behavioral Rules
1. Only include contexts the user wants to change
2. Validate that actions and contexts are from the known lists
3. Warn the user if they choose a key that conflicts with reserved shortcuts
4. When adding a new binding, the new binding is additive
5. To fully replace a default binding, unbind old AND add new
""";
const _debugPrompt = """
# Debug Skill
Help the user debug an issue they're encountering in this current Claude Code session.
## Session Debug Log
The debug log for the current session is at: `~/.claude/debug/<session-id>.txt`
Read the debug log and look for errors, warnings, and failure patterns.
## Instructions
1. Review the user's issue description
2. Look for [ERROR] and [WARN] entries, stack traces, and failure patterns in the debug log
3. Explain what you found in plain language
4. Suggest concrete fixes or next steps
## Settings
Settings are in:
* user - `~/.claude/settings.json`
* project - `.claude/settings.json`
* local - `.claude/settings.local.json`
""";
const _rememberPrompt = """
# Memory Review
## Goal
Review the user's memory landscape and produce a clear report of proposed changes, grouped by action type. Do NOT apply changes — present proposals for user approval.
## 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.
### 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 |
| **Stay in auto-memory** | Working notes, temporary context |
### 3. Identify cleanup opportunities
- **Duplicates**: entries already in CLAUDE.md or CLAUDE.local.md
- **Outdated**: entries contradicted by newer entries
- **Conflicts**: contradictions between any two layers
### 4. Present the report
1. **Promotions** — entries to move, with destination and rationale
2. **Cleanup** — duplicates, outdated entries, conflicts to resolve
3. **Ambiguous** — entries where user input is needed
4. **No action needed** — entries that should stay put
## Rules
- Present ALL proposals before making any changes
- Do NOT modify files without explicit user approval
- Ask about ambiguous entries — don't guess
""";
const _skillifyPrompt = """
# Skillify
You are capturing this session's repeatable process as a reusable skill.
## Your Task
### Step 1: Analyze the Session
Before asking questions, analyze the session to identify:
- What repeatable process was performed
- What inputs/parameters were needed
- The distinct steps (in order)
- The success criteria for each step
- Where the user corrected or steered you
- What tools and permissions were needed
### Step 2: Interview the User
Use AskUserQuestion for ALL questions.
**Round 1:** Suggest a name and description, confirm high-level goals.
**Round 2:** Present steps, suggest arguments if needed, ask about inline vs forked context, and where to save (repo or personal).
**Round 3:** For each step, clarify what it produces, how success is verified, and whether human confirmation is needed.
**Round 4:** Confirm when to invoke, trigger phrases, and any gotchas.
### Step 3: Write the SKILL.md
Format:
```markdown
---
name: skill-name
description: one-line description
allowed-tools:
- Bash(git:*)
when_to_use: Use when...
argument-hint: "\$arg_name"
arguments:
- arg_name
context: fork
---
# Skill Title
## Goal
...
## Steps
### 1. Step Name
What to do.
**Success criteria**: How to know this step is done.
```
### Step 4: Confirm and Save
Show the complete SKILL.md content before writing. Ask for confirmation with AskUserQuestion. After writing, tell the user where it was saved and how to invoke it.
""";
const _stuckPrompt = """
# /stuck — diagnose frozen/slow Claude Code sessions
The user thinks another Claude Code session on this machine is frozen, stuck, or very slow. Investigate and post a report to #claude-code-feedback.
## What to look for
Scan for other Claude Code processes. Process names are typically `claude` (installed) or `cli` (native dev build).
Signs of a stuck session:
- **High CPU (≥90%) sustained** — likely an infinite loop
- **Process state `D`** — I/O hang
- **Process state `T`** — user probably hit Ctrl+Z
- **Process state `Z`** — zombie
- **Very high RSS (≥4GB)** — possible memory leak
## Investigation steps
1. List all Claude Code processes:
```
ps -axo pid=,pcpu=,rss=,etime=,state=,comm=,command= | grep -E '(claude|cli)' | grep -v grep
```
2. For anything suspicious, gather more context:
- Child processes: `pgrep -lP <pid>`
- Check debug log: `~/.claude/debug/<session-id>.txt`
## Report
Only post to Slack if you found something stuck. If everything looks healthy, tell the user directly.
If stuck, post to **#claude-code-feedback** using Slack MCP tool with a two-message structure:
1. **Top-level**: hostname, version, terse symptom
2. **Thread reply**: full diagnostic dump
""";
// registry - holds all skills keyed by name
class SkillRegistry {
SkillRegistry._();
static final SkillRegistry instance = SkillRegistry._();
final _skills = <String, Skill>{};
void register(Skill skill) {
_skills[skill.name] = skill;
for (final alias in skill.aliases) {
_skills[alias] = skill;
}
}
// looks up a skill by name or alias
Skill? lookup(String name) => _skills[name];
// all registered skills (deduped by name)
List<Skill> get all {
final seen = <String>{};
final result = <Skill>[];
for (final skill in _skills.values) {
if (seen.add(skill.name)) {
result.add(skill);
}
}
return result;
}
// merge in user and project skills, user skills take precedence over project
// bundled skills take lowest precedence
void mergeExternalSkills(List<Skill> skills) {
for (final skill in skills) {
// external skills override bundled ones with same name
_skills[skill.name] = skill;
}
}
}
// registers the built-in bundled skills
// mirrors old_repo/skills/bundled/index.ts initBundledSkills()
void registerBundledSkills() {
final reg = SkillRegistry.instance;
reg.register(const Skill(
name: "update-config",
description: "Use this skill to configure the Claude Code harness via settings.json. Automated behaviors require hooks configured in settings.json. Also use for permissions, env vars, hook troubleshooting, or any changes to settings.json files.",
source: SkillSource.bundled,
promptTemplate: _updateConfigPrompt,
allowedTools: ["Read"],
userInvocable: true,
));
reg.register(const Skill(
name: "keybindings-help",
description: "Use when the user wants to customize keyboard shortcuts, rebind keys, add chord bindings, or modify ~/.claude/keybindings.json.",
source: SkillSource.bundled,
promptTemplate: _keybindingsPrompt,
allowedTools: ["Read"],
userInvocable: false,
));
reg.register(const Skill(
name: "simplify",
description: "Review changed code for reuse, quality, and efficiency, then fix any issues found.",
source: SkillSource.bundled,
promptTemplate: _simplifyPrompt,
userInvocable: true,
));
reg.register(const Skill(
name: "debug",
description: "Enable debug logging for this session and help diagnose issues",
source: SkillSource.bundled,
promptTemplate: _debugPrompt,
allowedTools: ["Read", "Grep", "Glob"],
argumentHint: "[issue description]",
disableModelInvocation: true,
userInvocable: true,
));
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.",
source: SkillSource.bundled,
promptTemplate: _rememberPrompt,
whenToUse: "Use when the user wants to review, organize, or promote their auto-memory entries.",
userInvocable: true,
));
reg.register(const Skill(
name: "skillify",
description: "Capture this session's repeatable process into a skill.",
source: SkillSource.bundled,
promptTemplate: _skillifyPrompt,
allowedTools: ["Read", "Write", "Edit", "Glob", "Grep", "AskUserQuestion", "Bash(mkdir:*)"],
argumentHint: "[description of the process you want to capture]",
disableModelInvocation: true,
userInvocable: true,
));
// stuck is ant-only in the original but we register it here anyway
// the caller can filter by checking USER_TYPE env var
reg.register(const Skill(
name: "stuck",
description: "[ANT-ONLY] Investigate frozen/stuck/slow Claude Code sessions on this machine.",
source: SkillSource.bundled,
promptTemplate: _stuckPrompt,
userInvocable: true,
));
}
// loads user and project skills and merges them into the registry
Future<void> loadAndMergeExternalSkills() async {
final userSkills = await loadUserSkills();
final projectSkills = await loadProjectSkills();
// project skills override user skills for same name
final all = [...userSkills, ...projectSkills];
SkillRegistry.instance.mergeExternalSkills(all);
}
+85
View File
@@ -0,0 +1,85 @@
// skill model types - mirrors the old bundledSkills.ts / loadSkillsDir.ts structure
// where the skill came from
enum SkillSource {
bundled,
user,
project,
// mcp skills aren't fully suported yet
mcp,
}
// a skill definition - either bundled or loaded from disk
class Skill {
const Skill({
required this.name,
required this.description,
required this.source,
required this.promptTemplate,
this.whenToUse,
this.argumentHint,
this.allowedTools = const [],
this.userInvocable = true,
this.aliases = const [],
this.model,
this.disableModelInvocation = false,
});
final String name;
final String description;
final SkillSource source;
// the raw markdown prompt body - args substituted at invocation time
final String promptTemplate;
final String? whenToUse;
final String? argumentHint;
// tools this skill is allowed to use
final List<String> allowedTools;
// whether the user can invoke it via /name
final bool userInvocable;
final List<String> aliases;
// optional model override
final String? model;
final bool disableModelInvocation;
// resolve the prompt with optional user-supplied args
String resolvePrompt(String args) {
if (args.isEmpty) return promptTemplate;
return "$promptTemplate\n\n## User Request\n\n$args";
}
}
// frontmatter parsed from a .md skill file on disk
class SkillFrontmatter {
const SkillFrontmatter({
this.name,
this.description,
this.whenToUse,
this.argumentHint,
this.allowedTools = const [],
this.model,
this.disableModelInvocation = false,
this.userInvocable = true,
this.context,
});
final String? name;
final String? description;
final String? whenToUse;
final String? argumentHint;
final List<String> allowedTools;
final String? model;
final bool disableModelInvocation;
final bool userInvocable;
// 'fork' or null (inline is default)
final String? context;
}
@@ -0,0 +1,20 @@
String buildDefaultSystemPrompt({
String? appendSystemPrompt,
String? customSystemPrompt,
}) {
if (customSystemPrompt != null && customSystemPrompt.trim().isNotEmpty) {
final parts = <String>[customSystemPrompt];
if (appendSystemPrompt != null && appendSystemPrompt.trim().isNotEmpty) {
parts.add(appendSystemPrompt);
}
return parts.join("\n\n");
}
final parts = <String>[
"You are Claude Code, an AI assistant for software engineering.",
];
if (appendSystemPrompt != null && appendSystemPrompt.trim().isNotEmpty) {
parts.add(appendSystemPrompt);
}
return parts.join("\n\n");
}
+242
View File
@@ -0,0 +1,242 @@
// TaskManager — manages background tasks, spawn/cancel/list.
// Persists task state to ~/.claude/tasks/
import "dart:convert";
import "dart:io";
import "task_types.dart";
// Where we persist task state files
String _tasksDir() {
final home = Platform.environment["HOME"] ?? "/tmp";
return "$home/.claude/tasks";
}
String _taskFilePath(String taskId) {
return "${_tasksDir()}/$taskId.json";
}
class TaskManager {
// in-memory task registry
final Map<String, TaskStateBase> _tasks = {};
// singleton-ish pattern
static final TaskManager instance = TaskManager._();
TaskManager._();
Future<void> _ensureDir() async {
final dir = Directory(_tasksDir());
if (!dir.existsSync()) {
await dir.create(recursive: true);
}
}
// register a new task (in memory + on disk)
Future<void> register(TaskStateBase task) async {
_tasks[task.id] = task;
await _persist(task);
}
// update a task's state and re-persist
Future<void> update(String taskId, void Function(TaskStateBase task) mutate) async {
final task = _tasks[taskId];
if (task == null) return;
mutate(task);
await _persist(task);
}
// get a task by ID
TaskStateBase? get(String taskId) => _tasks[taskId];
// list all tasks, optionally filtered by status
List<TaskStateBase> list({TaskStatus? status}) {
final all = _tasks.values.toList();
if (status == null) return all;
return all.where((t) => t.status == status).toList();
}
// list active (non-terminal) tasks
List<TaskStateBase> listActive() {
return _tasks.values
.where((t) => !t.status.isTerminal)
.toList();
}
// spawn a local shell task
Future<LocalShellTaskState> spawnShellTask(LocalShellSpawnInput input) async {
final id = generateTaskId(TaskType.localBash);
final outputFile = "${_tasksDir()}/$id.output";
final task = LocalShellTaskState(
id: id,
status: TaskStatus.pending,
description: input.description,
toolUseId: input.toolUseId,
startTime: DateTime.now().millisecondsSinceEpoch,
outputFile: outputFile,
command: input.command,
kind: input.kind,
);
await register(task);
return task;
}
// spawn a local agent task
Future<LocalAgentTaskState> spawnAgentTask({
required String description,
required String prompt,
String? toolUseId,
}) async {
final id = generateTaskId(TaskType.localAgent);
final outputFile = "${_tasksDir()}/$id.output";
final task = LocalAgentTaskState(
id: id,
status: TaskStatus.pending,
description: description,
toolUseId: toolUseId,
startTime: DateTime.now().millisecondsSinceEpoch,
outputFile: outputFile,
prompt: prompt,
);
await register(task);
return task;
}
// cancel/kill a task
Future<bool> cancelTask(String taskId) async {
final task = _tasks[taskId];
if (task == null) return false;
if (task.status.isTerminal) {
// already dead
return false;
}
await update(taskId, (t) {
t.status = TaskStatus.killed;
t.endTime = DateTime.now().millisecondsSinceEpoch;
t.notified = true;
});
return true;
}
// mark a task as completed
Future<void> completeTask(String taskId, {int? exitCode}) async {
await update(taskId, (t) {
t.status = TaskStatus.completed;
t.endTime = DateTime.now().millisecondsSinceEpoch;
if (t is LocalShellTaskState && exitCode != null) {
t.exitCode = exitCode;
}
});
}
// mark a task as failed
Future<void> failTask(String taskId, {String? reason}) async {
await update(taskId, (t) {
t.status = TaskStatus.failed;
t.endTime = DateTime.now().millisecondsSinceEpoch;
});
}
// load persisted tasks from disk on startup
Future<void> loadFromDisk() async {
final dir = Directory(_tasksDir());
if (!dir.existsSync()) return;
await for (final entry in dir.list()) {
if (entry is! File) continue;
if (!entry.path.endsWith(".json")) continue;
try {
final raw = await (entry as File).readAsString();
final json = jsonDecode(raw) as Map<String, dynamic>;
final task = TaskStateBase.fromJson(json);
// dont re-load already terminal tasks older than 24h
if (task.status.isTerminal) {
final age = DateTime.now().millisecondsSinceEpoch - task.startTime;
if (age > const Duration(hours: 24).inMilliseconds) continue;
}
_tasks[task.id] = task;
} catch (_) {
// corupt file, skip
}
}
}
// evict old terminal tasks from memory (not from disk)
void evictOldTasks({Duration maxAge = const Duration(hours: 1)}) {
final cutoff = DateTime.now().millisecondsSinceEpoch - maxAge.inMilliseconds;
_tasks.removeWhere((_, t) {
if (!t.status.isTerminal) return false;
final end = t.endTime;
if (end == null) return false;
return end < cutoff;
});
}
// get pill label for background tasks (matches legacy pillLabel.ts logic)
String getPillLabel() {
final active = listActive().where(isBackgroundTask).toList();
if (active.isEmpty) return "";
final n = active.length;
final allSameType = active.every((t) => t.type == active.first.type);
if (allSameType && active.isNotEmpty) {
switch (active.first.type) {
case TaskType.localBash:
final monitors = active.whereType<LocalShellTaskState>()
.where((t) => t.kind == "monitor")
.length;
final shells = n - monitors;
final parts = <String>[];
if (shells > 0) parts.add(shells == 1 ? "1 shell" : "$shells shells");
if (monitors > 0) parts.add(monitors == 1 ? "1 monitor" : "$monitors monitors");
return parts.join(", ");
case TaskType.localAgent:
return n == 1 ? "1 local agent" : "$n local agents";
case TaskType.remoteAgent:
return n == 1 ? "◇ 1 cloud session" : "$n cloud sessions";
case TaskType.dream:
return "dreaming";
default:
break;
}
}
return "$n background ${n == 1 ? "task" : "tasks"}";
}
Future<void> _persist(TaskStateBase task) async {
await _ensureDir();
final file = File(_taskFilePath(task.id));
await file.writeAsString(jsonEncode(task.toJson()), flush: true);
}
}
+231
View File
@@ -0,0 +1,231 @@
// TaskRunner — spawns and manages task execution.
// Ported from old_repo/tasks/ and old_repo/Task.ts
import "dart:async";
import "dart:io";
import "task_types.dart";
import "task_manager.dart";
// StopTaskError — thrown when a task cannot be stopped
class StopTaskError implements Exception {
final String message;
final String code; // "not_found", "not_running", or "unsupported_type"
StopTaskError(this.message, this.code);
@override
String toString() => "StopTaskError: $message (code: $code)";
}
// StopTaskResult — result of stopping a task
class StopTaskResult {
final String taskId;
final String taskType;
final String? command;
const StopTaskResult({
required this.taskId,
required this.taskType,
this.command,
});
}
// TaskRunner — coordinates task spawning and lifecycle management
class TaskRunner {
final TaskManager taskManager;
TaskRunner({TaskManager? taskManager})
: taskManager = taskManager ?? TaskManager.instance;
// spawnShellTask — spawn a local shell task and start execution
Future<LocalShellTaskState> spawnShellTask({
required String command,
required String description,
String? toolUseId,
String? agentId,
String kind = "bash",
Duration? timeout,
}) async {
// Create and register the task
final task = await taskManager.spawnShellTask(
LocalShellSpawnInput(
command: command,
description: description,
toolUseId: toolUseId,
agentId: agentId,
kind: kind,
timeout: timeout,
),
);
// Mark as running
await taskManager.update(task.id, (t) {
t.status = TaskStatus.running;
});
// Start the actual process execution in the background
_executeShellTask(task, timeout);
return task;
}
// spawnAgentTask — spawn a local agent task
Future<LocalAgentTaskState> spawnAgentTask({
required String description,
required String prompt,
String? toolUseId,
}) async {
final task = await taskManager.spawnAgentTask(
description: description,
prompt: prompt,
toolUseId: toolUseId,
);
await taskManager.update(task.id, (t) {
t.status = TaskStatus.running;
});
return task;
}
// stopTask — stop a running task by ID
Future<StopTaskResult> stopTask(String taskId) async {
final task = taskManager.get(taskId);
if (task == null) {
throw StopTaskError("No task found with ID: $taskId", "not_found");
}
if (task.status != TaskStatus.running) {
throw StopTaskError(
"Task $taskId is not running (status: ${task.status.key})",
"not_running",
);
}
// Mark as killed
await taskManager.cancelTask(taskId);
// For shell tasks, suppress exit notification
if (task is LocalShellTaskState) {
await taskManager.update(taskId, (t) {
t.notified = true;
});
}
final command = task is LocalShellTaskState ? task.command : task.description;
return StopTaskResult(
taskId: taskId,
taskType: task.type.key,
command: command,
);
}
// _executeShellTask — internal method to execute a shell task
Future<void> _executeShellTask(
LocalShellTaskState task,
Duration? timeout,
) async {
try {
final process = await Process.start(
"bash",
["-c", task.command],
);
// Write output to file
final outputFile = File(task.outputFile);
await outputFile.create(recursive: true);
final sink = outputFile.openWrite();
// Stream stdout + stderr to output file and track
process.stdout.transform(
const SystemEncoding().decoder,
).listen((String data) {
sink.write(data);
});
process.stderr.transform(
const SystemEncoding().decoder,
).listen((String data) {
sink.write("[STDERR] $data");
});
// Wait for process to complete (with optional timeout)
final exitCode = await (timeout != null
? process.exitCode.timeout(timeout)
: process.exitCode).catchError((_) => 137); // 137 = SIGKILL
await sink.close();
// Mark as completed
await taskManager.completeTask(task.id, exitCode: exitCode);
} catch (e) {
// Mark as failed
await taskManager.failTask(task.id, reason: e.toString());
}
}
// listTasks — list all tasks
List<TaskStateBase> listTasks({TaskStatus? status}) {
return taskManager.list(status: status);
}
// getBackgroundTasks — list currently active (background) tasks
List<TaskStateBase> getBackgroundTasks() {
return taskManager.listActive();
}
}
// getPillLabel — get display label for background tasks (ported from pillLabel.ts)
String getPillLabel(List<TaskStateBase> tasks) {
final n = tasks.length;
if (n == 0) return "";
final allSameType = tasks.every((t) => t.type == tasks.first.type);
if (allSameType && tasks.isNotEmpty) {
switch (tasks.first.type) {
case TaskType.localBash:
final monitors = tasks
.whereType<LocalShellTaskState>()
.where((t) => t.kind == "monitor")
.length;
final shells = n - monitors;
final parts = <String>[];
if (shells > 0) parts.add(shells == 1 ? "1 shell" : "$shells shells");
if (monitors > 0) parts.add(monitors == 1 ? "1 monitor" : "$monitors monitors");
return parts.join(", ");
case TaskType.localAgent:
return n == 1 ? "1 local agent" : "$n local agents";
case TaskType.remoteAgent:
return n == 1 ? "◇ 1 cloud session" : "$n cloud sessions";
case TaskType.inProcessTeammate:
return n == 1 ? "1 teammate" : "$n teammates";
case TaskType.localWorkflow:
return n == 1 ? "1 workflow" : "$n workflows";
case TaskType.monitorMcp:
return n == 1 ? "1 monitor" : "$n monitors";
case TaskType.dream:
return "dreaming";
}
}
return "$n background ${n == 1 ? "task" : "tasks"}";
}
+313
View File
@@ -0,0 +1,313 @@
// Task model types — ported from old_repo/Task.ts and old_repo/tasks/types.ts
import "dart:math";
// matches the TypeScript TaskType union
enum TaskType {
localBash,
localAgent,
remoteAgent,
inProcessTeammate,
localWorkflow,
monitorMcp,
dream;
// legacy string key used in task IDs and storage
String get key {
switch (this) {
case TaskType.localBash: return "local_bash";
case TaskType.localAgent: return "local_agent";
case TaskType.remoteAgent: return "remote_agent";
case TaskType.inProcessTeammate: return "in_process_teammate";
case TaskType.localWorkflow: return "local_workflow";
case TaskType.monitorMcp: return "monitor_mcp";
case TaskType.dream: return "dream";
}
}
// prefix used when generating task IDs
String get idPrefix {
switch (this) {
case TaskType.localBash: return "b";
case TaskType.localAgent: return "a";
case TaskType.remoteAgent: return "r";
case TaskType.inProcessTeammate: return "t";
case TaskType.localWorkflow: return "w";
case TaskType.monitorMcp: return "m";
case TaskType.dream: return "d";
}
}
static TaskType? fromKey(String key) {
for (final t in TaskType.values) {
if (t.key == key) return t;
}
return null;
}
}
enum TaskStatus {
pending,
running,
completed,
failed,
killed;
String get key {
switch (this) {
case TaskStatus.pending: return "pending";
case TaskStatus.running: return "running";
case TaskStatus.completed: return "completed";
case TaskStatus.failed: return "failed";
case TaskStatus.killed: return "killed";
}
}
bool get isTerminal =>
this == TaskStatus.completed ||
this == TaskStatus.failed ||
this == TaskStatus.killed;
static TaskStatus? fromKey(String key) {
for (final s in TaskStatus.values) {
if (s.key == key) return s;
}
return null;
}
}
// base fields shared by all task types
class TaskStateBase {
final String id;
final TaskType type;
TaskStatus status;
final String description;
final String? toolUseId;
final int startTime;
int? endTime;
int? totalPausedMs;
final String outputFile;
int outputOffset;
bool notified;
TaskStateBase({
required this.id,
required this.type,
required this.status,
required this.description,
this.toolUseId,
required this.startTime,
this.endTime,
this.totalPausedMs,
required this.outputFile,
this.outputOffset = 0,
this.notified = false,
});
Map<String, dynamic> toJson() => {
"id": id,
"type": type.key,
"status": status.key,
"description": description,
if (toolUseId != null) "tool_use_id": toolUseId,
"start_time": startTime,
if (endTime != null) "end_time": endTime,
if (totalPausedMs != null) "total_paused_ms": totalPausedMs,
"output_file": outputFile,
"output_offset": outputOffset,
"notified": notified,
};
static TaskStateBase fromJson(Map<String, dynamic> json) {
return TaskStateBase(
id: json["id"] as String,
type: TaskType.fromKey(json["type"] as String) ?? TaskType.localBash,
status: TaskStatus.fromKey(json["status"] as String) ?? TaskStatus.pending,
description: json["description"] as String? ?? "",
toolUseId: json["tool_use_id"] as String?,
startTime: json["start_time"] as int,
endTime: json["end_time"] as int?,
totalPausedMs: json["total_paused_ms"] as int?,
outputFile: json["output_file"] as String? ?? "",
outputOffset: json["output_offset"] as int? ?? 0,
notified: json["notified"] as bool? ?? false,
);
}
}
// Result returned from a completed task execution
class TaskResult {
final String taskId;
final TaskStatus status;
final String? output;
final String? errorMessage;
final int durationMs;
final int? exitCode;
const TaskResult({
required this.taskId,
required this.status,
this.output,
this.errorMessage,
required this.durationMs,
this.exitCode,
});
bool get isSuccess => status == TaskStatus.completed;
Map<String, dynamic> toJson() => {
"task_id": taskId,
"status": status.key,
if (output != null) "output": output,
if (errorMessage != null) "error_message": errorMessage,
"duration_ms": durationMs,
if (exitCode != null) "exit_code": exitCode,
};
}
// spawn input for shell tasks
class LocalShellSpawnInput {
final String command;
final String description;
final Duration? timeout;
final String? toolUseId;
final String? agentId;
// display variant: bash or monitor
final String kind;
const LocalShellSpawnInput({
required this.command,
required this.description,
this.timeout,
this.toolUseId,
this.agentId,
this.kind = "bash",
});
}
// Local agent task — runs a sub-agent in process
class LocalAgentTaskState extends TaskStateBase {
final String prompt;
int toolUseCount;
int tokenCount;
String? lastActivity;
LocalAgentTaskState({
required super.id,
required super.status,
required super.description,
super.toolUseId,
required super.startTime,
super.endTime,
required super.outputFile,
required this.prompt,
this.toolUseCount = 0,
this.tokenCount = 0,
this.lastActivity,
}) : super(type: TaskType.localAgent);
}
// Shell task state
class LocalShellTaskState extends TaskStateBase {
final String command;
final String kind; // "bash" | "monitor"
int? exitCode;
LocalShellTaskState({
required super.id,
required super.status,
required super.description,
super.toolUseId,
required super.startTime,
super.endTime,
required super.outputFile,
required this.command,
this.kind = "bash",
this.exitCode,
}) : super(type: TaskType.localBash);
}
// Remote agent task state (cloud / ultraplan etc.)
class RemoteAgentTaskState extends TaskStateBase {
final String sessionId;
final String command;
final String title;
final String remoteTaskType;
bool isLongRunning;
RemoteAgentTaskState({
required super.id,
required super.status,
required super.description,
super.toolUseId,
required super.startTime,
super.endTime,
required super.outputFile,
required this.sessionId,
required this.command,
required this.title,
required this.remoteTaskType,
this.isLongRunning = false,
}) : super(type: TaskType.remoteAgent);
}
// Dream task — memory consolidation sub-agent
class DreamTaskState extends TaskStateBase {
String phase; // "starting" | "updating"
int sessionsReviewing;
List<String> filesTouched;
final int priorMtime;
DreamTaskState({
required super.id,
required super.status,
required super.description,
super.toolUseId,
required super.startTime,
super.endTime,
required super.outputFile,
this.phase = "starting",
this.sessionsReviewing = 0,
List<String>? filesTouched,
required this.priorMtime,
}) : filesTouched = filesTouched ?? [],
super(type: TaskType.dream);
}
// --- task ID generation ---
final _rng = Random.secure();
const _alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
String generateTaskId(TaskType type) {
final prefix = type.idPrefix;
final buf = StringBuffer(prefix);
for (var i = 0; i < 8; i++) {
buf.write(_alphabet[_rng.nextInt(_alphabet.length)]);
}
return buf.toString();
}
// helpers to check background task eligibility
bool isBackgroundTask(TaskStateBase task) {
if (task.status != TaskStatus.running && task.status != TaskStatus.pending) {
return false;
}
// foreground-flagged tasks (isBackgrounded == false) are not background tasks
// local agent tasks handle this via their own flag — here we just use status
return true;
}
+37
View File
@@ -0,0 +1,37 @@
// base class for all tools
abstract class BaseTool {
String get name;
String get description;
Future<String> execute(Map<String, dynamic> input);
// helper to get a required string field
String requireString(Map<String, dynamic> input, String key) {
final val = input[key];
if (val == null) throw ArgumentError("Missing required field: $key");
return val.toString();
}
String? optionalString(Map<String, dynamic> input, String key) {
final val = input[key];
if (val == null) return null;
return val.toString();
}
int? optionalInt(Map<String, dynamic> input, String key) {
final val = input[key];
if (val == null) return null;
if (val is int) return val;
if (val is String) return int.tryParse(val);
return null;
}
bool optionalBool(Map<String, dynamic> input, String key, {bool defaultVal = false}) {
final val = input[key];
if (val == null) return defaultVal;
if (val is bool) return val;
if (val is String) return val == "true" || val == "1";
return defaultVal;
}
}
+100
View File
@@ -0,0 +1,100 @@
import "dart:async";
import "dart:io";
import "dart:convert";
import "base_tool.dart";
// default timeouts (ms)
const int _defaultTimeoutMs = 120000;
const int _maxTimeoutMs = 600000;
class BashTool extends BaseTool {
@override
final String name = "Bash";
@override
final String description = "Execute a bash command and return its output.";
@override
Future<String> execute(Map<String, dynamic> input) async {
final command = requireString(input, "command");
final timeoutMs = optionalInt(input, "timeout") ?? _defaultTimeoutMs;
final workingDirectory = optionalString(input, "cwd");
if (command.trim().isEmpty) {
throw ArgumentError("command must not be empty");
}
// clamp timeout to max
final effectiveTimeout = timeoutMs.clamp(1, _maxTimeoutMs);
final result = await _runCommand(
command,
Duration(milliseconds: effectiveTimeout),
workingDirectory: workingDirectory,
);
return result;
}
Future<String> _runCommand(
String command,
Duration timeout, {
String? workingDirectory,
}) async {
Process proc;
try {
proc = await Process.start(
"/bin/sh",
["-c", command],
runInShell: false,
workingDirectory: workingDirectory,
);
} catch (e) {
throw StateError("Failed to start process: $e");
}
final stdoutBuf = StringBuffer();
final stderrBuf = StringBuffer();
final stdoutDone = proc.stdout
.transform(utf8.decoder)
.listen((chunk) => stdoutBuf.write(chunk));
final stderrDone = proc.stderr
.transform(utf8.decoder)
.listen((chunk) => stderrBuf.write(chunk));
int exitCode;
try {
exitCode = await proc.exitCode.timeout(
timeout,
onTimeout: () {
proc.kill(ProcessSignal.sigkill);
throw TimeoutException(
"Command timed out after ${timeout.inMilliseconds}ms",
timeout,
);
},
);
} finally {
await stdoutDone.cancel();
await stderrDone.cancel();
}
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);
return combined.toString();
}
}
+108
View File
@@ -0,0 +1,108 @@
import "dart:io";
import "dart:convert";
import "base_tool.dart";
// max file size we'll try to edit in memory (1 GiB)
const int _maxEditFileSize = 1024 * 1024 * 1024;
class FileEditTool extends BaseTool {
@override
final String name = "Edit";
@override
final String description =
"Performs exact string replacements in files. "
"Requires old_string and new_string. "
"Set replace_all=true to replace all occurrences.";
@override
Future<String> execute(Map<String, dynamic> input) async {
final filePath = requireString(input, "file_path");
final oldString = requireString(input, "old_string");
final newString = requireString(input, "new_string");
final replaceAll = optionalBool(input, "replace_all");
if (oldString == newString) {
return "Error: old_string and new_string are identical - no changes to make.";
}
final file = File(filePath);
if (!await file.exists()) {
// empty old_string on non-existant file = create new file
if (oldString.isEmpty) {
final parent = file.parent;
if (!await parent.exists()) {
await parent.create(recursive: true);
}
await file.writeAsString(newString, encoding: utf8, flush: true);
return "File created successfully at: $filePath";
}
return "Error: File does not exist: $filePath";
}
// size check
final stat = await file.stat();
if (stat.size > _maxEditFileSize) {
return "Error: File is too large to edit (${stat.size} bytes).";
}
String content;
try {
content = await file.readAsString(encoding: utf8);
} catch (_) {
try {
final bytes = await file.readAsBytes();
content = latin1.decode(bytes);
} catch (e) {
return "Error: Could not read file: $e";
}
}
// normalise line endings
content = content.replaceAll("\r\n", "\n");
if (oldString.isEmpty) {
// empty old_string on existing file - only valid if file is also empty
if (content.trim().isNotEmpty) {
return "Error: Cannot create new file - file already exists with content.";
}
await file.writeAsString(newString, encoding: utf8, flush: true);
return "The file $filePath has been updated successfully.";
}
if (!content.contains(oldString)) {
return "Error: String to replace not found in file.\nString: $oldString";
}
// check for multiple matches when replace_all is false
if (!replaceAll) {
final matches = oldString.allMatches(content).length;
if (matches > 1) {
return "Error: Found $matches matches of the string to replace, but replace_all is false. "
"Either set replace_all=true or provide more context to uniquely identify the instance.";
}
}
final updated = replaceAll
? content.replaceAll(oldString, newString)
: content.replaceFirst(oldString, newString);
try {
await file.writeAsString(updated, encoding: utf8, flush: true);
} catch (e) {
return "Error: Failed to write file: $e";
}
if (replaceAll) {
return "The file $filePath has been updated. All occurrences were successfully replaced.";
}
return "The file $filePath has been updated successfully.";
}
}
+94
View File
@@ -0,0 +1,94 @@
import "dart:io";
import "dart:convert";
import "base_tool.dart";
// blocked paths that would hang or make no sense to read
const _blockedPaths = {
"/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;
class FileReadTool extends BaseTool {
@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.";
@override
Future<String> execute(Map<String, dynamic> input) async {
final filePath = requireString(input, "file_path");
final offset = optionalInt(input, "offset") ?? 0;
final limit = optionalInt(input, "limit");
// check blocked device paths
if (_blockedPaths.contains(filePath)) {
return "Error: Reading from $filePath is not allowed.";
}
final file = File(filePath);
if (!await file.exists()) {
return "Error: File not found: $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";
}
String content;
try {
content = await file.readAsString(encoding: utf8);
} catch (e) {
// maybe its latin1 or something
try {
final bytes = await file.readAsBytes();
content = latin1.decode(bytes);
} catch (_) {
return "Error: Could not read file (binary or encoding issue): $filePath";
}
}
// normalise line endings
content = content.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
final lines = content.split("\n");
// apply offset + limit
final effectiveOffset = offset.clamp(0, lines.length);
final effectiveEnd = limit != null
? (effectiveOffset + limit).clamp(0, lines.length)
: lines.length;
final sliced = lines.sublist(effectiveOffset, effectiveEnd);
// 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]}");
}
final result = buf.toString();
if (result.isEmpty) {
return "(empty file)";
}
return result;
}
}
+50
View File
@@ -0,0 +1,50 @@
import "dart:io";
import "dart:convert";
import "base_tool.dart";
class FileWriteTool extends BaseTool {
@override
final String name = "Write";
@override
final String description =
"Writes a file to the local filesystem. "
"Overwrites the file if it already exists. "
"Creates parent directories as needed.";
@override
Future<String> execute(Map<String, dynamic> input) async {
final filePath = requireString(input, "file_path");
final content = requireString(input, "content");
if (filePath.isEmpty) {
throw ArgumentError("file_path must not be empty");
}
final file = File(filePath);
// figure out if this is a create or update
final existed = await file.exists();
// make sure parent dir exists
final parent = file.parent;
if (!await parent.exists()) {
await parent.create(recursive: true);
}
try {
await file.writeAsString(content, encoding: utf8, flush: true);
} catch (e) {
return "Error: Failed to write file: $e";
}
if (existed) {
return "The file $filePath has been updated successfully.";
}
return "File created successfully at: $filePath";
}
}
+146
View File
@@ -0,0 +1,146 @@
import "dart:io";
import "base_tool.dart";
class GlobTool extends BaseTool {
@override
final String name = "Glob";
@override
final String description =
"Fast file pattern matching. Supports glob patterns like \"**/*.js\". "
"Returns matching file paths sorted by modification time.";
@override
Future<String> execute(Map<String, dynamic> input) async {
final pattern = requireString(input, "pattern");
final pathArg = optionalString(input, "path");
final searchDir = pathArg != null ? Directory(pathArg) : Directory.current;
if (!await searchDir.exists()) {
return "Error: Directory does not exist: ${searchDir.path}";
}
final start = DateTime.now();
final matched = <FileSystemEntity>[];
await for (final entity in searchDir.list(recursive: true, followLinks: false)) {
if (entity is File) {
final rel = entity.path.substring(searchDir.path.length);
// strip leading slash
final relClean = rel.startsWith("/") ? rel.substring(1) : rel;
if (_matchGlob(pattern, relClean)) {
matched.add(entity);
}
}
}
const maxResults = 100;
final truncated = matched.length > maxResults;
final limited = truncated ? matched.sublist(0, maxResults) : matched;
// sort by mtime desc (same as original)
final withStat = <MapEntry<FileSystemEntity, DateTime>>[];
for (final f in limited) {
try {
final st = await f.stat();
withStat.add(MapEntry(f, st.modified));
} catch (_) {
withStat.add(MapEntry(f, DateTime.fromMillisecondsSinceEpoch(0)));
}
}
withStat.sort((a, b) => b.value.compareTo(a.value));
final filenames = withStat.map((e) {
final rel = e.key.path.substring(searchDir.path.length);
return rel.startsWith("/") ? rel.substring(1) : rel;
}).toList();
final durationMs = DateTime.now().difference(start).inMilliseconds;
if (filenames.isEmpty) {
return "No files found";
}
final buf = StringBuffer();
for (final f in filenames) {
buf.writeln(f);
}
if (truncated) {
buf.writeln("(Results are truncated. Consider using a more specific path or pattern.)");
}
// small summary footer
buf.write("Found ${filenames.length} file${filenames.length == 1 ? "" : "s"} in ${durationMs}ms");
return buf.toString();
}
// basic glob matching - handles ** and *
bool _matchGlob(String pattern, String path) {
return _globMatch(pattern, path);
}
bool _globMatch(String pattern, String text) {
// convert glob to regex-ish check
final regexStr = _globToRegex(pattern);
final regex = RegExp(regexStr);
return regex.hasMatch(text);
}
String _globToRegex(String pattern) {
final buf = StringBuffer("^");
int i = 0;
while (i < pattern.length) {
final c = pattern[i];
if (c == "*") {
if (i + 1 < pattern.length && pattern[i + 1] == "*") {
// ** matches anything including slashes
buf.write(".*");
i += 2;
// skip optional trailing slash after **
if (i < pattern.length && pattern[i] == "/") i++;
} else {
// * matches anything except slash
buf.write("[^/]*");
i++;
}
} else if (c == "?") {
buf.write("[^/]");
i++;
} else if (c == ".") {
buf.write("\\.");
i++;
} else if (c == "{") {
// {a,b,c} style alternation
final close = pattern.indexOf("}", i);
if (close == -1) {
buf.write("\\{");
i++;
} else {
final inner = pattern.substring(i + 1, close);
final parts = inner.split(",").map(_globToRegex).join("|");
buf.write("(?:$parts)");
i = close + 1;
}
} else if ("[]()|^".contains(c)) {
buf.write("\\$c");
i++;
} else {
buf.write(c);
i++;
}
}
buf.write(r"$");
return buf.toString();
}
}
+269
View File
@@ -0,0 +1,269 @@
import "dart:io";
import "dart:convert";
import "base_tool.dart";
// directories to skip during grep searches
const _vcsSkip = {".git", ".svn", ".hg", ".bzr", ".jj", ".sl"};
const int _defaultHeadLimit = 250;
class GrepTool extends BaseTool {
@override
final String name = "Grep";
@override
final String description =
"A powerful search tool. Supports full regex syntax. "
"Filter files with glob parameter. "
"Output modes: content, files_with_matches, count.";
@override
Future<String> execute(Map<String, dynamic> input) async {
final pattern = requireString(input, "pattern");
final pathArg = optionalString(input, "path");
final glob = optionalString(input, "glob");
final outputMode = optionalString(input, "output_mode") ?? "files_with_matches";
final caseInsensitive = optionalBool(input, "-i");
final showLineNumbers = optionalBool(input, "-n", defaultVal: true);
final multiline = optionalBool(input, "multiline");
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,
);
}
// fallback - pure dart implementation
return _runPureDart(
pattern: pattern,
path: pathArg,
glob: glob,
outputMode: outputMode,
caseInsensitive: caseInsensitive,
showLineNumbers: showLineNumbers,
headLimit: headLimit,
offset: offset,
);
}
Future<String?> _findRipgrep() async {
for (final candidate in ["rg", "/usr/bin/rg", "/usr/local/bin/rg"]) {
try {
final res = await Process.run("which", [candidate]);
if ((res.exitCode == 0) && (res.stdout as String).trim().isNotEmpty) {
return (res.stdout as String).trim();
}
} catch (_) {}
}
return null;
}
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,
int? headLimit,
required int offset,
int? contextLines,
int? contextBefore,
int? contextAfter,
}) async {
final searchPath = path ?? Directory.current.path;
final args = <String>["--hidden"];
for (final dir in _vcsSkip) {
args.addAll(["--glob", "!$dir"]);
}
args.addAll(["--max-columns", "500"]);
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");
}
if (showLineNumbers && outputMode == "content") args.add("-n");
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);
}
Future<String> _runPureDart({
required String pattern,
String? path,
String? glob,
required String outputMode,
required bool caseInsensitive,
required bool showLineNumbers,
int? headLimit,
required int offset,
}) async {
final searchDir = Directory(path ?? Directory.current.path);
if (!await searchDir.exists()) {
return "Error: Path does not exist: ${searchDir.path}";
}
final regex = RegExp(pattern, caseSensitive: !caseInsensitive, multiLine: true);
final matchedFiles = <String>[];
final contentLines = <String>[];
var totalMatches = 0;
await for (final entity in searchDir.list(recursive: true, followLinks: false)) {
if (entity is! File) continue;
// skip vcs dirs
final parts = entity.path.split("/");
if (parts.any((p) => _vcsSkip.contains(p))) continue;
// glob filter
if (glob != null) {
final filename = entity.path.split("/").last;
if (!_simpleGlobMatch(glob, filename)) continue;
}
String content;
try {
content = await entity.readAsString(encoding: utf8);
} catch (_) {
continue; // skip binary/unreadable
}
final fileMatches = regex.allMatches(content).length;
if (fileMatches == 0) continue;
final relPath = entity.path.startsWith(searchDir.path)
? entity.path.substring(searchDir.path.length + 1)
: entity.path;
if (outputMode == "files_with_matches") {
matchedFiles.add(relPath);
} else if (outputMode == "count") {
contentLines.add("$relPath:$fileMatches");
totalMatches += fileMatches;
} else {
// content mode
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]}");
}
}
matchedFiles.add(relPath);
}
}
if (outputMode == "files_with_matches") {
return _formatResults(matchedFiles, outputMode, headLimit, offset);
}
return _formatResults(contentLines, outputMode, headLimit, offset);
}
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";
}
return sliced.join("\n");
}
bool _simpleGlobMatch(String pattern, String text) {
final regex = RegExp(
"^${pattern.replaceAll(".", "\\.").replaceAll("*", ".*").replaceAll("?", ".")}\$",
);
return regex.hasMatch(text);
}
}
+43
View File
@@ -0,0 +1,43 @@
import "base_tool.dart";
import "bash_tool.dart";
import "glob_tool.dart";
import "grep_tool.dart";
import "file_read_tool.dart";
import "file_write_tool.dart";
import "file_edit_tool.dart";
// registry that holds all available tools by name
class ToolRegistry {
final Map<String, BaseTool> _tools = {};
ToolRegistry() {
_register(BashTool());
_register(GlobTool());
_register(GrepTool());
_register(FileReadTool());
_register(FileWriteTool());
_register(FileEditTool());
}
void _register(BaseTool tool) {
_tools[tool.name] = tool;
}
BaseTool? getTool(String name) => _tools[name];
List<BaseTool> get allTools => _tools.values.toList();
List<String> get toolNames => _tools.keys.toList();
// execute a tool by name
Future<String> execute(String toolName, Map<String, dynamic> input) async {
final tool = _tools[toolName];
if (tool == null) {
return "Error: Unknown tool \"$toolName\". Available tools: ${toolNames.join(", ")}";
}
return tool.execute(input);
}
}
+44
View File
@@ -0,0 +1,44 @@
// Ported from old_repo/utils/agentId.ts
// Deterministic agent ID helpers for the swarm/teammate system.
/// Format an agent ID: agentName@teamName
String formatAgentId(String agentName, String teamName) {
return "$agentName@$teamName";
}
/// Parse an agentId like "researcher@my-project".
/// Returns null if the @ separator is missing.
({String agentName, String teamName})? parseAgentId(String agentId) {
final idx = agentId.indexOf("@");
if (idx == -1) return null;
return (
agentName: agentId.substring(0, idx),
teamName: agentId.substring(idx + 1),
);
}
/// Generate a request ID: requestType-timestamp@agentId
String generateRequestId(String requestType, String agentId) {
final ts = DateTime.now().millisecondsSinceEpoch;
return "$requestType-$ts@$agentId";
}
/// Parse a request ID back into its parts. Returns null on bad format.
({String requestType, int timestamp, String agentId})? parseRequestId(String requestId) {
final atIdx = requestId.indexOf("@");
if (atIdx == -1) return null;
final prefix = requestId.substring(0, atIdx);
final agentId = requestId.substring(atIdx + 1);
final dashIdx = prefix.lastIndexOf("-");
if (dashIdx == -1) return null;
final requestType = prefix.substring(0, dashIdx);
final tsStr = prefix.substring(dashIdx + 1);
final timestamp = int.tryParse(tsStr);
if (timestamp == null) return null;
return (requestType: requestType, timestamp: timestamp, agentId: agentId);
}
+119
View File
@@ -0,0 +1,119 @@
// Ported from old_repo/utils/argumentSubstitution.ts
// Handles $ARGUMENTS substitution in skill/command prompts.
/// Parse an arguments string into individual tokens.
/// Shell quoting is roughly handled: quoted strings are kept together.
List<String> parseArguments(String args) {
if (args.trim().isEmpty) return [];
final tokens = <String>[];
final buf = StringBuffer();
var inSingle = false;
var inDouble = false;
for (var i = 0; i < args.length; i++) {
final ch = args[i];
if (ch == "'" && !inDouble) {
inSingle = !inSingle;
} else if (ch == '"' && !inSingle) {
inDouble = !inDouble;
} else if (ch == " " && !inSingle && !inDouble) {
final tok = buf.toString();
if (tok.isNotEmpty) tokens.add(tok);
buf.clear();
} else {
buf.write(ch);
}
}
final last = buf.toString();
if (last.isNotEmpty) tokens.add(last);
return tokens;
}
/// Parse argument names from a frontmatter "arguments" field.
/// Accepts a space-separated string or a list.
List<String> parseArgumentNames(dynamic argumentNames) {
if (argumentNames == null) return [];
bool isValid(String name) =>
name.trim().isNotEmpty && !RegExp(r"^\d+$").hasMatch(name);
if (argumentNames is List) {
return argumentNames
.whereType<String>()
.where(isValid)
.toList();
}
if (argumentNames is String) {
return argumentNames.split(RegExp(r"\s+")).where(isValid).toList();
}
return [];
}
/// Generate a hint showing unfilled argument names.
String? generateProgressiveArgumentHint(List<String> argNames, List<String> typedArgs) {
final remaining = argNames.skip(typedArgs.length).toList();
if (remaining.isEmpty) return null;
return remaining.map((n) => "[$n]").join(" ");
}
/// Substitute \$ARGUMENTS placeholders in [content] with actual values.
///
/// Supports: \$ARGUMENTS, \$ARGUMENTS[n], \$n, named arguments.
/// If no placeholder found and [appendIfNoPlaceholder] is true, appends
/// "ARGUMENTS: {args}" to the content.
String substituteArguments(
String content,
String? args, {
bool appendIfNoPlaceholder = true,
List<String> argumentNames = const [],
}) {
if (args == null) return content;
final parsedArgs = parseArguments(args);
final original = content;
// named args
for (var i = 0; i < argumentNames.length; i++) {
final name = argumentNames[i];
final value = i < parsedArgs.length ? parsedArgs[i] : "";
content = content.replaceAll(
RegExp(r"\$" + RegExp.escape(name) + r"(?![\[\w])"),
value,
);
}
// \$ARGUMENTS[n]
content = content.replaceAllMapped(
RegExp(r'\$ARGUMENTS\[(\d+)\]'),
(m) {
final idx = int.parse(m.group(1)!);
return idx < parsedArgs.length ? parsedArgs[idx] : "";
},
);
// \$n shorthand
content = content.replaceAllMapped(
RegExp(r'\$(\d+)(?!\w)'),
(m) {
final idx = int.parse(m.group(1)!);
return idx < parsedArgs.length ? parsedArgs[idx] : "";
},
);
// \$ARGUMENTS
content = content.replaceAll("\$ARGUMENTS", args);
if (content == original && appendIfNoPlaceholder && args.isNotEmpty) {
content = "$content\n\nARGUMENTS: $args";
}
return content;
}
+35
View File
@@ -0,0 +1,35 @@
// Ported from old_repo/utils/array.ts
// small generic array helpers
/// Intersperse a separator element between all elements of [list].
/// The separator factory receives the index of the element that follows it.
List<T> intersperse<T>(List<T> list, T Function(int index) separator) {
if (list.isEmpty) return [];
final result = <T>[];
for (var i = 0; i < list.length; i++) {
if (i > 0) result.add(separator(i));
result.add(list[i]);
}
return result;
}
/// Count how many elements satisfy [pred].
int countWhere<T>(Iterable<T> items, bool Function(T x) pred) {
var n = 0;
for (final x in items) {
if (pred(x)) n++;
}
return n;
}
/// Remove duplicates, preserving first-seen order.
List<T> uniq<T>(Iterable<T> xs) {
final seen = <T>{};
final out = <T>[];
for (final x in xs) {
if (seen.add(x)) out.add(x);
}
return out;
}
+61
View File
@@ -0,0 +1,61 @@
// Ported from old_repo/utils/CircularBuffer.ts
/// Fixed-size circular buffer. Automatically evicts oldest items when full.
class CircularBuffer<T> {
final int capacity;
final List<T?> _buf;
int _head = 0;
int _size = 0;
CircularBuffer(this.capacity) : _buf = List.filled(capacity, null);
/// Add an item. If full, oldest item is evicted.
void add(T item) {
_buf[_head] = item;
_head = (_head + 1) % capacity;
if (_size < capacity) _size++;
}
void addAll(Iterable<T> items) {
for (final item in items) {
add(item);
}
}
/// Get the most recent [count] items, oldest-first.
List<T> getRecent(int count) {
final available = count < _size ? count : _size;
final start = _size < capacity ? 0 : _head;
final result = <T>[];
for (var i = 0; i < available; i++) {
final idx = (start + _size - available + i) % capacity;
result.add(_buf[idx] as T);
}
return result;
}
/// All items oldest-first.
List<T> toList() {
if (_size == 0) return [];
final start = _size < capacity ? 0 : _head;
final result = <T>[];
for (var i = 0; i < _size; i++) {
final idx = (start + i) % capacity;
result.add(_buf[idx] as T);
}
return result;
}
void clear() {
_buf.fillRange(0, capacity, null);
_head = 0;
_size = 0;
}
int get length => _size;
bool get isEmpty => _size == 0;
}
+36
View File
@@ -0,0 +1,36 @@
// Ported from old_repo/utils/cliArgs.ts
/// Parse a CLI flag value before full argument parsing.
/// Handles both --flag=value and --flag value syntax.
///
/// Useful for flags that need to be resolved before full CLI init
/// (e.g., --settings which affects config loading).
String? eagerParseCliFlag(String flagName, List<String> argv) {
for (var i = 0; i < argv.length; i++) {
final arg = argv[i];
// --flag=value
if (arg.startsWith("$flagName=")) {
return arg.substring(flagName.length + 1);
}
// --flag value
if (arg == flagName && i + 1 < argv.length) {
return argv[i + 1];
}
}
return null;
}
/// Handle the Unix -- separator convention.
///
/// When a command positional equals "--", the real command is argv[0]
/// and the rest of argv is the actual args. Returns corrected command + args.
({String command, List<String> args}) extractArgsAfterDoubleDash(
String commandOrValue,
List<String> args,
) {
if (commandOrValue == "--" && args.isNotEmpty) {
return (command: args[0], args: args.sublist(1));
}
return (command: commandOrValue, args: args);
}
+231
View File
@@ -0,0 +1,231 @@
// Diff formatting helpers
// Ported from diff.ts - pure dart, no external diff library
// We do a simple line-based diff (LCS approach)
const int contextLines = 3;
/// A single hunk from a diff
class DiffHunk {
final int oldStart;
final int oldLines;
final int newStart;
final int newLines;
// lines prefixed with ' ', '+', or '-'
final List<String> lines;
const DiffHunk({
required this.oldStart,
required this.oldLines,
required this.newStart,
required this.newLines,
required this.lines,
});
DiffHunk copyWith({int? oldStart, int? newStart}) => DiffHunk(
oldStart: oldStart ?? this.oldStart,
oldLines: oldLines,
newStart: newStart ?? this.newStart,
newLines: newLines,
lines: lines,
);
}
/// Adjust hunk line numbers by an offset.
/// Useful when the diff was computed on a slice of the file.
List<DiffHunk> adjustHunkLineNumbers(List<DiffHunk> hunks, int offset) {
if (offset == 0) return hunks;
return hunks.map((h) => h.copyWith(
oldStart: h.oldStart + offset,
newStart: h.newStart + offset,
)).toList();
}
// Myers-ish LCS diff between two string lists.
// Returns edit operations as (type, line) where type is ' ', '+', '-'
List<(String, String)> _diffLines(List<String> oldLines, List<String> newLines) {
final m = oldLines.length;
final n = newLines.length;
// Build LCS table
final dp = List.generate(m + 1, (_) => List.filled(n + 1, 0));
for (var i = m - 1; i >= 0; i--) {
for (var j = n - 1; j >= 0; j--) {
if (oldLines[i] == newLines[j]) {
dp[i][j] = dp[i + 1][j + 1] + 1;
} else {
dp[i][j] = dp[i + 1][j] > dp[i][j + 1] ? dp[i + 1][j] : dp[i][j + 1];
}
}
}
// Traceback
final result = <(String, String)>[];
var i = 0;
var j = 0;
while (i < m && j < n) {
if (oldLines[i] == newLines[j]) {
result.add((" ", oldLines[i]));
i++;
j++;
} else if (dp[i + 1][j] >= dp[i][j + 1]) {
result.add(("-", oldLines[i]));
i++;
} else {
result.add(("+", newLines[j]));
j++;
}
}
while (i < m) {
result.add(("-", oldLines[i++]));
}
while (j < n) {
result.add(("+", newLines[j++]));
}
return result;
}
/// Compute a unified diff between [oldContent] and [newContent].
/// Returns a list of hunks, similar to getPatchFromContents in the TS code.
List<DiffHunk> getPatchFromContents({
required String oldContent,
required String newContent,
bool ignoreWhitespace = false,
int context = contextLines,
}) {
var oldLines = oldContent.split("\n");
var newLines = newContent.split("\n");
if (ignoreWhitespace) {
oldLines = oldLines.map((l) => l.trimRight()).toList();
newLines = newLines.map((l) => l.trimRight()).toList();
}
final edits = _diffLines(oldLines, newLines);
if (edits.every((e) => e.$1 == " ")) return [];
// group edits into hunks with context
final hunks = <DiffHunk>[];
int oldLine = 1;
int newLine = 1;
// find changed regions and build hunks
var editIdx = 0;
while (editIdx < edits.length) {
// skip unchanged lines until we find a change
if (edits[editIdx].$1 == " ") {
oldLine++;
newLine++;
editIdx++;
continue;
}
// found a change - collect context before
final hunkStart = editIdx - context < 0 ? 0 : editIdx - context;
// find end of this change region
var changeEnd = editIdx;
while (changeEnd < edits.length && edits[changeEnd].$1 != " ") {
changeEnd++;
}
// extend by context after
final hunkEnd = changeEnd + context < edits.length ? changeEnd + context : edits.length;
// build hunk lines
final hunkLines = <String>[];
var hunkOldStart = oldLine;
var hunkNewStart = newLine;
var hunkOldCount = 0;
var hunkNewCount = 0;
// recalculate old/new line positions from start of edits
// by scanning from beginning to hunkStart
int oLine = 1;
int nLine = 1;
for (var k = 0; k < hunkStart; k++) {
if (edits[k].$1 != "+") oLine++;
if (edits[k].$1 != "-") nLine++;
}
hunkOldStart = oLine;
hunkNewStart = nLine;
for (var k = hunkStart; k < hunkEnd; k++) {
final (type, line) = edits[k];
hunkLines.add("$type$line");
if (type != "+") hunkOldCount++;
if (type != "-") hunkNewCount++;
}
hunks.add(DiffHunk(
oldStart: hunkOldStart,
oldLines: hunkOldCount,
newStart: hunkNewStart,
newLines: hunkNewCount,
lines: hunkLines,
));
// advance past this hunk
editIdx = hunkEnd;
for (var k = 0; k < hunkEnd; k++) {
if (edits[k].$1 != "+") oldLine++;
if (edits[k].$1 != "-") newLine++;
}
// skip ahead
oldLine = oLine + hunkOldCount;
newLine = nLine + hunkNewCount;
editIdx = hunkEnd;
}
return hunks;
}
/// Count lines added/removed across a list of hunks.
/// Returns a record of (additions, removals).
(int, int) countLinesChanged(List<DiffHunk> hunks, {String? newFileContent}) {
if (hunks.isEmpty && newFileContent != null) {
final additions = newFileContent.split(RegExp(r"\r?\n")).length;
return (additions, 0);
}
var additions = 0;
var removals = 0;
for (final hunk in hunks) {
for (final line in hunk.lines) {
if (line.startsWith("+")) additions++;
if (line.startsWith("-")) removals++;
}
}
return (additions, removals);
}
/// Format hunks as a unified diff string (for display)
String formatPatch(List<DiffHunk> hunks, {String filePath = "file"}) {
if (hunks.isEmpty) return "";
final buf = StringBuffer();
buf.writeln("--- $filePath");
buf.writeln("+++ $filePath");
for (final hunk in hunks) {
buf.writeln("@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@");
for (final line in hunk.lines) {
buf.writeln(line);
}
}
return buf.toString();
}
+37
View File
@@ -0,0 +1,37 @@
// env utils — environment variable helpers
// Ported from old_repo/utils/envUtils.ts
import "dart:io";
// Check if an environment variable is "truthy"
bool isEnvTruthy(String? envVar) {
if (envVar == null || envVar.isEmpty) return false;
final normalized = envVar.toLowerCase().trim();
return ["1", "true", "yes", "on"].contains(normalized);
}
// Check if an environment variable is explicitly "falsy"
bool isEnvDefinedFalsy(String? envVar) {
if (envVar == null) return false;
if (envVar.isEmpty) return false;
final normalized = envVar.toLowerCase().trim();
return ["0", "false", "no", "off"].contains(normalized);
}
// Get the Claude config home directory (default: ~/.claude)
String getClaudeConfigHomeDir() {
final configured = Platform.environment["CLAUDE_CONFIG_DIR"];
if (configured != null && configured.isNotEmpty) {
return configured;
}
return "${Platform.environment["HOME"] ?? "~"}/.claude";
}
// Get the teams directory
String getTeamsDir() {
return "${getClaudeConfigHomeDir()}/teams";
}
+81
View File
@@ -0,0 +1,81 @@
// Error classes and helpers
// Ported from errors.ts (subset without SDK/Node specific stuff)
class ClaudeError extends Error {
final String message;
ClaudeError(this.message);
@override
String toString() => "ClaudeError: $message";
}
class MalformedCommandError extends Error {
final String message;
MalformedCommandError(this.message);
@override
String toString() => "MalformedCommandError: $message";
}
class AbortError extends Error {
final String? message;
AbortError([this.message]);
@override
String toString() => "AbortError: ${message ?? ""}";
}
class ConfigParseError extends Error {
final String message;
final String filePath;
final dynamic defaultConfig;
ConfigParseError(this.message, this.filePath, this.defaultConfig);
@override
String toString() => "ConfigParseError: $message (file: $filePath)";
}
class ShellError extends Error {
final String stdout;
final String stderr;
final int code;
final bool interrupted;
ShellError({
required this.stdout,
required this.stderr,
required this.code,
required this.interrupted,
});
@override
String toString() => "ShellError(code: $code): $stderr";
}
/// Check if error is an abort-type error
bool isAbortError(Object? e) {
return e is AbortError;
}
/// Extract message string from unknown error
String errorMessage(Object? e) {
if (e == null) return "null";
if (e is Error) return e.toString();
if (e is Exception) return e.toString();
return e.toString();
}
/// Normalize unknown value to an Exception
Exception toException(Object? e) {
if (e is Exception) return e;
return Exception(e.toString());
}
bool hasExactErrorMessage(Object? error, String message) {
if (error is Error) return error.toString() == message;
if (error is Exception) return error.toString() == message;
return false;
}
+257
View File
@@ -0,0 +1,257 @@
// Display formatters - file size, duration, token counts, relative time, etc.
// Ported from format.ts
import "dart:math" as math;
/// Formats byte count to human readable string
String formatFileSize(int sizeInBytes) {
final kb = sizeInBytes / 1024;
if (kb < 1) {
return "$sizeInBytes bytes";
}
if (kb < 1024) {
final s = kb.toStringAsFixed(1);
final stripped = s.endsWith(".0") ? s.substring(0, s.length - 2) : s;
return "${stripped}KB";
}
final mb = kb / 1024;
if (mb < 1024) {
final s = mb.toStringAsFixed(1);
final stripped = s.endsWith(".0") ? s.substring(0, s.length - 2) : s;
return "${stripped}MB";
}
final gb = mb / 1024;
final s = gb.toStringAsFixed(1);
final stripped = s.endsWith(".0") ? s.substring(0, s.length - 2) : s;
return "${stripped}GB";
}
/// Formats milliseconds as seconds with 1 decimal place eg "1.2s"
String formatSecondsShort(num ms) {
return "${(ms / 1000).toStringAsFixed(1)}s";
}
/// Options for formatDuration
class DurationFormatOptions {
final bool hideTrailingZeros;
final bool mostSignificantOnly;
const DurationFormatOptions({
this.hideTrailingZeros = false,
this.mostSignificantOnly = false,
});
}
String formatDuration(num ms, [DurationFormatOptions? options]) {
if (ms < 60000) {
if (ms == 0) return "0s";
if (ms < 1) {
return "${(ms / 1000).toStringAsFixed(1)}s";
}
final s = (ms / 1000).floor().toString();
return "${s}s";
}
int days = (ms / 86400000).floor();
int hours = ((ms % 86400000) / 3600000).floor();
int minutes = ((ms % 3600000) / 60000).floor();
int seconds = ((ms % 60000) / 1000).round();
// handle rounding carryover
if (seconds == 60) {
seconds = 0;
minutes++;
}
if (minutes == 60) {
minutes = 0;
hours++;
}
if (hours == 24) {
hours = 0;
days++;
}
final hide = options?.hideTrailingZeros ?? false;
if (options?.mostSignificantOnly == true) {
if (days > 0) return "${days}d";
if (hours > 0) return "${hours}h";
if (minutes > 0) return "${minutes}m";
return "${seconds}s";
}
if (days > 0) {
if (hide && hours == 0 && minutes == 0) return "${days}d";
if (hide && minutes == 0) return "${days}d ${hours}h";
return "${days}d ${hours}h ${minutes}m";
}
if (hours > 0) {
if (hide && minutes == 0 && seconds == 0) return "${hours}h";
if (hide && seconds == 0) return "${hours}h ${minutes}m";
return "${hours}h ${minutes}m ${seconds}s";
}
if (minutes > 0) {
if (hide && seconds == 0) return "${minutes}m";
return "${minutes}m ${seconds}s";
}
return "${seconds}s";
}
/// Format a number in compact notation eg 1321 -> "1.3k"
String formatNumber(num number) {
if (number.abs() >= 1000000000) {
final val = number / 1000000000;
final str = val.toStringAsFixed(1);
final trimmed = str.endsWith(".0") ? str.substring(0, str.length - 2) : str;
return "${trimmed}b";
} else if (number.abs() >= 1000000) {
final val = number / 1000000;
final str = val.toStringAsFixed(1);
final trimmed = str.endsWith(".0") ? str.substring(0, str.length - 2) : str;
return "${trimmed}m";
} else if (number.abs() >= 1000) {
final val = number / 1000;
final str = val.toStringAsFixed(1);
// for >= 1000 we keep consistent decimals
return "${str}k";
}
return number.truncate().toString();
}
String formatTokens(int count) {
return formatNumber(count).replaceAll(".0", "");
}
// relative time formatting
enum RelativeTimeStyle { long, short, narrow }
String formatRelativeTime(
DateTime date, {
RelativeTimeStyle style = RelativeTimeStyle.narrow,
DateTime? now,
}) {
final _now = now ?? DateTime.now();
final diffMs = date.difference(_now).inMilliseconds;
final diffSeconds = (diffMs / 1000).truncate();
final intervals = [
(unit: "year", seconds: 31536000, short: "y"),
(unit: "month", seconds: 2592000, short: "mo"),
(unit: "week", seconds: 604800, short: "w"),
(unit: "day", seconds: 86400, short: "d"),
(unit: "hour", seconds: 3600, short: "h"),
(unit: "minute", seconds: 60, short: "m"),
(unit: "second", seconds: 1, short: "s"),
];
for (final interval in intervals) {
if (diffSeconds.abs() >= interval.seconds) {
final value = (diffSeconds / interval.seconds).truncate();
if (style == RelativeTimeStyle.narrow) {
return diffSeconds < 0
? "${value.abs()}${interval.short} ago"
: "in $value${interval.short}";
}
// fall back to long format
final absVal = value.abs();
final unitStr = absVal == 1 ? interval.unit : "${interval.unit}s";
return diffSeconds < 0 ? "$absVal $unitStr ago" : "in $absVal $unitStr";
}
}
if (style == RelativeTimeStyle.narrow) {
return diffSeconds <= 0 ? "0s ago" : "in 0s";
}
return "0 seconds ago";
}
String formatRelativeTimeAgo(DateTime date, {DateTime? now}) {
final _now = now ?? DateTime.now();
if (date.isAfter(_now)) {
return formatRelativeTime(date, now: _now);
}
return formatRelativeTime(date, now: _now);
}
/// Log metadata display string
String formatLogMetadata({
required DateTime modified,
required int messageCount,
int? fileSize,
String? gitBranch,
String? tag,
String? agentSetting,
int? prNumber,
String? prRepository,
}) {
final sizeOrCount = fileSize != null
? formatFileSize(fileSize)
: "$messageCount messages";
final parts = <String>[
formatRelativeTimeAgo(modified, now: DateTime.now()),
if (gitBranch != null) gitBranch,
sizeOrCount,
];
if (tag != null) parts.add("#$tag");
if (agentSetting != null) parts.add("@$agentSetting");
if (prNumber != null) {
parts.add(prRepository != null
? "$prRepository#$prNumber"
: "#$prNumber");
}
return parts.join(" · ");
}
/// Brief timestamp - same day shows time, within 6 days shows weekday, older shows full date
String formatBriefTimestamp(String isoString, [DateTime? now]) {
final d = DateTime.tryParse(isoString);
if (d == null) return "";
final _now = now ?? DateTime.now();
final todayStart = DateTime(_now.year, _now.month, _now.day);
final dateStart = DateTime(d.year, d.month, d.day);
final daysAgo = todayStart.difference(dateStart).inDays;
if (daysAgo == 0) {
// same day - show time
final hour = d.hour;
final minute = d.minute.toString().padLeft(2, "0");
final h12 = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour);
final ampm = hour < 12 ? "AM" : "PM";
return "$h12:$minute $ampm";
}
const weekdays = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
final weekday = weekdays[d.weekday - 1];
final hour = d.hour;
final minute = d.minute.toString().padLeft(2, "0");
final h12 = hour == 0 ? 12 : (hour > 12 ? hour - 12 : hour);
final ampm = hour < 12 ? "AM" : "PM";
final time = "$h12:$minute $ampm";
if (daysAgo > 0 && daysAgo < 7) {
return "$weekday, $time";
}
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
final month = months[d.month - 1];
return "$weekday, $month ${d.day}, $time";
}
+171
View File
@@ -0,0 +1,171 @@
// Glob pattern matching - ported from glob.ts
// pure Dart, no ripgrep dependency
// supports: *, **, ?, [abc], {a,b,c}, negation with !
import "dart:io";
import "package:path/path.dart" as p;
/// Extract the static base directory from a glob pattern.
/// Returns (baseDir, relativePattern).
(String, String) extractGlobBaseDirectory(String pattern) {
final globChars = RegExp(r"[*?\[{]");
final match = globChars.firstMatch(pattern);
if (match == null) {
// no glob chars - literal path
final dir = p.dirname(pattern);
final file = p.basename(pattern);
return (dir, file);
}
final staticPrefix = pattern.substring(0, match.start);
final lastSep = staticPrefix.lastIndexOf("/");
if (lastSep == -1) {
return ("", pattern);
}
var baseDir = staticPrefix.substring(0, lastSep);
final relPattern = pattern.substring(lastSep + 1);
if (baseDir.isEmpty && lastSep == 0) {
baseDir = "/";
}
return (baseDir, relPattern);
}
/// Convert a glob pattern to a RegExp.
/// Handles *, **, ?, [abc], {a,b} (basic brace expansion)
RegExp globToRegex(String pattern) {
final buf = StringBuffer("^");
// expand braces first eg {a,b,c} -> (a|b|c)
final expanded = _expandBraces(pattern);
for (var i = 0; i < expanded.length; i++) {
final ch = expanded[i];
if (ch == "*") {
if (i + 1 < expanded.length && expanded[i + 1] == "*") {
// ** matches any path including separators
buf.write(".*");
i++; // skip next *
// skip trailing slash after ** if present
if (i + 1 < expanded.length && expanded[i + 1] == "/") {
i++;
}
} else {
// * matches anything except path separator
buf.write("[^/]*");
}
} else if (ch == "?") {
buf.write("[^/]");
} else if (ch == "[") {
// pass through character classes
final end = expanded.indexOf("]", i + 1);
if (end == -1) {
buf.write(RegExp.escape("["));
} else {
buf.write(expanded.substring(i, end + 1));
i = end;
}
} else if (ch == "(") {
// from brace expansion - pass as regex group
buf.write("(");
} else if (ch == ")") {
buf.write(")");
} else if (ch == "|") {
buf.write("|");
} else {
buf.write(RegExp.escape(ch));
}
}
buf.write(r"$");
return RegExp(buf.toString());
}
String _expandBraces(String pattern) {
final start = pattern.indexOf("{");
if (start == -1) return pattern;
final end = pattern.indexOf("}", start);
if (end == -1) return pattern;
final prefix = pattern.substring(0, start);
final suffix = pattern.substring(end + 1);
final options = pattern.substring(start + 1, end).split(",");
// build a regex group instead of expanding - simpler
return "$prefix(${options.join("|")})$suffix";
}
/// Check if a path matches a glob pattern.
bool matchesGlob(String filePath, String pattern) {
// handle negation
if (pattern.startsWith("!")) {
return !matchesGlob(filePath, pattern.substring(1));
}
final regex = globToRegex(pattern);
return regex.hasMatch(filePath);
}
/// List files in [dir] matching [pattern].
/// [pattern] is relative to [dir].
Future<List<String>> glob(
String pattern,
String cwd, {
int limit = 1000,
int offset = 0,
List<String> ignorePatterns = const [],
}) async {
String searchDir = cwd;
String searchPattern = pattern;
if (p.isAbsolute(pattern)) {
final (baseDir, relPattern) = extractGlobBaseDirectory(pattern);
if (baseDir.isNotEmpty) {
searchDir = baseDir;
searchPattern = relPattern;
}
}
final dir = Directory(searchDir);
if (!await dir.exists()) return [];
final allFiles = <String>[];
await for (final entity in dir.list(recursive: true, followLinks: false)) {
if (entity is File) {
// get path relative to searchDir
final relPath = p.relative(entity.path, from: searchDir);
allFiles.add(relPath);
}
}
// sort by modification time (older first) - approximate by sorting by name
allFiles.sort();
final regex = globToRegex(searchPattern);
final ignoreRegexes = ignorePatterns.map(globToRegex).toList();
final matched = <String>[];
for (final rel in allFiles) {
if (!regex.hasMatch(rel)) continue;
// check ignore patterns
final ignored = ignoreRegexes.any((r) => r.hasMatch(rel));
if (ignored) continue;
matched.add(p.join(searchDir, rel));
}
final truncated = matched.length > offset + limit;
return matched.skip(offset).take(limit).toList();
}
+28
View File
@@ -0,0 +1,28 @@
// Group items by a key selector
// Ported from objectGroupBy.ts
/// Groups items in an iterable by a key derived from each item.
/// Returns a Map from key to list of items with that key.
Map<K, List<T>> groupBy<T, K>(
Iterable<T> items,
K Function(T item, int index) keySelector,
) {
final result = <K, List<T>>{};
int index = 0;
for (final item in items) {
final key = keySelector(item, index++);
result.putIfAbsent(key, () => []).add(item);
}
return result;
}
/// Simpler overload that doesnt pass the index
Map<K, List<T>> groupByKey<T, K>(
Iterable<T> items,
K Function(T item) keySelector,
) {
return groupBy(items, (item, _) => keySelector(item));
}
+43
View File
@@ -0,0 +1,43 @@
// Non-cryptographic hash utilities
// Ported from hash.ts
import "dart:convert";
/// djb2 hash - fast non-crypto hash, deterministic
/// Returns a 32-bit signed integer (as int in Dart)
int djb2Hash(String str) {
int hash = 0;
for (int i = 0; i < str.length; i++) {
// emulate: hash = ((hash << 5) - hash + charCode) | 0
hash = ((hash << 5) - hash + str.codeUnitAt(i)) & 0xFFFFFFFF;
// sign extend to match JS 32-bit signed int behavior
if (hash > 0x7FFFFFFF) hash -= 0x100000000;
}
return hash;
}
/// Hash content for change detection. Uses SHA-256 in hex.
/// Not crypto safe but good enugh for diff detection.
String hashContent(String content) {
final bytes = utf8.encode(content);
// simple djb2-based hash since dart:crypto isnt in core
// for actual crypto use, caller should import package:crypto
int h = 5381;
for (final b in bytes) {
h = ((h << 5) + h + b) & 0xFFFFFFFF;
}
return h.toRadixString(16);
}
/// Hash two strings without allocating a concatenated temp string.
String hashPair(String a, String b) {
final ha = djb2Hash(a);
final hb = djb2Hash(b);
// combine the two hashes
int combined = ((ha << 5) + ha + hb) & 0xFFFFFFFF;
if (combined > 0x7FFFFFFF) combined -= 0x100000000;
return combined.toRadixString(16);
}
+91
View File
@@ -0,0 +1,91 @@
// JSON helpers - ported from json.ts
// no jsonc-parser available so we just use dart:convert
// the fancy JSONL stuff is here too
import "dart:convert";
/// Safely parse a JSON string, returns null on failure.
/// strips UTF-8 BOM if present (powershell writes those sometimes)
dynamic safeParseJson(String? json, {bool logError = true}) {
if (json == null || json.isEmpty) return null;
// strip BOM
final cleaned = json.startsWith("\uFEFF") ? json.substring(1) : json;
try {
return jsonDecode(cleaned);
} catch (e) {
if (logError) {
// not much we can do here, just swallow it
}
return null;
}
}
/// Parse JSONL text - each line is a separate JSON value.
/// skips blank and malformed lines (same behaviour as old parseJSONLString)
List<T> parseJsonl<T>(String data) {
final cleaned = data.startsWith("\uFEFF") ? data.substring(1) : data;
final results = <T>[];
var start = 0;
final len = cleaned.length;
while (start < len) {
var end = cleaned.indexOf("\n", start);
if (end == -1) end = len;
final line = cleaned.substring(start, end).trim();
start = end + 1;
if (line.isEmpty) continue;
try {
results.add(jsonDecode(line) as T);
} catch (_) {
// skip bad lines
}
}
return results;
}
/// Serialize value to JSON. Basically just jsonEncode with a fallback.
String jsonStringify(dynamic value, {int? indent}) {
if (indent != null) {
final encoder = JsonEncoder.withIndent(" " * indent);
return encoder.convert(value);
}
return jsonEncode(value);
}
/// add an item to a JSON array string. preserves existing content if valid.
/// on parse failure, returns a fresh single-element array.
String addItemToJsonArray(String content, dynamic newItem) {
final cleaned = content.trim().startsWith("\uFEFF")
? content.trim().substring(1)
: content.trim();
if (cleaned.isEmpty) {
return jsonStringify([newItem], indent: 4);
}
try {
final parsed = jsonDecode(cleaned);
if (parsed is List) {
final copy = [...parsed, newItem];
return jsonStringify(copy, indent: 4);
}
// not an array - replace
return jsonStringify([newItem], indent: 4);
} catch (_) {
return jsonStringify([newItem], indent: 4);
}
}
+197
View File
@@ -0,0 +1,197 @@
// Memoization helpers - TTL cache, LRU cache, etc.
// Ported from memoize.ts
import "dart:async";
import "dart:convert";
class _CacheEntry<T> {
final T value;
final DateTime timestamp;
bool refreshing;
_CacheEntry({
required this.value,
required this.timestamp,
this.refreshing = false,
});
}
/// Creates a memoized sync function with TTL-based stale-while-revalidate.
/// On first call: computes and caches. On stale hit: returns old value and
/// refreshes in background. Cache key is JSON encoded args.
class MemoizedWithTTL<R> {
final R Function(List<Object?>) _fn;
final Duration _ttl;
final Map<String, _CacheEntry<R>> _cache = {};
MemoizedWithTTL(this._fn, {Duration ttl = const Duration(minutes: 5)})
: _ttl = ttl;
R call(List<Object?> args) {
final key = jsonEncode(args);
final cached = _cache[key];
final now = DateTime.now();
if (cached == null) {
final value = _fn(args);
_cache[key] = _CacheEntry(value: value, timestamp: now);
return value;
}
if (now.difference(cached.timestamp) > _ttl && !cached.refreshing) {
cached.refreshing = true;
// refresh in background
scheduleMicrotask(() {
try {
final newValue = _fn(args);
if (_cache[key] == cached) {
_cache[key] = _CacheEntry(value: newValue, timestamp: DateTime.now());
}
} catch (e) {
if (_cache[key] == cached) {
_cache.remove(key);
}
}
});
return cached.value;
}
return _cache[key]!.value;
}
void clearCache() => _cache.clear();
}
/// Async TTL memoize - same stale-while-revalidate pattern but for futures
class MemoizedWithTTLAsync<R> {
final Future<R> Function(List<Object?>) _fn;
final Duration _ttl;
final Map<String, _CacheEntry<R>> _cache = {};
final Map<String, Future<R>> _inFlight = {};
MemoizedWithTTLAsync(this._fn, {Duration ttl = const Duration(minutes: 5)})
: _ttl = ttl;
Future<R> call(List<Object?> args) async {
final key = jsonEncode(args);
final cached = _cache[key];
final now = DateTime.now();
if (cached == null) {
final pending = _inFlight[key];
if (pending != null) return pending;
final future = _fn(args);
_inFlight[key] = future;
try {
final result = await future;
if (_inFlight[key] == future) {
_cache[key] = _CacheEntry(value: result, timestamp: now);
}
return result;
} finally {
if (_inFlight[key] == future) {
_inFlight.remove(key);
}
}
}
if (now.difference(cached.timestamp) > _ttl && !cached.refreshing) {
cached.refreshing = true;
final staleEntry = cached;
_fn(args).then((newValue) {
if (_cache[key] == staleEntry) {
_cache[key] = _CacheEntry(value: newValue, timestamp: DateTime.now());
}
}).catchError((e) {
if (_cache[key] == staleEntry) {
_cache.remove(key);
}
});
return cached.value;
}
return _cache[key]!.value;
}
void clearCache() {
_cache.clear();
_inFlight.clear();
}
}
/// Simple LRU cache with max size. Evicts least recently used entries.
class LruCache<K, V> {
final int maxSize;
final _map = <K, V>{};
// track insertion order (LinkedHashMap in Dart preserves insertion order)
LruCache({this.maxSize = 100});
V? get(K key) {
if (!_map.containsKey(key)) return null;
// move to end (LRU eviction means most recent at end)
final v = _map.remove(key) as V;
_map[key] = v;
return v;
}
void set(K key, V value) {
if (_map.containsKey(key)) {
_map.remove(key);
} else if (_map.length >= maxSize) {
// remove oldest (first inserted)
_map.remove(_map.keys.first);
}
_map[key] = value;
}
bool has(K key) => _map.containsKey(key);
V? peek(K key) => _map[key];
bool delete(K key) {
if (_map.containsKey(key)) {
_map.remove(key);
return true;
}
return false;
}
void clear() => _map.clear();
int get size => _map.length;
}
/// Memoized function with LRU eviction
class MemoizedWithLRU<R> {
final R Function(List<Object?>) _fn;
final R Function(List<Object?>) Function()? _cacheFnFactory;
final LruCache<String, R> _cache;
MemoizedWithLRU(
this._fn, {
String Function(List<Object?>)? cacheKey,
int maxCacheSize = 100,
}) : _cacheFnFactory = null,
_cache = LruCache(maxSize: maxCacheSize);
R call(List<Object?> args) {
final key = jsonEncode(args);
final cached = _cache.get(key);
if (cached != null) return cached;
final result = _fn(args);
_cache.set(key, result);
return result;
}
void clearCache() => _cache.clear();
}
+164
View File
@@ -0,0 +1,164 @@
// Model pricing and cost calculation
// Ported from modelCost.ts
/// Per-million-token costs for a model
class ModelCosts {
final double inputTokens;
final double outputTokens;
final double promptCacheWriteTokens;
final double promptCacheReadTokens;
final double webSearchRequests;
const ModelCosts({
required this.inputTokens,
required this.outputTokens,
required this.promptCacheWriteTokens,
required this.promptCacheReadTokens,
this.webSearchRequests = 0.01,
});
}
// standard Sonnet pricing: $3 input / $15 output per Mtok
const costTier3_15 = ModelCosts(
inputTokens: 3,
outputTokens: 15,
promptCacheWriteTokens: 3.75,
promptCacheReadTokens: 0.3,
);
// Opus 4/4.1 pricing: $15/$75
const costTier15_75 = ModelCosts(
inputTokens: 15,
outputTokens: 75,
promptCacheWriteTokens: 18.75,
promptCacheReadTokens: 1.5,
);
// Opus 4.5: $5/$25
const costTier5_25 = ModelCosts(
inputTokens: 5,
outputTokens: 25,
promptCacheWriteTokens: 6.25,
promptCacheReadTokens: 0.5,
);
// fast mode Opus 4.6: $30/$150
const costTier30_150 = ModelCosts(
inputTokens: 30,
outputTokens: 150,
promptCacheWriteTokens: 37.5,
promptCacheReadTokens: 3,
);
// Haiku 3.5: $0.80/$4
const costHaiku35 = ModelCosts(
inputTokens: 0.8,
outputTokens: 4,
promptCacheWriteTokens: 1,
promptCacheReadTokens: 0.08,
);
// Haiku 4.5: $1/$5
const costHaiku45 = ModelCosts(
inputTokens: 1,
outputTokens: 5,
promptCacheWriteTokens: 1.25,
promptCacheReadTokens: 0.1,
);
const _defaultUnknownModelCost = costTier5_25;
// Model name -> cost mapping
final Map<String, ModelCosts> modelCosts = {
"claude-3-5-haiku": costHaiku35,
"claude-haiku-4-5": costHaiku45,
"claude-3-5-sonnet-v2": costTier3_15,
"claude-3-7-sonnet": costTier3_15,
"claude-sonnet-4": costTier3_15,
"claude-sonnet-4-5": costTier3_15,
"claude-sonnet-4-6": costTier3_15,
"claude-opus-4": costTier15_75,
"claude-opus-4-1": costTier15_75,
"claude-opus-4-5": costTier5_25,
"claude-opus-4-6": costTier5_25,
};
/// token usage data (mirrors the API usage object)
class TokenUsage {
final int inputTokens;
final int outputTokens;
final int cacheReadInputTokens;
final int cacheCreationInputTokens;
final int webSearchRequests;
const TokenUsage({
required this.inputTokens,
required this.outputTokens,
this.cacheReadInputTokens = 0,
this.cacheCreationInputTokens = 0,
this.webSearchRequests = 0,
});
}
/// Canonical short model name from a full model id
String getCanonicalModelName(String model) {
// strip version suffixes like "-20241022" dates
final withoutDate = model.replaceAll(RegExp(r"-\d{8}$"), "");
// check direct match first
if (modelCosts.containsKey(withoutDate)) return withoutDate;
// try prefix matching
for (final key in modelCosts.keys) {
if (withoutDate.startsWith(key) || model.startsWith(key)) {
return key;
}
}
return withoutDate;
}
ModelCosts getModelCosts(String model) {
final canonical = getCanonicalModelName(model);
return modelCosts[canonical] ?? _defaultUnknownModelCost;
}
/// Calculate USD cost from token usage
double calculateUSDCost(String model, TokenUsage usage) {
final costs = getModelCosts(model);
return _tokensToUSDCost(costs, usage);
}
double _tokensToUSDCost(ModelCosts costs, TokenUsage usage) {
return (usage.inputTokens / 1000000) * costs.inputTokens +
(usage.outputTokens / 1000000) * costs.outputTokens +
(usage.cacheReadInputTokens / 1000000) * costs.promptCacheReadTokens +
(usage.cacheCreationInputTokens / 1000000) * costs.promptCacheWriteTokens +
usage.webSearchRequests * costs.webSearchRequests;
}
String _formatPrice(double price) {
if (price == price.truncateToDouble()) {
return "\$${price.truncate()}";
}
return "\$${price.toStringAsFixed(2)}";
}
/// Format model costs as pricing string like "$3/$15 per Mtok"
String formatModelPricing(ModelCosts costs) {
return "${_formatPrice(costs.inputTokens)}/${_formatPrice(costs.outputTokens)} per Mtok";
}
/// Get pricing string for a model, returns null if not found
String? getModelPricingString(String model) {
final canonical = getCanonicalModelName(model);
final costs = modelCosts[canonical];
if (costs == null) return null;
return formatModelPricing(costs);
}
+109
View File
@@ -0,0 +1,109 @@
// Path helper utilities
// Ported from path.ts (subset without Windows-specific and fsOperations deps)
import "dart:io";
/// Expands a path that may contain ~ notation to an absolute path.
/// ~ -> home directory
/// ~/foo -> home/foo
/// relative paths resolved against baseDir (defaults to current dir)
String expandPath(String path, [String? baseDir]) {
final actualBaseDir = baseDir ?? Directory.current.path;
if (path.contains("\x00") || actualBaseDir.contains("\x00")) {
throw ArgumentError("Path contains null bytes");
}
final trimmed = path.trim();
if (trimmed.isEmpty) {
return _normalizePath(actualBaseDir);
}
if (trimmed == "~") {
return _homeDir();
}
if (trimmed.startsWith("~/")) {
return _joinPath(_homeDir(), trimmed.substring(2));
}
if (_isAbsolute(trimmed)) {
return _normalizePath(trimmed);
}
// relative path
return _normalizePath(_joinPath(actualBaseDir, trimmed));
}
/// Convert absolute path to relative from cwd. If path escapes cwd returns absolute.
String toRelativePath(String absolutePath) {
final cwd = Directory.current.path;
final rel = _relativePath(cwd, absolutePath);
return rel.startsWith("..") ? absolutePath : rel;
}
/// Check if path contains directory traversal patterns
bool containsPathTraversal(String path) {
return RegExp(r'(?:^|[/\\])\.\.(?:[/\\]|$)').hasMatch(path);
}
/// Normalize path separators - converts backslashes to forward slashes for consistency
String normalizePathForConfigKey(String path) {
return _normalizePath(path).replaceAll("\\", "/");
}
// -- helpers --
String _homeDir() {
return Platform.environment["HOME"] ??
Platform.environment["USERPROFILE"] ??
Directory.current.path;
}
bool _isAbsolute(String path) {
if (path.isEmpty) return false;
if (path.startsWith("/")) return true;
// windows drive letter
if (path.length >= 3 && path[1] == ":" && (path[2] == "/" || path[2] == "\\")) {
return true;
}
return false;
}
String _normalizePath(String path) {
// resolve . and .. segments
final uri = Uri.directory(path).normalizePath();
String result = uri.toFilePath();
// remove trailing slash unless root
if (result.length > 1 && result.endsWith("/")) {
result = result.substring(0, result.length - 1);
}
return result;
}
String _joinPath(String base, String other) {
if (base.endsWith("/") || base.endsWith("\\")) {
return "$base$other";
}
return "$base/$other";
}
String _relativePath(String from, String to) {
final fromParts = from.split("/").where((p) => p.isNotEmpty).toList();
final toParts = to.split("/").where((p) => p.isNotEmpty).toList();
// find common prefix
int common = 0;
while (common < fromParts.length &&
common < toParts.length &&
fromParts[common] == toParts[common]) {
common++;
}
final up = List.filled(fromParts.length - common, "..");
final down = toParts.sublist(common);
final parts = [...up, ...down];
if (parts.isEmpty) return ".";
return parts.join("/");
}
+66
View File
@@ -0,0 +1,66 @@
// Unicode sanitization - protects against hidden character attacks
// Ported from sanitization.ts
//
// Mitigates ASCII Smuggling and Hidden Prompt Injection via invisible
// Unicode characters (Tag chars, format controls, private use areas).
/// Sanitize a string by removing dangerous unicode categories.
/// Applies NFKC normalization and strips control/private-use chars.
String partiallySanitizeUnicode(String prompt) {
String current = prompt;
String previous = "";
int iterations = 0;
const maxIterations = 10;
while (current != previous && iterations < maxIterations) {
previous = current;
// NFKC normalization
// Dart doesnt have built in unicode normalization, so we skip that part
// and rely on the regex stripping below
// zero-width and directional chars
current = current
.replaceAll(RegExp(r'[\u200B-\u200F]'), "")
.replaceAll(RegExp(r'[\u202A-\u202E]'), "")
.replaceAll(RegExp(r'[\u2066-\u2069]'), "")
.replaceAll("\uFEFF", "")
.replaceAll(RegExp(r'[\uE000-\uF8FF]'), ""); // private use area
// unicode tag characters (U+E0000 block) - used in ASCII smuggling attacks
current = current.replaceAll(
RegExp(r'[\u{E0000}-\u{E007F}]', unicode: true), "");
iterations++;
}
if (iterations >= maxIterations) {
throw Exception(
"Unicode sanitization reached max iterations for input: ${prompt.substring(0, prompt.length.clamp(0, 100))}");
}
return current;
}
/// Recursivley sanitize a value - handles strings, lists, and maps.
dynamic recursivelySanitizeUnicode(dynamic value) {
if (value is String) {
return partiallySanitizeUnicode(value);
}
if (value is List) {
return value.map(recursivelySanitizeUnicode).toList();
}
if (value is Map) {
return Map.fromEntries(
value.entries.map((e) => MapEntry(
recursivelySanitizeUnicode(e.key),
recursivelySanitizeUnicode(e.value),
)),
);
}
// numbers, bools, null pass through unchanged
return value;
}
+80
View File
@@ -0,0 +1,80 @@
// Semver comparison utils
// Ported from semver.ts - pure Dart, no external package
// parses a semver string into [major, minor, patch] ints
// ignores pre-release and build metadata for now
List<int> _parse(String v) {
// strip leading 'v' if present
final s = v.startsWith("v") ? v.substring(1) : v;
// strip pre-release/build
final clean = s.split("-").first.split("+").first;
final parts = clean.split(".");
final major = int.tryParse(parts.isNotEmpty ? parts[0] : "0") ?? 0;
final minor = int.tryParse(parts.length > 1 ? parts[1] : "0") ?? 0;
final patch = int.tryParse(parts.length > 2 ? parts[2] : "0") ?? 0;
return [major, minor, patch];
}
/// Compare two semver strings.
/// Returns -1 if a < b, 0 if equal, 1 if a > b
int semverOrder(String a, String b) {
final pa = _parse(a);
final pb = _parse(b);
for (int i = 0; i < 3; i++) {
if (pa[i] < pb[i]) return -1;
if (pa[i] > pb[i]) return 1;
}
return 0;
}
bool semverGt(String a, String b) => semverOrder(a, b) == 1;
bool semverGte(String a, String b) => semverOrder(a, b) >= 0;
bool semverLt(String a, String b) => semverOrder(a, b) == -1;
bool semverLte(String a, String b) => semverOrder(a, b) <= 0;
/// Check if version satisfies a range like ">=1.2.3" or "^1.0.0" or "~1.2.0"
/// Only implements basic comparators: =, >, >=, <, <=, ^, ~, and bare version (=)
bool semverSatisfies(String version, String range) {
final v = _parse(version);
// Handle space-separated ranges (AND logic) like ">=1.0.0 <2.0.0"
final parts = range.trim().split(RegExp(r"\s+"));
if (parts.length > 1) {
return parts.every((r) => semverSatisfies(version, r));
}
final r = range.trim();
if (r.startsWith(">=")) {
return semverGte(version, r.substring(2));
} else if (r.startsWith("<=")) {
return semverLte(version, r.substring(2));
} else if (r.startsWith(">")) {
return semverGt(version, r.substring(1));
} else if (r.startsWith("<")) {
return semverLt(version, r.substring(1));
} else if (r.startsWith("=")) {
return semverOrder(version, r.substring(1)) == 0;
} else if (r.startsWith("^")) {
// caret: compatible with version (same major, >= minor.patch)
final req = _parse(r.substring(1));
if (req[0] == 0) {
// ^0.x.y: same minor, >= patch
if (req[1] == 0) {
return v[0] == 0 && v[1] == 0 && v[2] >= req[2];
}
return v[0] == 0 && v[1] == req[1] && v[2] >= req[2];
}
return v[0] == req[0] && semverGte(version, r.substring(1));
} else if (r.startsWith("~")) {
// tilde: same major.minor, >= patch
final req = _parse(r.substring(1));
return v[0] == req[0] && v[1] == req[1] && v[2] >= req[2];
}
// bare version - treat as exact
return semverOrder(version, r) == 0;
}

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