diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 8e6a29427..924adec02 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -15,7 +15,6 @@ struct ClawdisApp: App { @State private var statusItem: NSStatusItem? @State private var isMenuPresented = false @State private var isPanelVisible = false - @State private var menuInjector = MenuContextCardInjector.shared @State private var tailscaleService = TailscaleService.shared @MainActor @@ -49,7 +48,6 @@ struct ClawdisApp: App { self.statusItem = item self.applyStatusItemAppearance(paused: self.state.isPaused) self.installStatusItemMouseHandler(for: item) - self.menuInjector.install(into: item) self.updateHoverHUDSuppression() } .onChange(of: self.state.isPaused) { _, paused in diff --git a/apps/macos/Sources/Clawdis/MenuContentView.swift b/apps/macos/Sources/Clawdis/MenuContentView.swift index ef10d181d..240eb3e13 100644 --- a/apps/macos/Sources/Clawdis/MenuContentView.swift +++ b/apps/macos/Sources/Clawdis/MenuContentView.swift @@ -17,7 +17,10 @@ struct MenuContent: View { @State private var availableMics: [AudioInputDevice] = [] @State private var loadingMics = false @State private var sessionMenu: [SessionRow] = [] + @State private var sessionStorePath: String? @State private var browserControlEnabled = true + private let sessionMenuItemWidth: CGFloat = 320 + private let sessionMenuActiveWindowSeconds: TimeInterval = 24 * 60 * 60 var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -28,6 +31,7 @@ struct MenuContent: View { } } .disabled(self.state.connectionMode == .unconfigured) + self.sessionsSection Divider() Toggle(isOn: self.heartbeatsBinding) { VStack(alignment: .leading, spacing: 2) { @@ -125,55 +129,6 @@ struct MenuContent: View { private var debugMenu: some View { if self.state.debugPaneEnabled { Menu("Debug") { - Menu { - ForEach(self.sessionMenu) { row in - Menu(row.key) { - Menu("Thinking") { - ForEach(["low", "medium", "high", "default"], id: \.self) { level in - let normalized = level == "default" ? nil : level - Button { - Task { - try? await DebugActions.updateSession( - key: row.key, - thinking: normalized, - verbose: row.verboseLevel) - await self.reloadSessionMenu() - } - } label: { - let checkmark = row.thinkingLevel == normalized ? "checkmark" : "" - Label(level.capitalized, systemImage: checkmark) - } - } - } - Menu("Verbose") { - ForEach(["on", "off", "default"], id: \.self) { level in - let normalized = level == "default" ? nil : level - Button { - Task { - try? await DebugActions.updateSession( - key: row.key, - thinking: row.thinkingLevel, - verbose: normalized) - await self.reloadSessionMenu() - } - } label: { - let checkmark = row.verboseLevel == normalized ? "checkmark" : "" - Label(level.capitalized, systemImage: checkmark) - } - } - } - Button { - DebugActions.openSessionStoreInCode() - } label: { - Label("Open Session Log", systemImage: "doc.text") - } - } - } - Divider() - } label: { - Label("Sessions", systemImage: "clock.arrow.circlepath") - } - Divider() Button { DebugActions.openConfigFolder() } label: { @@ -239,6 +194,201 @@ struct MenuContent: View { } } + private var sessionsSection: some View { + Group { + Divider() + + if self.sessionMenu.isEmpty { + Text("No active sessions") + .font(.caption) + .foregroundStyle(.secondary) + .disabled(true) + } else { + ForEach(self.sessionMenu) { row in + Menu { + self.sessionSubmenu(for: row) + } label: { + MenuHostedItem( + width: self.sessionMenuItemWidth, + rootView: AnyView(SessionMenuLabelView(row: row))) + } + } + } + + Button { + Task { @MainActor in + guard let key = SessionActions.promptForSessionKey() else { return } + do { + try await SessionActions.createSession(key: key) + await self.reloadSessionMenu() + } catch { + SessionActions.presentError(title: "Create session failed", error: error) + } + } + } label: { + Label("New Session…", systemImage: "plus.circle") + } + } + } + + @ViewBuilder + private func sessionSubmenu(for row: SessionRow) -> some View { + Menu("Syncing") { + ForEach(["on", "off", "default"], id: \.self) { option in + Button { + Task { + do { + let value: SessionSyncingValue? = switch option { + case "on": .bool(true) + case "off": .bool(false) + default: nil + } + try await SessionActions.patchSession(key: row.key, syncing: .some(value)) + await self.reloadSessionMenu() + } catch { + await MainActor.run { + SessionActions.presentError(title: "Update syncing failed", error: error) + } + } + } + } label: { + let normalized: SessionSyncingValue? = switch option { + case "on": .bool(true) + case "off": .bool(false) + default: nil + } + let isSelected: Bool = { + switch normalized { + case .none: + row.syncing == nil + case let .some(value): + switch value { + case .bool(true): + row.syncing?.isOn == true + case .bool(false): + row.syncing?.isOff == true + case let .string(v): + row.syncing?.label == v + } + } + }() + Label(option.capitalized, systemImage: isSelected ? "checkmark" : "") + } + } + } + + Menu("Thinking") { + ForEach(["off", "minimal", "low", "medium", "high", "default"], id: \.self) { level in + let normalized = level == "default" ? nil : level + Button { + Task { + do { + try await SessionActions.patchSession(key: row.key, thinking: .some(normalized)) + await self.reloadSessionMenu() + } catch { + await MainActor.run { + SessionActions.presentError(title: "Update thinking failed", error: error) + } + } + } + } label: { + let checkmark = row.thinkingLevel == normalized ? "checkmark" : "" + Label(level.capitalized, systemImage: checkmark) + } + } + } + + Menu("Verbose") { + ForEach(["on", "off", "default"], id: \.self) { level in + let normalized = level == "default" ? nil : level + Button { + Task { + do { + try await SessionActions.patchSession(key: row.key, verbose: .some(normalized)) + await self.reloadSessionMenu() + } catch { + await MainActor.run { + SessionActions.presentError(title: "Update verbose failed", error: error) + } + } + } + } label: { + let checkmark = row.verboseLevel == normalized ? "checkmark" : "" + Label(level.capitalized, systemImage: checkmark) + } + } + } + + if self.state.debugPaneEnabled, self.state.connectionMode == .local, let sessionId = row.sessionId, !sessionId.isEmpty { + Button { + SessionActions.openSessionLogInCode(sessionId: sessionId, storePath: self.sessionStorePath) + } label: { + Label("Open Session Log", systemImage: "doc.text") + } + } + + Divider() + + Button { + Task { @MainActor in + guard SessionActions.confirmDestructiveAction( + title: "Reset session?", + message: "Starts a new session id for “\(row.key)”.", + action: "Reset") + else { return } + + do { + try await SessionActions.resetSession(key: row.key) + await self.reloadSessionMenu() + } catch { + SessionActions.presentError(title: "Reset failed", error: error) + } + } + } label: { + Label("Reset Session", systemImage: "arrow.counterclockwise") + } + + Button { + Task { @MainActor in + guard SessionActions.confirmDestructiveAction( + title: "Compact session log?", + message: "Keeps the last 400 lines; archives the old file.", + action: "Compact") + else { return } + + do { + try await SessionActions.compactSession(key: row.key, maxLines: 400) + await self.reloadSessionMenu() + } catch { + SessionActions.presentError(title: "Compact failed", error: error) + } + } + } label: { + Label("Compact Session Log", systemImage: "scissors") + } + + if row.key != "main" { + Button(role: .destructive) { + Task { @MainActor in + guard SessionActions.confirmDestructiveAction( + title: "Delete session?", + message: "Deletes the “\(row.key)” entry and archives its transcript.", + action: "Delete") + else { return } + + do { + try await SessionActions.deleteSession(key: row.key) + await self.reloadSessionMenu() + } catch { + SessionActions.presentError(title: "Delete failed", error: error) + } + } + } label: { + Label("Delete Session", systemImage: "trash") + } + } + } + private func open(tab: SettingsTab) { SettingsTabRouter.request(tab) NSApp.activate(ignoringOtherApps: true) @@ -427,7 +577,24 @@ struct MenuContent: View { @MainActor private func reloadSessionMenu() async { - self.sessionMenu = await DebugActions.recentSessions() + do { + let snapshot = try await SessionLoader.loadSnapshot(limit: 32) + self.sessionStorePath = snapshot.storePath + let now = Date() + let active = snapshot.rows.filter { row in + if row.key == "main" { return true } + guard let updatedAt = row.updatedAt else { return false } + return now.timeIntervalSince(updatedAt) <= self.sessionMenuActiveWindowSeconds + } + self.sessionMenu = active.sorted { lhs, rhs in + if lhs.key == "main" { return true } + if rhs.key == "main" { return false } + return (lhs.updatedAt ?? .distantPast) > (rhs.updatedAt ?? .distantPast) + } + } catch { + self.sessionStorePath = nil + self.sessionMenu = [] + } } @MainActor diff --git a/apps/macos/Sources/Clawdis/SessionActions.swift b/apps/macos/Sources/Clawdis/SessionActions.swift new file mode 100644 index 000000000..3f1ebe2f7 --- /dev/null +++ b/apps/macos/Sources/Clawdis/SessionActions.swift @@ -0,0 +1,133 @@ +import AppKit +import Foundation + +enum SessionActions { + static func patchSession( + key: String, + thinking: String?? = nil, + verbose: String?? = nil, + syncing: SessionSyncingValue?? = nil) async throws + { + var params: [String: AnyHashable] = ["key": AnyHashable(key)] + + if let thinking { + params["thinkingLevel"] = thinking.map(AnyHashable.init) ?? AnyHashable(NSNull()) + } + if let verbose { + params["verboseLevel"] = verbose.map(AnyHashable.init) ?? AnyHashable(NSNull()) + } + if let syncing { + let payload: AnyHashable = { + switch syncing { + case .none: + AnyHashable(NSNull()) + case let .some(value): + switch value { + case let .bool(v): AnyHashable(v) + case let .string(v): AnyHashable(v) + } + } + }() + params["syncing"] = payload + } + + _ = try await ControlChannel.shared.request(method: "sessions.patch", params: params) + } + + static func createSession(key: String) async throws { + _ = try await ControlChannel.shared.request( + method: "sessions.patch", + params: ["key": AnyHashable(key)]) + } + + static func resetSession(key: String) async throws { + _ = try await ControlChannel.shared.request( + method: "sessions.reset", + params: ["key": AnyHashable(key)]) + } + + static func deleteSession(key: String) async throws { + _ = try await ControlChannel.shared.request( + method: "sessions.delete", + params: ["key": AnyHashable(key), "deleteTranscript": AnyHashable(true)]) + } + + static func compactSession(key: String, maxLines: Int = 400) async throws { + _ = try await ControlChannel.shared.request( + method: "sessions.compact", + params: ["key": AnyHashable(key), "maxLines": AnyHashable(maxLines)]) + } + + @MainActor + static func confirmDestructiveAction(title: String, message: String, action: String) -> Bool { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = message + alert.addButton(withTitle: action) + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + return alert.runModal() == .alertFirstButtonReturn + } + + @MainActor + static func promptForSessionKey() -> String? { + let alert = NSAlert() + alert.messageText = "New Session" + alert.informativeText = "Create a new session key (e.g. \"main\", \"group:dev\", \"scratch\")." + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 280, height: 24)) + field.placeholderString = "session key" + alert.accessoryView = field + alert.addButton(withTitle: "Create") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .informational + let result = alert.runModal() + guard result == .alertFirstButtonReturn else { return nil } + let trimmed = field.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + @MainActor + static func presentError(title: String, error: Error) { + let alert = NSAlert() + alert.messageText = title + alert.informativeText = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.runModal() + } + + @MainActor + static func openSessionLogInCode(sessionId: String, storePath: String?) { + let candidates: [URL] = { + var urls: [URL] = [] + if let storePath, !storePath.isEmpty { + let dir = URL(fileURLWithPath: storePath).deletingLastPathComponent() + urls.append(dir.appendingPathComponent("\(sessionId).jsonl")) + } + let home = FileManager.default.homeDirectoryForCurrentUser + urls.append(home.appendingPathComponent(".clawdis/sessions/\(sessionId).jsonl")) + urls.append(home.appendingPathComponent(".pi/agent/sessions/\(sessionId).jsonl")) + urls.append(home.appendingPathComponent(".tau/agent/sessions/clawdis/\(sessionId).jsonl")) + return urls + }() + + let existing = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) }) + guard let url = existing else { + let alert = NSAlert() + alert.messageText = "Session log not found" + alert.informativeText = sessionId + alert.runModal() + return + } + + let proc = Process() + proc.launchPath = "/usr/bin/env" + proc.arguments = ["code", url.path] + if (try? proc.run()) != nil { + return + } + + NSWorkspace.shared.activateFileViewerSelecting([url]) + } +} + diff --git a/apps/macos/Sources/Clawdis/SessionData.swift b/apps/macos/Sources/Clawdis/SessionData.swift index ebfbaac7f..bb0390169 100644 --- a/apps/macos/Sources/Clawdis/SessionData.swift +++ b/apps/macos/Sources/Clawdis/SessionData.swift @@ -1,6 +1,65 @@ import Foundation import SwiftUI +enum SessionSyncingValue: Codable, Equatable { + case bool(Bool) + case string(String) + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(Bool.self) { + self = .bool(value) + return + } + if let value = try? container.decode(String.self) { + self = .string(value) + return + } + throw DecodingError.typeMismatch( + SessionSyncingValue.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected Bool or String")) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .bool(value): + try container.encode(value) + case let .string(value): + try container.encode(value) + } + } + + var isOn: Bool { + switch self { + case let .bool(value): + value + case let .string(value): + value.lowercased() == "on" + } + } + + var isOff: Bool { + switch self { + case let .bool(value): + !value + case let .string(value): + value.lowercased() == "off" + } + } + + var label: String { + switch self { + case let .bool(value): + value ? "on" : "off" + case let .string(value): + value + } + } +} + struct GatewaySessionDefaultsRecord: Codable { let model: String? let contextTokens: Int? @@ -14,6 +73,7 @@ struct GatewaySessionEntryRecord: Codable { let abortedLastRun: Bool? let thinkingLevel: String? let verboseLevel: String? + let syncing: SessionSyncingValue? let inputTokens: Int? let outputTokens: Int? let totalTokens: Int? @@ -69,6 +129,7 @@ struct SessionRow: Identifiable { let sessionId: String? let thinkingLevel: String? let verboseLevel: String? + let syncing: SessionSyncingValue? let systemSent: Bool let abortedLastRun: Bool let tokens: SessionTokenStats @@ -80,6 +141,13 @@ struct SessionRow: Identifiable { var flags: [String] = [] if let thinkingLevel { flags.append("think \(thinkingLevel)") } if let verboseLevel { flags.append("verbose \(verboseLevel)") } + if let syncing { + if syncing.isOn { + flags.append("syncing") + } else if !syncing.label.isEmpty { + flags.append("sync \(syncing.label)") + } + } if self.systemSent { flags.append("system sent") } if self.abortedLastRun { flags.append("aborted") } return flags @@ -131,6 +199,7 @@ extension SessionRow { sessionId: "sess-direct-1234", thinkingLevel: "low", verboseLevel: "info", + syncing: .bool(true), systemSent: false, abortedLastRun: false, tokens: SessionTokenStats(input: 320, output: 680, total: 1000, contextTokens: 200_000), @@ -143,6 +212,7 @@ extension SessionRow { sessionId: "sess-group-4321", thinkingLevel: "medium", verboseLevel: nil, + syncing: nil, systemSent: true, abortedLastRun: true, tokens: SessionTokenStats(input: 5000, output: 1200, total: 6200, contextTokens: 200_000), @@ -155,6 +225,7 @@ extension SessionRow { sessionId: nil, thinkingLevel: nil, verboseLevel: nil, + syncing: nil, systemSent: false, abortedLastRun: false, tokens: SessionTokenStats(input: 150, output: 220, total: 370, contextTokens: 200_000), @@ -273,6 +344,7 @@ enum SessionLoader { sessionId: entry.sessionId, thinkingLevel: entry.thinkingLevel, verboseLevel: entry.verboseLevel, + syncing: entry.syncing, systemSent: entry.systemSent ?? false, abortedLastRun: entry.abortedLastRun ?? false, tokens: SessionTokenStats( diff --git a/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift new file mode 100644 index 000000000..698355322 --- /dev/null +++ b/apps/macos/Sources/Clawdis/SessionMenuLabelView.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct SessionMenuLabelView: View { + let row: SessionRow + + var body: some View { + VStack(alignment: .leading, spacing: 5) { + ContextUsageBar( + usedTokens: row.tokens.total, + contextTokens: row.tokens.contextTokens, + width: nil, + height: 3) + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(row.key) + .font(.caption.weight(row.key == "main" ? .semibold : .regular)) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) + + Spacer(minLength: 8) + + Text(row.tokens.contextSummaryShort) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(2) + } + } + .padding(.vertical, 4) + .padding(.horizontal, 6) + } +} + diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 4a0884c23..c3162d432 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -73,8 +73,14 @@ import { SendParamsSchema, type SessionsListParams, SessionsListParamsSchema, + type SessionsCompactParams, + SessionsCompactParamsSchema, + type SessionsDeleteParams, + SessionsDeleteParamsSchema, type SessionsPatchParams, SessionsPatchParamsSchema, + type SessionsResetParams, + SessionsResetParamsSchema, type ShutdownEvent, ShutdownEventSchema, type SkillsInstallParams, @@ -143,6 +149,15 @@ export const validateSessionsListParams = ajv.compile( export const validateSessionsPatchParams = ajv.compile( SessionsPatchParamsSchema, ); +export const validateSessionsResetParams = ajv.compile( + SessionsResetParamsSchema, +); +export const validateSessionsDeleteParams = ajv.compile( + SessionsDeleteParamsSchema, +); +export const validateSessionsCompactParams = ajv.compile( + SessionsCompactParamsSchema, +); export const validateConfigGetParams = ajv.compile( ConfigGetParamsSchema, ); @@ -226,6 +241,9 @@ export { NodeInvokeParamsSchema, SessionsListParamsSchema, SessionsPatchParamsSchema, + SessionsResetParamsSchema, + SessionsDeleteParamsSchema, + SessionsCompactParamsSchema, ConfigGetParamsSchema, ConfigSetParamsSchema, ProvidersStatusParamsSchema, @@ -286,6 +304,9 @@ export type { NodeInvokeParams, SessionsListParams, SessionsPatchParams, + SessionsResetParams, + SessionsDeleteParams, + SessionsCompactParams, CronJob, CronListParams, CronStatusParams, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index b8147165c..1e8530207 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -291,6 +291,30 @@ export const SessionsPatchParamsSchema = Type.Object( key: NonEmptyString, thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])), + syncing: Type.Optional( + Type.Union([Type.Boolean(), NonEmptyString, Type.Null()]), + ), + }, + { additionalProperties: false }, +); + +export const SessionsResetParamsSchema = Type.Object( + { key: NonEmptyString }, + { additionalProperties: false }, +); + +export const SessionsDeleteParamsSchema = Type.Object( + { + key: NonEmptyString, + deleteTranscript: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +export const SessionsCompactParamsSchema = Type.Object( + { + key: NonEmptyString, + maxLines: Type.Optional(Type.Integer({ minimum: 1 })), }, { additionalProperties: false }, ); @@ -629,6 +653,9 @@ export const ProtocolSchemas: Record = { NodeInvokeParams: NodeInvokeParamsSchema, SessionsListParams: SessionsListParamsSchema, SessionsPatchParams: SessionsPatchParamsSchema, + SessionsResetParams: SessionsResetParamsSchema, + SessionsDeleteParams: SessionsDeleteParamsSchema, + SessionsCompactParams: SessionsCompactParamsSchema, ConfigGetParams: ConfigGetParamsSchema, ConfigSetParams: ConfigSetParamsSchema, ProvidersStatusParams: ProvidersStatusParamsSchema, @@ -681,6 +708,9 @@ export type NodeDescribeParams = Static; export type NodeInvokeParams = Static; export type SessionsListParams = Static; export type SessionsPatchParams = Static; +export type SessionsResetParams = Static; +export type SessionsDeleteParams = Static; +export type SessionsCompactParams = Static; export type ConfigGetParams = Static; export type ConfigSetParams = Static; export type ProvidersStatusParams = Static; diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 07ea8a6b7..05f92abdd 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -3388,6 +3388,19 @@ describe("gateway server", () => { const now = Date.now(); testSessionStorePath = storePath; + await fs.writeFile( + path.join(dir, "sess-main.jsonl"), + Array.from({ length: 10 }) + .map((_, idx) => JSON.stringify({ role: "user", content: `line ${idx}` })) + .join("\n") + "\n", + "utf-8", + ); + await fs.writeFile( + path.join(dir, "sess-group.jsonl"), + JSON.stringify({ role: "user", content: "group line 0" }) + "\n", + "utf-8", + ); + await fs.writeFile( storePath, JSON.stringify( @@ -3421,7 +3434,15 @@ describe("gateway server", () => { expect( (hello as unknown as { features?: { methods?: string[] } }).features ?.methods, - ).toEqual(expect.arrayContaining(["sessions.list", "sessions.patch"])); + ).toEqual( + expect.arrayContaining([ + "sessions.list", + "sessions.patch", + "sessions.reset", + "sessions.delete", + "sessions.compact", + ]), + ); const list1 = await rpcReq<{ path: string; @@ -3483,6 +3504,63 @@ describe("gateway server", () => { expect(main2?.thinkingLevel).toBe("medium"); expect(main2?.verboseLevel).toBeUndefined(); + const syncPatched = await rpcReq<{ ok: true; key: string }>( + ws, + "sessions.patch", + { key: "main", syncing: true }, + ); + expect(syncPatched.ok).toBe(true); + + const list3 = await rpcReq<{ + sessions: Array<{ key: string; syncing?: boolean | string }>; + }>(ws, "sessions.list", {}); + expect(list3.ok).toBe(true); + const main3 = list3.payload?.sessions.find((s) => s.key === "main"); + expect(main3?.syncing).toBe(true); + + const compacted = await rpcReq<{ ok: true; compacted: boolean }>( + ws, + "sessions.compact", + { key: "main", maxLines: 3 }, + ); + expect(compacted.ok).toBe(true); + expect(compacted.payload?.compacted).toBe(true); + const compactedLines = ( + await fs.readFile(path.join(dir, "sess-main.jsonl"), "utf-8") + ) + .split(/\r?\n/) + .filter((l) => l.trim().length > 0); + expect(compactedLines).toHaveLength(3); + const filesAfterCompact = await fs.readdir(dir); + expect(filesAfterCompact.some((f) => f.startsWith("sess-main.jsonl.bak."))) + .toBe(true); + + const deleted = await rpcReq<{ ok: true; deleted: boolean }>( + ws, + "sessions.delete", + { key: "group:dev" }, + ); + expect(deleted.ok).toBe(true); + expect(deleted.payload?.deleted).toBe(true); + const listAfterDelete = await rpcReq<{ + sessions: Array<{ key: string }>; + }>(ws, "sessions.list", {}); + expect(listAfterDelete.ok).toBe(true); + expect(listAfterDelete.payload?.sessions.some((s) => s.key === "group:dev")) + .toBe(false); + const filesAfterDelete = await fs.readdir(dir); + expect(filesAfterDelete.some((f) => f.startsWith("sess-group.jsonl.deleted."))) + .toBe(true); + + const reset = await rpcReq<{ ok: true; key: string; entry: { sessionId: string } }>( + ws, + "sessions.reset", + { key: "main" }, + ); + expect(reset.ok).toBe(true); + expect(reset.payload?.key).toBe("main"); + expect(reset.payload?.entry.sessionId).not.toBe("sess-main"); + const badThinking = await rpcReq(ws, "sessions.patch", { key: "main", thinkingLevel: "banana", diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 94638fb63..99bdb4d62 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -271,7 +271,10 @@ import { PROTOCOL_VERSION, type RequestFrame, type SessionsListParams, + type SessionsCompactParams, + type SessionsDeleteParams, type SessionsPatchParams, + type SessionsResetParams, type Snapshot, validateAgentParams, validateChatAbortParams, @@ -300,7 +303,10 @@ import { validateRequestFrame, validateSendParams, validateSessionsListParams, + validateSessionsCompactParams, + validateSessionsDeleteParams, validateSessionsPatchParams, + validateSessionsResetParams, validateSkillsInstallParams, validateSkillsStatusParams, validateSkillsUpdateParams, @@ -389,6 +395,9 @@ const METHODS = [ "voicewake.set", "sessions.list", "sessions.patch", + "sessions.reset", + "sessions.delete", + "sessions.compact", "last-heartbeat", "set-heartbeats", "wake", @@ -697,27 +706,7 @@ function readSessionMessages( sessionId: string, storePath: string | undefined, ): unknown[] { - const candidates: string[] = []; - if (storePath) { - const dir = path.dirname(storePath); - candidates.push(path.join(dir, `${sessionId}.jsonl`)); - } - candidates.push( - path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`), - ); - candidates.push( - path.join(os.homedir(), ".pi", "agent", "sessions", `${sessionId}.jsonl`), - ); - candidates.push( - path.join( - os.homedir(), - ".tau", - "agent", - "sessions", - "clawdis", - `${sessionId}.jsonl`, - ), - ); + const candidates = resolveSessionTranscriptCandidates(sessionId, storePath); const filePath = candidates.find((p) => fs.existsSync(p)); if (!filePath) return []; @@ -741,6 +730,41 @@ function readSessionMessages( return messages; } +function resolveSessionTranscriptCandidates( + sessionId: string, + storePath: string | undefined, +): string[] { + const candidates: string[] = []; + if (storePath) { + const dir = path.dirname(storePath); + candidates.push(path.join(dir, `${sessionId}.jsonl`)); + } + candidates.push( + path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`), + ); + candidates.push( + path.join(os.homedir(), ".pi", "agent", "sessions", `${sessionId}.jsonl`), + ); + candidates.push( + path.join( + os.homedir(), + ".tau", + "agent", + "sessions", + "clawdis", + `${sessionId}.jsonl`, + ), + ); + return candidates; +} + +function archiveFileOnDisk(filePath: string, reason: string): string { + const ts = new Date().toISOString().replaceAll(":", "-"); + const archived = `${filePath}.${reason}.${ts}`; + fs.renameSync(filePath, archived); + return archived; +} + function jsonUtf8Bytes(value: unknown): number { try { return Buffer.byteLength(JSON.stringify(value), "utf8"); @@ -1991,6 +2015,206 @@ export async function startGatewayServer( }; return { ok: true, payloadJSON: JSON.stringify(payload) }; } + case "sessions.reset": { + const params = parseParams(); + if (!validateSessionsResetParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`, + }, + }; + } + + const p = params as SessionsResetParams; + const key = String(p.key ?? "").trim(); + if (!key) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "key required", + }, + }; + } + + const { storePath, store, entry } = loadSessionEntry(key); + const now = Date.now(); + const next: SessionEntry = { + sessionId: randomUUID(), + updatedAt: now, + systemSent: false, + abortedLastRun: false, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + syncing: entry?.syncing, + model: entry?.model, + contextTokens: entry?.contextTokens, + lastChannel: entry?.lastChannel, + lastTo: entry?.lastTo, + skillsSnapshot: entry?.skillsSnapshot, + }; + store[key] = next; + await saveSessionStore(storePath, store); + return { + ok: true, + payloadJSON: JSON.stringify({ ok: true, key, entry: next }), + }; + } + case "sessions.delete": { + const params = parseParams(); + if (!validateSessionsDeleteParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`, + }, + }; + } + + const p = params as SessionsDeleteParams; + const key = String(p.key ?? "").trim(); + if (!key) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "key required", + }, + }; + } + + const deleteTranscript = + typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; + + const { storePath, store, entry } = loadSessionEntry(key); + const sessionId = entry?.sessionId; + const existed = Boolean(store[key]); + if (existed) delete store[key]; + await saveSessionStore(storePath, store); + + const archived: string[] = []; + if (deleteTranscript && sessionId) { + for (const candidate of resolveSessionTranscriptCandidates( + sessionId, + storePath, + )) { + if (!fs.existsSync(candidate)) continue; + try { + archived.push(archiveFileOnDisk(candidate, "deleted")); + } catch { + // Best-effort; deleting the store entry is the main operation. + } + } + } + + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key, + deleted: existed, + archived, + }), + }; + } + case "sessions.compact": { + const params = parseParams(); + if (!validateSessionsCompactParams(params)) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`, + }, + }; + } + + const p = params as SessionsCompactParams; + const key = String(p.key ?? "").trim(); + if (!key) { + return { + ok: false, + error: { + code: ErrorCodes.INVALID_REQUEST, + message: "key required", + }, + }; + } + + const maxLines = + typeof p.maxLines === "number" && Number.isFinite(p.maxLines) + ? Math.max(1, Math.floor(p.maxLines)) + : 400; + + const { storePath, store, entry } = loadSessionEntry(key); + const sessionId = entry?.sessionId; + if (!sessionId) { + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key, + compacted: false, + reason: "no sessionId", + }), + }; + } + + const filePath = resolveSessionTranscriptCandidates(sessionId, storePath) + .find((candidate) => fs.existsSync(candidate)); + if (!filePath) { + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key, + compacted: false, + reason: "no transcript", + }), + }; + } + + const raw = fs.readFileSync(filePath, "utf-8"); + const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); + if (lines.length <= maxLines) { + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key, + compacted: false, + kept: lines.length, + }), + }; + } + + const archived = archiveFileOnDisk(filePath, "bak"); + const keptLines = lines.slice(-maxLines); + fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8"); + + // Token counts no longer match; clear so status + UI reflect reality after the next turn. + if (store[key]) { + delete store[key].inputTokens; + delete store[key].outputTokens; + delete store[key].totalTokens; + store[key].updatedAt = Date.now(); + await saveSessionStore(storePath, store); + } + + return { + ok: true, + payloadJSON: JSON.stringify({ + ok: true, + key, + compacted: true, + archived, + kept: keptLines.length, + }), + }; + } case "chat.history": { const params = parseParams(); if (!validateChatHistoryParams(params)) { @@ -4056,6 +4280,15 @@ export async function startGatewayServer( } } + if ("syncing" in p) { + const raw = p.syncing; + if (raw === null) { + delete next.syncing; + } else if (raw !== undefined) { + next.syncing = raw as boolean | string; + } + } + store[key] = next; await saveSessionStore(storePath, store); const result: SessionsPatchResult = { @@ -4067,6 +4300,199 @@ export async function startGatewayServer( respond(true, result, undefined); break; } + case "sessions.reset": { + const params = (req.params ?? {}) as Record; + if (!validateSessionsResetParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`, + ), + ); + break; + } + const p = params as SessionsResetParams; + const key = String(p.key ?? "").trim(); + if (!key) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "key required"), + ); + break; + } + + const { storePath, store, entry } = loadSessionEntry(key); + const now = Date.now(); + const next: SessionEntry = { + sessionId: randomUUID(), + updatedAt: now, + systemSent: false, + abortedLastRun: false, + thinkingLevel: entry?.thinkingLevel, + verboseLevel: entry?.verboseLevel, + syncing: entry?.syncing, + model: entry?.model, + contextTokens: entry?.contextTokens, + lastChannel: entry?.lastChannel, + lastTo: entry?.lastTo, + skillsSnapshot: entry?.skillsSnapshot, + }; + store[key] = next; + await saveSessionStore(storePath, store); + respond(true, { ok: true, key, entry: next }, undefined); + break; + } + case "sessions.delete": { + const params = (req.params ?? {}) as Record; + if (!validateSessionsDeleteParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`, + ), + ); + break; + } + const p = params as SessionsDeleteParams; + const key = String(p.key ?? "").trim(); + if (!key) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "key required"), + ); + break; + } + + const deleteTranscript = + typeof p.deleteTranscript === "boolean" + ? p.deleteTranscript + : true; + + const { storePath, store, entry } = loadSessionEntry(key); + const sessionId = entry?.sessionId; + const existed = Boolean(store[key]); + if (existed) delete store[key]; + await saveSessionStore(storePath, store); + + const archived: string[] = []; + if (deleteTranscript && sessionId) { + for (const candidate of resolveSessionTranscriptCandidates( + sessionId, + storePath, + )) { + if (!fs.existsSync(candidate)) continue; + try { + archived.push(archiveFileOnDisk(candidate, "deleted")); + } catch { + // Best-effort. + } + } + } + + respond( + true, + { ok: true, key, deleted: existed, archived }, + undefined, + ); + break; + } + case "sessions.compact": { + const params = (req.params ?? {}) as Record; + if (!validateSessionsCompactParams(params)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`, + ), + ); + break; + } + const p = params as SessionsCompactParams; + const key = String(p.key ?? "").trim(); + if (!key) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "key required"), + ); + break; + } + + const maxLines = + typeof p.maxLines === "number" && Number.isFinite(p.maxLines) + ? Math.max(1, Math.floor(p.maxLines)) + : 400; + + const { storePath, store, entry } = loadSessionEntry(key); + const sessionId = entry?.sessionId; + if (!sessionId) { + respond( + true, + { ok: true, key, compacted: false, reason: "no sessionId" }, + undefined, + ); + break; + } + + const filePath = resolveSessionTranscriptCandidates( + sessionId, + storePath, + ).find((candidate) => fs.existsSync(candidate)); + if (!filePath) { + respond( + true, + { ok: true, key, compacted: false, reason: "no transcript" }, + undefined, + ); + break; + } + + const raw = fs.readFileSync(filePath, "utf-8"); + const lines = raw + .split(/\r?\n/) + .filter((l) => l.trim().length > 0); + if (lines.length <= maxLines) { + respond( + true, + { ok: true, key, compacted: false, kept: lines.length }, + undefined, + ); + break; + } + + const archived = archiveFileOnDisk(filePath, "bak"); + const keptLines = lines.slice(-maxLines); + fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8"); + + if (store[key]) { + delete store[key].inputTokens; + delete store[key].outputTokens; + delete store[key].totalTokens; + store[key].updatedAt = Date.now(); + await saveSessionStore(storePath, store); + } + + respond( + true, + { + ok: true, + key, + compacted: true, + archived, + kept: keptLines.length, + }, + undefined, + ); + break; + } case "last-heartbeat": { respond(true, getLastHeartbeatEvent(), undefined); break;