Merge branch 'main' into shadow/discord-transport
This commit is contained in:
commit
96428d0f24
@ -1,5 +1,3 @@
|
||||
READ ~/Projects/agent-scripts/AGENTS.MD BEFORE ANYTHING (skip if missing).
|
||||
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
28
apps/macos/Sources/Clawdis/FileHandle+SafeRead.swift
Normal file
28
apps/macos/Sources/Clawdis/FileHandle+SafeRead.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
25
apps/macos/Sources/ClawdisCLI/FileHandle+SafeRead.swift
Normal file
25
apps/macos/Sources/ClawdisCLI/FileHandle+SafeRead.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user