feat(mac): sessions submenus

This commit is contained in:
Peter Steinberger
2025-12-22 19:29:24 +01:00
parent 19b847b23b
commit a0dd504991
9 changed files with 1034 additions and 74 deletions

View File

@@ -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

View File

@@ -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

View 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])
}
}

View File

@@ -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(

View 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)
}
}

View File

@@ -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,

View File

@@ -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>;

View File

@@ -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",

View File

@@ -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;