Merge branch 'main' into shadow/discord-transport

This commit is contained in:
Peter Steinberger 2025-12-17 10:26:52 +01:00 committed by GitHub
commit 96428d0f24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 697 additions and 88 deletions

View File

@ -1,5 +1,3 @@
READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
# Repository Guidelines
## Project Structure & Module Organization

View File

@ -262,17 +262,24 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
private func repositionPanel(using anchorProvider: () -> NSRect?) {
guard let panel = self.window else { return }
let anchor = anchorProvider()
let screen = NSScreen.screens.first { screen in
guard let anchor else { return false }
return screen.frame.contains(anchor.origin) || screen.frame.contains(NSPoint(
x: anchor.midX,
y: anchor.midY))
} ?? NSScreen.main
let targetScreen = Self.screen(forAnchor: anchor)
?? Self.screenContainingMouseCursor()
?? panel.screen
?? NSScreen.main
?? NSScreen.screens.first
// Base frame: restored frame (preferred), otherwise default top-right.
var frame = Self.loadRestoredFrame(sessionKey: self.sessionKey) ?? Self.defaultTopRightFrame(
panel: panel,
screen: screen)
let restored = Self.loadRestoredFrame(sessionKey: self.sessionKey)
let restoredIsValid = if let restored, let targetScreen {
Self.isFrameMeaningfullyVisible(restored, on: targetScreen)
} else {
restored != nil
}
var frame = if let restored, restoredIsValid {
restored
} else {
Self.defaultTopRightFrame(panel: panel, screen: targetScreen)
}
// Apply agent placement as partial overrides:
// - If agent provides x/y, override origin.
@ -285,30 +292,66 @@ final class CanvasWindowController: NSWindowController, WKNavigationDelegate, NS
if let h = placement.height { frame.size.height = max(CanvasLayout.minPanelSize.height, CGFloat(h)) }
}
self.setPanelFrame(frame, on: screen)
self.setPanelFrame(frame, on: targetScreen)
}
private static func defaultTopRightFrame(panel: NSWindow, screen: NSScreen?) -> NSRect {
let visible = (screen?.visibleFrame ?? NSScreen.main?.visibleFrame) ?? panel.frame
let w = max(CanvasLayout.minPanelSize.width, panel.frame.width)
let h = max(CanvasLayout.minPanelSize.height, panel.frame.height)
let x = visible.maxX - w - CanvasLayout.defaultPadding
let y = visible.maxY - h - CanvasLayout.defaultPadding
return NSRect(x: x, y: y, width: w, height: h)
return WindowPlacement.topRightFrame(
size: NSSize(width: w, height: h),
padding: CanvasLayout.defaultPadding,
on: screen)
}
private func setPanelFrame(_ frame: NSRect, on screen: NSScreen?) {
guard let panel = self.window else { return }
let s = screen ?? panel.screen ?? NSScreen.main
let constrained: NSRect = if let s {
panel.constrainFrameRect(frame, to: s)
} else {
frame
guard let s = screen ?? panel.screen ?? NSScreen.main ?? NSScreen.screens.first else {
panel.setFrame(frame, display: false)
self.persistFrameIfPanel()
return
}
let constrained = Self.constrainFrame(frame, toVisibleFrame: s.visibleFrame)
panel.setFrame(constrained, display: false)
self.persistFrameIfPanel()
}
private static func screen(forAnchor anchor: NSRect?) -> NSScreen? {
guard let anchor else { return nil }
let center = NSPoint(x: anchor.midX, y: anchor.midY)
return NSScreen.screens.first { screen in
screen.frame.contains(anchor.origin) || screen.frame.contains(center)
}
}
private static func screenContainingMouseCursor() -> NSScreen? {
let point = NSEvent.mouseLocation
return NSScreen.screens.first { $0.frame.contains(point) }
}
private static func isFrameMeaningfullyVisible(_ frame: NSRect, on screen: NSScreen) -> Bool {
frame.intersects(screen.visibleFrame.insetBy(dx: 12, dy: 12))
}
fileprivate static func constrainFrame(_ frame: NSRect, toVisibleFrame bounds: NSRect) -> NSRect {
if bounds == .zero { return frame }
var next = frame
next.size.width = min(max(CanvasLayout.minPanelSize.width, next.size.width), bounds.width)
next.size.height = min(max(CanvasLayout.minPanelSize.height, next.size.height), bounds.height)
let maxX = bounds.maxX - next.size.width
let maxY = bounds.maxY - next.size.height
next.origin.x = maxX >= bounds.minX ? min(max(next.origin.x, bounds.minX), maxX) : bounds.minX
next.origin.y = maxY >= bounds.minY ? min(max(next.origin.y, bounds.minY), maxY) : bounds.minY
next.origin.x = round(next.origin.x)
next.origin.y = round(next.origin.y)
return next
}
// MARK: - WKNavigationDelegate
@MainActor
@ -490,7 +533,7 @@ private final class HoverChromeContainerView: NSView {
frame.size.height = max(CanvasLayout.minPanelSize.height, frame.size.height - dy)
if let screen = window.screen {
frame = window.constrainFrameRect(frame, to: screen)
frame = CanvasWindowController.constrainFrame(frame, toVisibleFrame: screen.visibleFrame)
}
window.setFrame(frame, display: true)
}

View File

@ -0,0 +1,28 @@
import Foundation
extension FileHandle {
/// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure.
///
/// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and
/// `availableData`). They can raise Objective-C exceptions when the handle is closed/invalid, which
/// will abort the process.
func readToEndSafely() -> Data {
do {
return try self.readToEnd() ?? Data()
} catch {
return Data()
}
}
/// Reads up to `count` bytes using the throwing FileHandle API and returns empty `Data` on failure/EOF.
///
/// Important: Use this instead of `availableData` in callbacks like `readabilityHandler` to avoid
/// Objective-C exceptions terminating the process.
func readSafely(upToCount count: Int) -> Data {
do {
return try self.read(upToCount: count) ?? Data()
} catch {
return Data()
}
}
}

View File

@ -202,7 +202,7 @@ enum GatewayEnvironment {
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let data = pipe.fileHandleForReading.readToEndSafely()
let raw = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
return Semver.parse(raw)
} catch {

View File

@ -116,7 +116,8 @@ struct ClawdisApp: App {
@MainActor
private func statusButtonScreenFrame() -> NSRect? {
guard let button = self.statusItem?.button, let window = button.window else { return nil }
return window.convertToScreen(button.frame)
let inWindow = button.convert(button.bounds, to: nil)
return window.convertToScreen(inWindow)
}
private var effectiveIconState: IconState {

View File

@ -257,7 +257,7 @@ actor PortGuardian {
} catch {
return nil
}
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let data = pipe.fileHandleForReading.readToEndSafely()
guard !data.isEmpty else { return nil }
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@ -132,7 +132,7 @@ enum RuntimeLocator {
do {
try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let data = pipe.fileHandleForReading.readToEndSafely()
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
} catch {
return nil

View File

@ -24,8 +24,8 @@ enum ShellExecutor {
let waitTask = Task { () -> Response in
process.waitUntilExit()
let out = stdoutPipe.fileHandleForReading.readDataToEndOfFile()
let err = stderrPipe.fileHandleForReading.readDataToEndOfFile()
let out = stdoutPipe.fileHandleForReading.readToEndSafely()
let err = stderrPipe.fileHandleForReading.readToEndSafely()
let status = process.terminationStatus
let combined = out.isEmpty ? err : out
return Response(ok: status == 0, message: status == 0 ? nil : "exit \(status)", payload: combined)

View File

@ -463,7 +463,7 @@ private enum ToolInstaller {
process.standardOutput = pipe
process.standardError = pipe
process.terminationHandler = { proc in
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? ""
continuation.resume(returning: (proc.terminationStatus, output))
}

View File

@ -191,7 +191,7 @@ enum CLIInstaller {
do {
try proc.run()
proc.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if proc.terminationStatus == 0 {
return output.isEmpty ? "CLI helper linked into \(targetList)" : output

View File

@ -74,9 +74,9 @@ final class WebChatTunnel {
// Consume stderr so ssh cannot block if it logs.
stderrHandle.readabilityHandler = { handle in
let data = handle.availableData
let data = handle.readSafely(upToCount: 64 * 1024)
guard !data.isEmpty else {
// EOF: stop monitoring to avoid spinning on a closed pipe.
// EOF (or read failure): stop monitoring to avoid spinning on a closed pipe.
Self.cleanupStderr(handle)
return
}
@ -223,15 +223,25 @@ final class WebChatTunnel {
private static func drainStderr(_ handle: FileHandle) -> String {
handle.readabilityHandler = nil
let data = handle.readDataToEndOfFile()
try? handle.close()
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
defer { try? handle.close() }
do {
let data = try handle.readToEnd() ?? Data()
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
} catch {
self.logger.debug("Failed to drain ssh stderr: \(error, privacy: .public)")
return ""
}
}
#if SWIFT_PACKAGE
static func _testPortIsFree(_ port: UInt16) -> Bool {
self.portIsFree(port)
}
static func _testDrainStderr(_ handle: FileHandle) -> String {
self.drainStderr(handle)
}
#endif
}

View File

@ -263,7 +263,7 @@ enum BrowserCLI {
return try String(contentsOfFile: jsFile, encoding: .utf8)
}
if options.jsStdin {
let data = FileHandle.standardInput.readDataToEndOfFile()
let data = FileHandle.standardInput.readToEndSafely()
return String(data: data, encoding: .utf8) ?? ""
}
if let js = options.js, !js.isEmpty {

View File

@ -0,0 +1,25 @@
import Foundation
extension FileHandle {
/// Reads until EOF using the throwing FileHandle API and returns empty `Data` on failure.
///
/// Important: Avoid legacy, non-throwing FileHandle read APIs (e.g. `readDataToEndOfFile()` and
/// `availableData`). They can raise Objective-C exceptions when the handle is closed/invalid, which
/// will abort the process.
func readToEndSafely() -> Data {
do {
return try self.readToEnd() ?? Data()
} catch {
return Data()
}
}
/// Reads up to `count` bytes using the throwing FileHandle API and returns empty `Data` on failure/EOF.
func readSafely(upToCount count: Int) -> Data {
do {
return try self.read(upToCount: count) ?? Data()
} catch {
return Data()
}
}
}

View File

@ -22,11 +22,12 @@ import Testing
return nil
}
let data = stderr.fileHandleForReading.readDataToEndOfFile()
let data = stderr.fileHandleForReading.readToEndSafely()
guard let text = String(data: data, encoding: .utf8) else { return nil }
for line in text.split(separator: "\n") {
if line.hasPrefix("TeamIdentifier=") {
let raw = String(line.dropFirst("TeamIdentifier=".count)).trimmingCharacters(in: .whitespacesAndNewlines)
let raw = String(line.dropFirst("TeamIdentifier=".count))
.trimmingCharacters(in: .whitespacesAndNewlines)
return raw == "not set" ? nil : raw
}
}

View File

@ -0,0 +1,155 @@
import Foundation
import Testing
@Suite struct FileHandleLegacyAPIGuardTests {
@Test func sourcesAvoidLegacyNonThrowingFileHandleReadAPIs() throws {
let testFile = URL(fileURLWithPath: #filePath)
let packageRoot = testFile
.deletingLastPathComponent() // ClawdisIPCTests
.deletingLastPathComponent() // Tests
.deletingLastPathComponent() // apps/macos
let sourcesRoot = packageRoot.appendingPathComponent("Sources")
let swiftFiles = try Self.swiftFiles(under: sourcesRoot)
var offenders: [String] = []
for file in swiftFiles {
let raw = try String(contentsOf: file, encoding: .utf8)
let stripped = Self.stripCommentsAndStrings(from: raw)
if stripped.contains("readDataToEndOfFile(") || stripped.contains(".availableData") {
offenders.append(file.path)
}
}
if !offenders.isEmpty {
let message = "Found legacy FileHandle reads in:\n" + offenders.joined(separator: "\n")
throw NSError(
domain: "FileHandleLegacyAPIGuardTests",
code: 1,
userInfo: [NSLocalizedDescriptionKey: message])
}
}
private static func swiftFiles(under root: URL) throws -> [URL] {
let fm = FileManager.default
guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: [.isRegularFileKey]) else {
return []
}
var files: [URL] = []
for case let url as URL in enumerator {
guard url.pathExtension == "swift" else { continue }
files.append(url)
}
return files
}
private static func stripCommentsAndStrings(from source: String) -> String {
enum Mode {
case code
case lineComment
case blockComment(depth: Int)
case string(quoteCount: Int) // 1 = ", 3 = """
}
var mode: Mode = .code
var out = ""
out.reserveCapacity(source.count)
var index = source.startIndex
func peek(_ offset: Int) -> Character? {
guard
let i = source.index(index, offsetBy: offset, limitedBy: source.endIndex),
i < source.endIndex
else { return nil }
return source[i]
}
while index < source.endIndex {
let ch = source[index]
switch mode {
case .code:
if ch == "/", peek(1) == "/" {
out.append(" ")
index = source.index(index, offsetBy: 2)
mode = .lineComment
continue
}
if ch == "/", peek(1) == "*" {
out.append(" ")
index = source.index(index, offsetBy: 2)
mode = .blockComment(depth: 1)
continue
}
if ch == "\"" {
let triple = (peek(1) == "\"") && (peek(2) == "\"")
out.append(triple ? " " : " ")
index = source.index(index, offsetBy: triple ? 3 : 1)
mode = .string(quoteCount: triple ? 3 : 1)
continue
}
out.append(ch)
index = source.index(after: index)
case .lineComment:
if ch == "\n" {
out.append(ch)
index = source.index(after: index)
mode = .code
} else {
out.append(" ")
index = source.index(after: index)
}
case let .blockComment(depth):
if ch == "/", peek(1) == "*" {
out.append(" ")
index = source.index(index, offsetBy: 2)
mode = .blockComment(depth: depth + 1)
continue
}
if ch == "*", peek(1) == "/" {
out.append(" ")
index = source.index(index, offsetBy: 2)
let newDepth = depth - 1
mode = newDepth > 0 ? .blockComment(depth: newDepth) : .code
continue
}
out.append(ch == "\n" ? "\n" : " ")
index = source.index(after: index)
case let .string(quoteCount):
if ch == "\\", quoteCount == 1 {
// Skip escaped character in normal strings.
out.append(" ")
index = source.index(after: index)
if index < source.endIndex {
out.append(" ")
index = source.index(after: index)
}
continue
}
if ch == "\"" {
if quoteCount == 3, peek(1) == "\"", peek(2) == "\"" {
out.append(" ")
index = source.index(index, offsetBy: 3)
mode = .code
continue
}
if quoteCount == 1 {
out.append(" ")
index = source.index(after: index)
mode = .code
continue
}
}
out.append(ch == "\n" ? "\n" : " ")
index = source.index(after: index)
}
}
return out
}
}

View File

@ -0,0 +1,47 @@
import Foundation
import Testing
@testable import Clawdis
@Suite struct FileHandleSafeReadTests {
@Test func readToEndSafelyReturnsEmptyForClosedHandle() {
let pipe = Pipe()
let handle = pipe.fileHandleForReading
try? handle.close()
let data = handle.readToEndSafely()
#expect(data.isEmpty)
}
@Test func readSafelyUpToCountReturnsEmptyForClosedHandle() {
let pipe = Pipe()
let handle = pipe.fileHandleForReading
try? handle.close()
let data = handle.readSafely(upToCount: 16)
#expect(data.isEmpty)
}
@Test func readToEndSafelyReadsPipeContents() {
let pipe = Pipe()
let writeHandle = pipe.fileHandleForWriting
writeHandle.write(Data("hello".utf8))
try? writeHandle.close()
let data = pipe.fileHandleForReading.readToEndSafely()
#expect(String(data: data, encoding: .utf8) == "hello")
}
@Test func readSafelyUpToCountReadsIncrementally() {
let pipe = Pipe()
let writeHandle = pipe.fileHandleForWriting
writeHandle.write(Data("hello world".utf8))
try? writeHandle.close()
let readHandle = pipe.fileHandleForReading
let first = readHandle.readSafely(upToCount: 5)
let second = readHandle.readSafely(upToCount: 32)
#expect(String(data: first, encoding: .utf8) == "hello")
#expect(String(data: second, encoding: .utf8) == " world")
}
}

View File

@ -6,6 +6,15 @@ import Darwin
import Foundation
@Suite struct WebChatTunnelTests {
@Test func drainStderrDoesNotCrashWhenHandleClosed() {
let pipe = Pipe()
let handle = pipe.fileHandleForReading
try? handle.close()
let drained = WebChatTunnel._testDrainStderr(handle)
#expect(drained.isEmpty)
}
@Test func portIsFreeDetectsIPv4Listener() {
var fd = socket(AF_INET, SOCK_STREAM, 0)
#expect(fd >= 0)
@ -57,7 +66,7 @@ import Foundation
free = true
break
}
usleep(10_000) // 10ms
usleep(10000) // 10ms
}
#expect(free == true)
}

View File

@ -130,8 +130,14 @@ struct ClawdisChatComposer: View {
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(ClawdisChatTheme.card))
.overlay(self.editorOverlay)
.frame(maxHeight: 140)
.overlay(alignment: .topLeading) {
self.editorOverlay
}
.overlay(alignment: .bottomTrailing) {
self.sendButton
.padding(8)
}
.frame(minHeight: 44, idealHeight: 44, maxHeight: 96)
}
private var editorOverlay: some View {
@ -140,16 +146,16 @@ struct ClawdisChatComposer: View {
Text("Message Clawd…")
.foregroundStyle(.tertiary)
.padding(.horizontal, 10)
.padding(.vertical, 8)
.padding(.vertical, 6)
}
#if os(macOS)
ChatComposerTextView(text: self.$viewModel.input) {
self.viewModel.send()
}
.frame(minHeight: 44, maxHeight: 120)
.frame(minHeight: 32, idealHeight: 32, maxHeight: 72)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.padding(.vertical, 5)
.padding(.trailing, 44)
#else
TextEditor(text: self.$viewModel.input)
@ -159,28 +165,23 @@ struct ClawdisChatComposer: View {
.padding(.vertical, 8)
.focused(self.$isFocused)
#endif
}
}
VStack {
Spacer()
HStack {
Spacer()
Button {
self.viewModel.send()
} label: {
if self.viewModel.isSending {
ProgressView().controlSize(.small)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!self.viewModel.canSend)
.padding(8)
}
private var sendButton: some View {
Button {
self.viewModel.send()
} label: {
if self.viewModel.isSending {
ProgressView().controlSize(.small)
} else {
Image(systemName: "arrow.up")
.font(.system(size: 13, weight: .semibold))
}
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
.disabled(!self.viewModel.canSend)
}
#if os(macOS)
@ -252,7 +253,7 @@ private struct ChatComposerTextView: NSViewRepresentable {
textView.font = .systemFont(ofSize: 14, weight: .regular)
textView.textContainer?.lineBreakMode = .byWordWrapping
textView.textContainer?.lineFragmentPadding = 0
textView.textContainerInset = NSSize(width: 2, height: 8)
textView.textContainerInset = NSSize(width: 2, height: 6)
textView.focusRingType = .none
textView.minSize = .zero

View File

@ -160,7 +160,8 @@ struct ChatTypingIndicatorBubble: View {
@MainActor
private struct TypingDots: View {
@State private var phase: Double = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var animate = false
var body: some View {
HStack(spacing: 5) {
@ -168,21 +169,20 @@ private struct TypingDots: View {
Circle()
.fill(Color.secondary.opacity(0.55))
.frame(width: 7, height: 7)
.scaleEffect(self.dotScale(idx))
.scaleEffect(self.reduceMotion ? 0.85 : (self.animate ? 1.05 : 0.70))
.opacity(self.reduceMotion ? 0.55 : (self.animate ? 0.95 : 0.30))
.animation(
self.reduceMotion ? nil : .easeInOut(duration: 0.55)
.repeatForever(autoreverses: true)
.delay(Double(idx) * 0.16),
value: self.animate)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {
self.phase = 1
}
guard !self.reduceMotion else { return }
self.animate = true
}
}
private func dotScale(_ idx: Int) -> CGFloat {
let base = 0.85 + (self.phase * 0.35)
let offset = Double(idx) * 0.15
return CGFloat(base - offset)
}
}
@MainActor

View File

@ -34,6 +34,10 @@ public final class ClawdisChatViewModel {
didSet { self.pendingRunCount = self.pendingRuns.count }
}
@ObservationIgnored
private nonisolated(unsafe) var pendingRunTimeoutTasks: [String: Task<Void, Never>] = [:]
private let pendingRunTimeoutMs: UInt64 = 120_000
private var lastHealthPollAt: Date?
public init(sessionKey: String, transport: any ClawdisChatTransport) {
@ -54,6 +58,9 @@ public final class ClawdisChatViewModel {
deinit {
self.eventTask?.cancel()
for (_, task) in self.pendingRunTimeoutTasks {
task.cancel()
}
}
public func load() {
@ -91,6 +98,7 @@ public final class ClawdisChatViewModel {
self.isLoading = true
self.errorText = nil
self.healthOK = false
self.clearPendingRuns(reason: nil)
defer { self.isLoading = false }
do {
do {
@ -173,6 +181,7 @@ public final class ClawdisChatViewModel {
idempotencyKey: runId,
attachments: encodedAttachments)
self.pendingRuns.insert(response.runId)
self.armPendingRunTimeout(runId: response.runId)
} catch {
self.errorText = error.localizedDescription
chatUILogger.error("chat.send failed \(error.localizedDescription, privacy: .public)")
@ -193,6 +202,7 @@ public final class ClawdisChatViewModel {
self.handleChatEvent(chat)
case .seqGap:
self.errorText = "Event stream interrupted; try refreshing."
self.clearPendingRuns(reason: nil)
}
}
@ -214,18 +224,53 @@ public final class ClawdisChatViewModel {
self.messages.append(msg)
}
if let runId = chat.runId {
self.pendingRuns.remove(runId)
self.clearPendingRun(runId)
} else if self.pendingRuns.count <= 1 {
self.clearPendingRuns(reason: nil)
}
case "error":
self.errorText = chat.errorMessage ?? "Chat failed"
if let runId = chat.runId {
self.pendingRuns.remove(runId)
self.clearPendingRun(runId)
} else if self.pendingRuns.count <= 1 {
self.clearPendingRuns(reason: nil)
}
default:
break
}
}
private func armPendingRunTimeout(runId: String) {
self.pendingRunTimeoutTasks[runId]?.cancel()
self.pendingRunTimeoutTasks[runId] = Task { [weak self] in
let timeoutMs = await MainActor.run { self?.pendingRunTimeoutMs ?? 0 }
try? await Task.sleep(nanoseconds: timeoutMs * 1_000_000)
await MainActor.run { [weak self] in
guard let self else { return }
guard self.pendingRuns.contains(runId) else { return }
self.clearPendingRun(runId)
self.errorText = "Timed out waiting for a reply; try again or refresh."
}
}
}
private func clearPendingRun(_ runId: String) {
self.pendingRuns.remove(runId)
self.pendingRunTimeoutTasks[runId]?.cancel()
self.pendingRunTimeoutTasks[runId] = nil
}
private func clearPendingRuns(reason: String?) {
for runId in self.pendingRuns {
self.pendingRunTimeoutTasks[runId]?.cancel()
}
self.pendingRunTimeoutTasks.removeAll()
self.pendingRuns.removeAll()
if let reason, !reason.isEmpty {
self.errorText = reason
}
}
private func pollHealthIfNeeded(force: Bool) async {
if !force, let last = self.lastHealthPollAt, Date().timeIntervalSince(last) < 10 {
return

View File

@ -680,16 +680,32 @@ export async function getReplyFromConfig(
}
}
// Prepend queued system events and (for new main sessions) a provider snapshot.
// Prepend queued system events (transitions only) and (for new main sessions) a provider snapshot.
// Token efficiency: we filter out periodic/heartbeat noise and keep the lines compact.
const isGroupSession =
typeof ctx.From === "string" &&
(ctx.From.includes("@g.us") || ctx.From.startsWith("group:"));
const isMainSession =
!isGroupSession && sessionKey === (sessionCfg?.mainKey ?? "main");
if (isMainSession) {
const compactSystemEvent = (line: string): string | null => {
const trimmed = line.trim();
if (!trimmed) return null;
const lower = trimmed.toLowerCase();
if (lower.includes("reason periodic")) return null;
if (lower.includes("heartbeat")) return null;
if (trimmed.startsWith("Node:")) {
// Drop the chatty "last input … ago" segment; keep connect/disconnect/launch reasons.
return trimmed.replace(/ · last input [^·]+/i, "").trim();
}
return trimmed;
};
const systemLines: string[] = [];
const queued = drainSystemEvents();
systemLines.push(...queued);
systemLines.push(
...queued.map(compactSystemEvent).filter((v): v is string => Boolean(v)),
);
if (isNewSession) {
const summary = await buildProviderSummary(cfg);
if (summary.length > 0) systemLines.unshift(...summary);

View File

@ -459,7 +459,7 @@ export const CronRunLogEntrySchema = Type.Object(
export const ChatHistoryParamsSchema = Type.Object(
{
sessionKey: NonEmptyString,
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 500 })),
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 1000 })),
},
{ additionalProperties: false },
);

View File

@ -1783,6 +1783,178 @@ describe("gateway server", () => {
await server.close();
});
test("chat.history caps large histories and honors limit", async () => {
const firstContentText = (msg: unknown): string | undefined => {
if (!msg || typeof msg !== "object") return undefined;
const content = (msg as { content?: unknown }).content;
if (!Array.isArray(content) || content.length === 0) return undefined;
const first = content[0];
if (!first || typeof first !== "object") return undefined;
const text = (first as { text?: unknown }).text;
return typeof text === "string" ? text : undefined;
};
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testSessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
const lines: string[] = [];
for (let i = 0; i < 300; i += 1) {
lines.push(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `m${i}` }],
timestamp: Date.now() + i,
},
}),
);
}
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
lines.join("\n"),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const defaultRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{
sessionKey: "main",
},
);
expect(defaultRes.ok).toBe(true);
const defaultMsgs = defaultRes.payload?.messages ?? [];
expect(defaultMsgs.length).toBe(200);
expect(firstContentText(defaultMsgs[0])).toBe("m100");
const limitedRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{
sessionKey: "main",
limit: 5,
},
);
expect(limitedRes.ok).toBe(true);
const limitedMsgs = limitedRes.payload?.messages ?? [];
expect(limitedMsgs.length).toBe(5);
expect(firstContentText(limitedMsgs[0])).toBe("m295");
const largeLines: string[] = [];
for (let i = 0; i < 1500; i += 1) {
largeLines.push(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `b${i}` }],
timestamp: Date.now() + i,
},
}),
);
}
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
largeLines.join("\n"),
"utf-8",
);
const cappedRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{
sessionKey: "main",
},
);
expect(cappedRes.ok).toBe(true);
const cappedMsgs = cappedRes.payload?.messages ?? [];
expect(cappedMsgs.length).toBe(200);
expect(firstContentText(cappedMsgs[0])).toBe("b1300");
const maxRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
limit: 1000,
});
expect(maxRes.ok).toBe(true);
const maxMsgs = maxRes.payload?.messages ?? [];
expect(maxMsgs.length).toBe(1000);
expect(firstContentText(maxMsgs[0])).toBe("b500");
ws.close();
await server.close();
});
test("chat.history caps payload bytes", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");
await fs.writeFile(
testSessionStorePath,
JSON.stringify(
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
null,
2,
),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const bigText = "x".repeat(300_000);
const largeLines: string[] = [];
for (let i = 0; i < 60; i += 1) {
largeLines.push(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `${i}:${bigText}` }],
timestamp: Date.now() + i,
},
}),
);
}
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
largeLines.join("\n"),
"utf-8",
);
const cappedRes = await rpcReq<{ messages?: unknown[] }>(
ws,
"chat.history",
{ sessionKey: "main", limit: 1000 },
);
expect(cappedRes.ok).toBe(true);
const cappedMsgs = cappedRes.payload?.messages ?? [];
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
expect(cappedMsgs.length).toBeLessThan(60);
ws.close();
await server.close();
});
test("chat.send does not overwrite last delivery route", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
testSessionStorePath = path.join(dir, "sessions.json");

View File

@ -258,6 +258,7 @@ function buildSnapshot(): Snapshot {
const MAX_PAYLOAD_BYTES = 512 * 1024; // cap incoming frame size
const MAX_BUFFERED_BYTES = 1.5 * 1024 * 1024; // per-connection send buffer limit
const MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
const HANDSHAKE_TIMEOUT_MS = 10_000;
const TICK_INTERVAL_MS = 30_000;
const HEALTH_REFRESH_INTERVAL_MS = 60_000;
@ -354,6 +355,30 @@ function readSessionMessages(
return messages;
}
function jsonUtf8Bytes(value: unknown): number {
try {
return Buffer.byteLength(JSON.stringify(value), "utf8");
} catch {
return Buffer.byteLength(String(value), "utf8");
}
}
function capArrayByJsonBytes<T>(
items: T[],
maxBytes: number,
): { items: T[]; bytes: number } {
if (items.length === 0) return { items, bytes: 2 };
const parts = items.map((item) => jsonUtf8Bytes(item));
let bytes = 2 + parts.reduce((a, b) => a + b, 0) + (items.length - 1); // [] + commas
let start = 0;
while (bytes > maxBytes && start < items.length - 1) {
bytes -= parts[start] + 1; // item + comma
start += 1;
}
const next = start > 0 ? items.slice(start) : items;
return { items: next, bytes };
}
function loadSessionEntry(sessionKey: string) {
const cfg = loadConfig();
const sessionCfg = cfg.inbound?.reply?.session;
@ -879,8 +904,12 @@ export async function startGatewayServer(
? readSessionMessages(sessionId, storePath)
: [];
const max = typeof limit === "number" ? limit : 200;
const messages =
const sliced =
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
const capped = capArrayByJsonBytes(
sliced,
MAX_CHAT_HISTORY_MESSAGES_BYTES,
).items;
const thinkingLevel =
entry?.thinkingLevel ??
loadConfig().inbound?.reply?.thinkingDefault ??
@ -890,7 +919,7 @@ export async function startGatewayServer(
payloadJSON: JSON.stringify({
sessionKey,
sessionId,
messages,
messages: capped,
thinkingLevel,
}),
};
@ -1841,18 +1870,36 @@ export async function startGatewayServer(
);
break;
}
const { sessionKey } = params as { sessionKey: string };
const { sessionKey, limit } = params as {
sessionKey: string;
limit?: number;
};
const { storePath, entry } = loadSessionEntry(sessionKey);
const sessionId = entry?.sessionId;
const messages =
const rawMessages =
sessionId && storePath
? readSessionMessages(sessionId, storePath)
: [];
const hardMax = 1000;
const defaultLimit = 200;
const requested = typeof limit === "number" ? limit : defaultLimit;
const max = Math.min(hardMax, requested);
const sliced =
rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
const capped = capArrayByJsonBytes(
sliced,
MAX_CHAT_HISTORY_MESSAGES_BYTES,
).items;
const thinkingLevel =
entry?.thinkingLevel ??
loadConfig().inbound?.reply?.thinkingDefault ??
"off";
respond(true, { sessionKey, sessionId, messages, thinkingLevel });
respond(true, {
sessionKey,
sessionId,
messages: capped,
thinkingLevel,
});
break;
}
case "chat.send": {
@ -2355,7 +2402,18 @@ export async function startGatewayServer(
reason,
tags,
});
enqueueSystemEvent(text);
const isNodePresenceLine = text.startsWith("Node:");
const normalizedReason = (reason ?? "").toLowerCase();
const looksPeriodic =
normalizedReason.startsWith("periodic") ||
normalizedReason === "heartbeat";
if (!(isNodePresenceLine && looksPeriodic)) {
const compactNodeText =
isNodePresenceLine && (host || ip || version || mode || reason)
? `Node: ${host?.trim() || "Unknown"}${ip ? ` (${ip})` : ""} · app ${version?.trim() || "unknown"} · mode ${mode?.trim() || "unknown"} · reason ${reason?.trim() || "event"}`
: text;
enqueueSystemEvent(compactNodeText);
}
presenceVersion += 1;
broadcast(
"presence",