feat(mac): sessions submenus
This commit is contained in:
@@ -15,7 +15,6 @@ struct ClawdisApp: App {
|
|||||||
@State private var statusItem: NSStatusItem?
|
@State private var statusItem: NSStatusItem?
|
||||||
@State private var isMenuPresented = false
|
@State private var isMenuPresented = false
|
||||||
@State private var isPanelVisible = false
|
@State private var isPanelVisible = false
|
||||||
@State private var menuInjector = MenuContextCardInjector.shared
|
|
||||||
@State private var tailscaleService = TailscaleService.shared
|
@State private var tailscaleService = TailscaleService.shared
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -49,7 +48,6 @@ struct ClawdisApp: App {
|
|||||||
self.statusItem = item
|
self.statusItem = item
|
||||||
self.applyStatusItemAppearance(paused: self.state.isPaused)
|
self.applyStatusItemAppearance(paused: self.state.isPaused)
|
||||||
self.installStatusItemMouseHandler(for: item)
|
self.installStatusItemMouseHandler(for: item)
|
||||||
self.menuInjector.install(into: item)
|
|
||||||
self.updateHoverHUDSuppression()
|
self.updateHoverHUDSuppression()
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.isPaused) { _, paused in
|
.onChange(of: self.state.isPaused) { _, paused in
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ struct MenuContent: View {
|
|||||||
@State private var availableMics: [AudioInputDevice] = []
|
@State private var availableMics: [AudioInputDevice] = []
|
||||||
@State private var loadingMics = false
|
@State private var loadingMics = false
|
||||||
@State private var sessionMenu: [SessionRow] = []
|
@State private var sessionMenu: [SessionRow] = []
|
||||||
|
@State private var sessionStorePath: String?
|
||||||
@State private var browserControlEnabled = true
|
@State private var browserControlEnabled = true
|
||||||
|
private let sessionMenuItemWidth: CGFloat = 320
|
||||||
|
private let sessionMenuActiveWindowSeconds: TimeInterval = 24 * 60 * 60
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -28,6 +31,7 @@ struct MenuContent: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.disabled(self.state.connectionMode == .unconfigured)
|
.disabled(self.state.connectionMode == .unconfigured)
|
||||||
|
self.sessionsSection
|
||||||
Divider()
|
Divider()
|
||||||
Toggle(isOn: self.heartbeatsBinding) {
|
Toggle(isOn: self.heartbeatsBinding) {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -125,55 +129,6 @@ struct MenuContent: View {
|
|||||||
private var debugMenu: some View {
|
private var debugMenu: some View {
|
||||||
if self.state.debugPaneEnabled {
|
if self.state.debugPaneEnabled {
|
||||||
Menu("Debug") {
|
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 {
|
Button {
|
||||||
DebugActions.openConfigFolder()
|
DebugActions.openConfigFolder()
|
||||||
} label: {
|
} 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) {
|
private func open(tab: SettingsTab) {
|
||||||
SettingsTabRouter.request(tab)
|
SettingsTabRouter.request(tab)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
@@ -427,7 +577,24 @@ struct MenuContent: View {
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func reloadSessionMenu() async {
|
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
|
@MainActor
|
||||||
|
|||||||
133
apps/macos/Sources/Clawdis/SessionActions.swift
Normal file
133
apps/macos/Sources/Clawdis/SessionActions.swift
Normal file
@@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,65 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
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 {
|
struct GatewaySessionDefaultsRecord: Codable {
|
||||||
let model: String?
|
let model: String?
|
||||||
let contextTokens: Int?
|
let contextTokens: Int?
|
||||||
@@ -14,6 +73,7 @@ struct GatewaySessionEntryRecord: Codable {
|
|||||||
let abortedLastRun: Bool?
|
let abortedLastRun: Bool?
|
||||||
let thinkingLevel: String?
|
let thinkingLevel: String?
|
||||||
let verboseLevel: String?
|
let verboseLevel: String?
|
||||||
|
let syncing: SessionSyncingValue?
|
||||||
let inputTokens: Int?
|
let inputTokens: Int?
|
||||||
let outputTokens: Int?
|
let outputTokens: Int?
|
||||||
let totalTokens: Int?
|
let totalTokens: Int?
|
||||||
@@ -69,6 +129,7 @@ struct SessionRow: Identifiable {
|
|||||||
let sessionId: String?
|
let sessionId: String?
|
||||||
let thinkingLevel: String?
|
let thinkingLevel: String?
|
||||||
let verboseLevel: String?
|
let verboseLevel: String?
|
||||||
|
let syncing: SessionSyncingValue?
|
||||||
let systemSent: Bool
|
let systemSent: Bool
|
||||||
let abortedLastRun: Bool
|
let abortedLastRun: Bool
|
||||||
let tokens: SessionTokenStats
|
let tokens: SessionTokenStats
|
||||||
@@ -80,6 +141,13 @@ struct SessionRow: Identifiable {
|
|||||||
var flags: [String] = []
|
var flags: [String] = []
|
||||||
if let thinkingLevel { flags.append("think \(thinkingLevel)") }
|
if let thinkingLevel { flags.append("think \(thinkingLevel)") }
|
||||||
if let verboseLevel { flags.append("verbose \(verboseLevel)") }
|
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.systemSent { flags.append("system sent") }
|
||||||
if self.abortedLastRun { flags.append("aborted") }
|
if self.abortedLastRun { flags.append("aborted") }
|
||||||
return flags
|
return flags
|
||||||
@@ -131,6 +199,7 @@ extension SessionRow {
|
|||||||
sessionId: "sess-direct-1234",
|
sessionId: "sess-direct-1234",
|
||||||
thinkingLevel: "low",
|
thinkingLevel: "low",
|
||||||
verboseLevel: "info",
|
verboseLevel: "info",
|
||||||
|
syncing: .bool(true),
|
||||||
systemSent: false,
|
systemSent: false,
|
||||||
abortedLastRun: false,
|
abortedLastRun: false,
|
||||||
tokens: SessionTokenStats(input: 320, output: 680, total: 1000, contextTokens: 200_000),
|
tokens: SessionTokenStats(input: 320, output: 680, total: 1000, contextTokens: 200_000),
|
||||||
@@ -143,6 +212,7 @@ extension SessionRow {
|
|||||||
sessionId: "sess-group-4321",
|
sessionId: "sess-group-4321",
|
||||||
thinkingLevel: "medium",
|
thinkingLevel: "medium",
|
||||||
verboseLevel: nil,
|
verboseLevel: nil,
|
||||||
|
syncing: nil,
|
||||||
systemSent: true,
|
systemSent: true,
|
||||||
abortedLastRun: true,
|
abortedLastRun: true,
|
||||||
tokens: SessionTokenStats(input: 5000, output: 1200, total: 6200, contextTokens: 200_000),
|
tokens: SessionTokenStats(input: 5000, output: 1200, total: 6200, contextTokens: 200_000),
|
||||||
@@ -155,6 +225,7 @@ extension SessionRow {
|
|||||||
sessionId: nil,
|
sessionId: nil,
|
||||||
thinkingLevel: nil,
|
thinkingLevel: nil,
|
||||||
verboseLevel: nil,
|
verboseLevel: nil,
|
||||||
|
syncing: nil,
|
||||||
systemSent: false,
|
systemSent: false,
|
||||||
abortedLastRun: false,
|
abortedLastRun: false,
|
||||||
tokens: SessionTokenStats(input: 150, output: 220, total: 370, contextTokens: 200_000),
|
tokens: SessionTokenStats(input: 150, output: 220, total: 370, contextTokens: 200_000),
|
||||||
@@ -273,6 +344,7 @@ enum SessionLoader {
|
|||||||
sessionId: entry.sessionId,
|
sessionId: entry.sessionId,
|
||||||
thinkingLevel: entry.thinkingLevel,
|
thinkingLevel: entry.thinkingLevel,
|
||||||
verboseLevel: entry.verboseLevel,
|
verboseLevel: entry.verboseLevel,
|
||||||
|
syncing: entry.syncing,
|
||||||
systemSent: entry.systemSent ?? false,
|
systemSent: entry.systemSent ?? false,
|
||||||
abortedLastRun: entry.abortedLastRun ?? false,
|
abortedLastRun: entry.abortedLastRun ?? false,
|
||||||
tokens: SessionTokenStats(
|
tokens: SessionTokenStats(
|
||||||
|
|||||||
35
apps/macos/Sources/Clawdis/SessionMenuLabelView.swift
Normal file
35
apps/macos/Sources/Clawdis/SessionMenuLabelView.swift
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -73,8 +73,14 @@ import {
|
|||||||
SendParamsSchema,
|
SendParamsSchema,
|
||||||
type SessionsListParams,
|
type SessionsListParams,
|
||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
|
type SessionsCompactParams,
|
||||||
|
SessionsCompactParamsSchema,
|
||||||
|
type SessionsDeleteParams,
|
||||||
|
SessionsDeleteParamsSchema,
|
||||||
type SessionsPatchParams,
|
type SessionsPatchParams,
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
|
type SessionsResetParams,
|
||||||
|
SessionsResetParamsSchema,
|
||||||
type ShutdownEvent,
|
type ShutdownEvent,
|
||||||
ShutdownEventSchema,
|
ShutdownEventSchema,
|
||||||
type SkillsInstallParams,
|
type SkillsInstallParams,
|
||||||
@@ -143,6 +149,15 @@ export const validateSessionsListParams = ajv.compile<SessionsListParams>(
|
|||||||
export const validateSessionsPatchParams = ajv.compile<SessionsPatchParams>(
|
export const validateSessionsPatchParams = ajv.compile<SessionsPatchParams>(
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
);
|
);
|
||||||
|
export const validateSessionsResetParams = ajv.compile<SessionsResetParams>(
|
||||||
|
SessionsResetParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateSessionsDeleteParams = ajv.compile<SessionsDeleteParams>(
|
||||||
|
SessionsDeleteParamsSchema,
|
||||||
|
);
|
||||||
|
export const validateSessionsCompactParams = ajv.compile<SessionsCompactParams>(
|
||||||
|
SessionsCompactParamsSchema,
|
||||||
|
);
|
||||||
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
|
export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
|
||||||
ConfigGetParamsSchema,
|
ConfigGetParamsSchema,
|
||||||
);
|
);
|
||||||
@@ -226,6 +241,9 @@ export {
|
|||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
SessionsListParamsSchema,
|
SessionsListParamsSchema,
|
||||||
SessionsPatchParamsSchema,
|
SessionsPatchParamsSchema,
|
||||||
|
SessionsResetParamsSchema,
|
||||||
|
SessionsDeleteParamsSchema,
|
||||||
|
SessionsCompactParamsSchema,
|
||||||
ConfigGetParamsSchema,
|
ConfigGetParamsSchema,
|
||||||
ConfigSetParamsSchema,
|
ConfigSetParamsSchema,
|
||||||
ProvidersStatusParamsSchema,
|
ProvidersStatusParamsSchema,
|
||||||
@@ -286,6 +304,9 @@ export type {
|
|||||||
NodeInvokeParams,
|
NodeInvokeParams,
|
||||||
SessionsListParams,
|
SessionsListParams,
|
||||||
SessionsPatchParams,
|
SessionsPatchParams,
|
||||||
|
SessionsResetParams,
|
||||||
|
SessionsDeleteParams,
|
||||||
|
SessionsCompactParams,
|
||||||
CronJob,
|
CronJob,
|
||||||
CronListParams,
|
CronListParams,
|
||||||
CronStatusParams,
|
CronStatusParams,
|
||||||
|
|||||||
@@ -291,6 +291,30 @@ export const SessionsPatchParamsSchema = Type.Object(
|
|||||||
key: NonEmptyString,
|
key: NonEmptyString,
|
||||||
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||||
verboseLevel: 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 },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
@@ -629,6 +653,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
NodeInvokeParams: NodeInvokeParamsSchema,
|
NodeInvokeParams: NodeInvokeParamsSchema,
|
||||||
SessionsListParams: SessionsListParamsSchema,
|
SessionsListParams: SessionsListParamsSchema,
|
||||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||||
|
SessionsResetParams: SessionsResetParamsSchema,
|
||||||
|
SessionsDeleteParams: SessionsDeleteParamsSchema,
|
||||||
|
SessionsCompactParams: SessionsCompactParamsSchema,
|
||||||
ConfigGetParams: ConfigGetParamsSchema,
|
ConfigGetParams: ConfigGetParamsSchema,
|
||||||
ConfigSetParams: ConfigSetParamsSchema,
|
ConfigSetParams: ConfigSetParamsSchema,
|
||||||
ProvidersStatusParams: ProvidersStatusParamsSchema,
|
ProvidersStatusParams: ProvidersStatusParamsSchema,
|
||||||
@@ -681,6 +708,9 @@ export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
|
|||||||
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
||||||
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
||||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||||
|
export type SessionsResetParams = Static<typeof SessionsResetParamsSchema>;
|
||||||
|
export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
||||||
|
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
|
||||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||||
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
||||||
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
|
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
|
||||||
|
|||||||
@@ -3388,6 +3388,19 @@ describe("gateway server", () => {
|
|||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
testSessionStorePath = storePath;
|
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(
|
await fs.writeFile(
|
||||||
storePath,
|
storePath,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@@ -3421,7 +3434,15 @@ describe("gateway server", () => {
|
|||||||
expect(
|
expect(
|
||||||
(hello as unknown as { features?: { methods?: string[] } }).features
|
(hello as unknown as { features?: { methods?: string[] } }).features
|
||||||
?.methods,
|
?.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<{
|
const list1 = await rpcReq<{
|
||||||
path: string;
|
path: string;
|
||||||
@@ -3483,6 +3504,63 @@ describe("gateway server", () => {
|
|||||||
expect(main2?.thinkingLevel).toBe("medium");
|
expect(main2?.thinkingLevel).toBe("medium");
|
||||||
expect(main2?.verboseLevel).toBeUndefined();
|
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", {
|
const badThinking = await rpcReq(ws, "sessions.patch", {
|
||||||
key: "main",
|
key: "main",
|
||||||
thinkingLevel: "banana",
|
thinkingLevel: "banana",
|
||||||
|
|||||||
@@ -271,7 +271,10 @@ import {
|
|||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
type RequestFrame,
|
type RequestFrame,
|
||||||
type SessionsListParams,
|
type SessionsListParams,
|
||||||
|
type SessionsCompactParams,
|
||||||
|
type SessionsDeleteParams,
|
||||||
type SessionsPatchParams,
|
type SessionsPatchParams,
|
||||||
|
type SessionsResetParams,
|
||||||
type Snapshot,
|
type Snapshot,
|
||||||
validateAgentParams,
|
validateAgentParams,
|
||||||
validateChatAbortParams,
|
validateChatAbortParams,
|
||||||
@@ -300,7 +303,10 @@ import {
|
|||||||
validateRequestFrame,
|
validateRequestFrame,
|
||||||
validateSendParams,
|
validateSendParams,
|
||||||
validateSessionsListParams,
|
validateSessionsListParams,
|
||||||
|
validateSessionsCompactParams,
|
||||||
|
validateSessionsDeleteParams,
|
||||||
validateSessionsPatchParams,
|
validateSessionsPatchParams,
|
||||||
|
validateSessionsResetParams,
|
||||||
validateSkillsInstallParams,
|
validateSkillsInstallParams,
|
||||||
validateSkillsStatusParams,
|
validateSkillsStatusParams,
|
||||||
validateSkillsUpdateParams,
|
validateSkillsUpdateParams,
|
||||||
@@ -389,6 +395,9 @@ const METHODS = [
|
|||||||
"voicewake.set",
|
"voicewake.set",
|
||||||
"sessions.list",
|
"sessions.list",
|
||||||
"sessions.patch",
|
"sessions.patch",
|
||||||
|
"sessions.reset",
|
||||||
|
"sessions.delete",
|
||||||
|
"sessions.compact",
|
||||||
"last-heartbeat",
|
"last-heartbeat",
|
||||||
"set-heartbeats",
|
"set-heartbeats",
|
||||||
"wake",
|
"wake",
|
||||||
@@ -697,27 +706,7 @@ function readSessionMessages(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
storePath: string | undefined,
|
storePath: string | undefined,
|
||||||
): unknown[] {
|
): unknown[] {
|
||||||
const candidates: string[] = [];
|
const candidates = resolveSessionTranscriptCandidates(sessionId, storePath);
|
||||||
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 filePath = candidates.find((p) => fs.existsSync(p));
|
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||||
if (!filePath) return [];
|
if (!filePath) return [];
|
||||||
@@ -741,6 +730,41 @@ function readSessionMessages(
|
|||||||
return messages;
|
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 {
|
function jsonUtf8Bytes(value: unknown): number {
|
||||||
try {
|
try {
|
||||||
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
||||||
@@ -1991,6 +2015,206 @@ export async function startGatewayServer(
|
|||||||
};
|
};
|
||||||
return { ok: true, payloadJSON: JSON.stringify(payload) };
|
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": {
|
case "chat.history": {
|
||||||
const params = parseParams();
|
const params = parseParams();
|
||||||
if (!validateChatHistoryParams(params)) {
|
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;
|
store[key] = next;
|
||||||
await saveSessionStore(storePath, store);
|
await saveSessionStore(storePath, store);
|
||||||
const result: SessionsPatchResult = {
|
const result: SessionsPatchResult = {
|
||||||
@@ -4067,6 +4300,199 @@ export async function startGatewayServer(
|
|||||||
respond(true, result, undefined);
|
respond(true, result, undefined);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "sessions.reset": {
|
||||||
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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": {
|
case "last-heartbeat": {
|
||||||
respond(true, getLastHeartbeatEvent(), undefined);
|
respond(true, getLastHeartbeatEvent(), undefined);
|
||||||
break;
|
break;
|
||||||
|
|||||||
Reference in New Issue
Block a user