Add initial project files and configurations for clawd_code
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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_\-]"), "_");
|
||||
}
|
||||
Reference in New Issue
Block a user