Format with swift-format
`swift-format lint --strict --recursive Termind TermindTests
TermindMCPBridge` is in the contribution instructions but had never been
run to completion: 4133 violations across 141 files, most of them
indentation and line breaking the pretty-printer would have settled on
its own.
`swift-format format -i` accounts for all but five. The rest are
lint-only rules it will not rewrite for you:
- `SSHKeyMaterial` declared its libssh2 entry point in the C symbol's
own spelling. `@_silgen_name` already carries the linkage name, so the
Swift side is free to be `libssh2PublicKeyFromPrivateMemory`.
- Four `.forEach { ... }` calls become for-in loops, two in
`PortForwardingController` and two in tests.
Formatting only, and checked as such rather than assumed: all 5409
string literals under `Termind` are byte-identical across the change, so
no localization key moved. Comparing whitespace-stripped token streams
leaves 43 files with any real change, and each is one of the rules
above, a dropped semicolon, a `case a, b` split onto separate lines with
its raw values kept explicit, or a file-scope `private` respelled
`fileprivate` — which is the same access level in that position.
Builds, and the suite stays at 975 of 975.
This commit is contained in:
1 parent
41404ca48d
commit
ba46e90e30
139 files changed
+4709
-3401
No files matched your search
@@ -21,11 +21,12 @@ nonisolated enum AppLocalization {
|
||||
}
|
||||
|
||||
private static func bundle(for locale: Locale) -> Bundle {
|
||||
let localization: String? = switch locale.language.languageCode?.identifier {
|
||||
case "zh": "zh-Hans"
|
||||
case "en": "en"
|
||||
default: nil
|
||||
}
|
||||
let localization: String? =
|
||||
switch locale.language.languageCode?.identifier {
|
||||
case "zh": "zh-Hans"
|
||||
case "en": "en"
|
||||
default: nil
|
||||
}
|
||||
guard let localization,
|
||||
let path = Bundle.main.path(forResource: localization, ofType: "lproj"),
|
||||
let bundle = Bundle(path: path)
|
||||
|
||||
+40
-30
@@ -266,13 +266,14 @@ final class AppModel {
|
||||
for hostID in changedHostIDs {
|
||||
let oldHost = hosts.host(hostID)
|
||||
let newHost = snapshot.hosts.first { $0.id == hostID }
|
||||
let routeChanged = oldHost.map { old in
|
||||
newHost.map {
|
||||
old.host != $0.host || old.port != $0.port
|
||||
|| old.identityID != $0.identityID || old.jumpHostID != $0.jumpHostID
|
||||
|| old.proxy != $0.proxy
|
||||
} ?? true
|
||||
} ?? false
|
||||
let routeChanged =
|
||||
oldHost.map { old in
|
||||
newHost.map {
|
||||
old.host != $0.host || old.port != $0.port
|
||||
|| old.identityID != $0.identityID || old.jumpHostID != $0.jumpHostID
|
||||
|| old.proxy != $0.proxy
|
||||
} ?? true
|
||||
} ?? false
|
||||
if routeChanged {
|
||||
hosts.willDeleteHost?(hostID, hosts.portForwardings(for: hostID).map(\.id))
|
||||
}
|
||||
@@ -334,10 +335,12 @@ final class AppModel {
|
||||
prompt
|
||||
?? lastUserTurn?.text
|
||||
?? ""
|
||||
let userImages = promptImages
|
||||
let userImages =
|
||||
promptImages
|
||||
?? lastUserTurn?.images
|
||||
?? []
|
||||
let userContextAttachments = promptContextAttachments
|
||||
let userContextAttachments =
|
||||
promptContextAttachments
|
||||
?? lastUserTurn?.contextAttachments
|
||||
?? []
|
||||
guard !userPrompt.isEmpty || !userImages.isEmpty || !userContextAttachments.isEmpty
|
||||
@@ -406,7 +409,8 @@ final class AppModel {
|
||||
// thinking must not change what this run was told it had. Empty when the
|
||||
// model cannot call tools, which is what keeps the catalog block from
|
||||
// naming a tool this run was never given.
|
||||
let skillCatalog = chat.model.abilities.contains(.toolCall)
|
||||
let skillCatalog =
|
||||
chat.model.abilities.contains(.toolCall)
|
||||
? skills.catalog : AgentSkillCatalog()
|
||||
|
||||
var registry = AgentToolRegistry([AnyAgentTool]())
|
||||
@@ -447,13 +451,14 @@ final class AppModel {
|
||||
mcpServers: servers,
|
||||
mcpClient: MCPClient(clientInfo: TermindAgentPolicy.mcpClientInfo)
|
||||
),
|
||||
additional: TermindAgentTools.tools(in: TermindAgentToolEnvironment(
|
||||
hosts: agentHosts, connections: agentConnections,
|
||||
authentication: agentAuthentication,
|
||||
background: agentBackgroundCommands,
|
||||
includesTerminalSuggestion: includesTerminalSuggestion,
|
||||
workspace: scratch
|
||||
))
|
||||
additional: TermindAgentTools.tools(
|
||||
in: TermindAgentToolEnvironment(
|
||||
hosts: agentHosts, connections: agentConnections,
|
||||
authentication: agentAuthentication,
|
||||
background: agentBackgroundCommands,
|
||||
includesTerminalSuggestion: includesTerminalSuggestion,
|
||||
workspace: scratch
|
||||
))
|
||||
)
|
||||
}
|
||||
// The one place the resolved tool set exists. Recorded on the
|
||||
@@ -537,9 +542,10 @@ final class AppModel {
|
||||
guard proAccess.require(.assistant) else { return false }
|
||||
guard !conversation.isStreaming, !conversation.isCompacting else { return false }
|
||||
conversation.beginCompaction()
|
||||
guard let span = conversation.compactionSpan(
|
||||
keepRecentTokens: compactionBudget.keepRecentTokens
|
||||
)
|
||||
guard
|
||||
let span = conversation.compactionSpan(
|
||||
keepRecentTokens: compactionBudget.keepRecentTokens
|
||||
)
|
||||
else {
|
||||
if isManual {
|
||||
conversation.failCompaction(
|
||||
@@ -560,9 +566,11 @@ final class AppModel {
|
||||
span.summarize, instructions: instructions,
|
||||
toolResultLimit: compactionBudget.toolResultLimit, model: model
|
||||
)
|
||||
guard let record = conversation.prepareCompaction(
|
||||
summary: summary, cut: span.cut
|
||||
) else {
|
||||
guard
|
||||
let record = conversation.prepareCompaction(
|
||||
summary: summary, cut: span.cut
|
||||
)
|
||||
else {
|
||||
throw AgentCompactionError.nothingToCompact
|
||||
}
|
||||
try await persistAndApplyCompaction(record, to: conversation, cut: span.cut)
|
||||
@@ -682,7 +690,8 @@ final class AppModel {
|
||||
$0.role == .assistant && !$0.text.isEmpty
|
||||
})?.text
|
||||
else { return nil }
|
||||
let user = firstUser.text.isEmpty
|
||||
let user =
|
||||
firstUser.text.isEmpty
|
||||
? firstUser.images.first?.label
|
||||
?? firstUser.contextAttachments.first?.label
|
||||
?? ""
|
||||
@@ -781,7 +790,8 @@ final class AppModel {
|
||||
let library = window?.assistantLibrary
|
||||
else { return }
|
||||
let toPinned = !conversation.isPinned
|
||||
let nextID = (toPinned
|
||||
let nextID =
|
||||
(toPinned
|
||||
? library.pinnedConversations
|
||||
: library.activeConversations).first?.id
|
||||
moveConversation(
|
||||
@@ -868,9 +878,10 @@ final class AppModel {
|
||||
) {
|
||||
guard let source = conversation(id) else { return }
|
||||
let title = assistantConversations.nextForkTitle(for: source)
|
||||
guard let fork = source.forked(
|
||||
throughAssistantMessage: messageID, title: title
|
||||
)
|
||||
guard
|
||||
let fork = source.forked(
|
||||
throughAssistantMessage: messageID, title: title
|
||||
)
|
||||
else { return }
|
||||
let stored = StoredAgentConversation(
|
||||
id: fork.id, messages: fork.transcript,
|
||||
@@ -896,8 +907,7 @@ final class AppModel {
|
||||
try await agentRunRepository.createConversation(stored)
|
||||
guard let self else { return }
|
||||
self.assistantConversations.insert(fork)
|
||||
if inSidebar { window?.openAssistantInSidebar(fork.id) }
|
||||
else { window?.selectAssistant(fork.id) }
|
||||
if inSidebar { window?.openAssistantInSidebar(fork.id) } else { window?.selectAssistant(fork.id) }
|
||||
} catch {
|
||||
try? await AgentScratchWorkspace(conversationID: fork.id).removeScope()
|
||||
window?.assistantOperationError = error.localizedDescription
|
||||
|
||||
@@ -7,7 +7,9 @@ final class MCPServerController {
|
||||
static let tokenReference = "termind.ai.mcpserver.token"
|
||||
|
||||
enum Status: Equatable {
|
||||
case stopped, starting, ready(port: UInt16), failed(String)
|
||||
case stopped, starting
|
||||
case ready(port: UInt16)
|
||||
case failed(String)
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
@@ -58,9 +60,11 @@ final class MCPServerController {
|
||||
self.defaults = defaults
|
||||
self.canUsePro = canUsePro
|
||||
self.requestPro = requestPro
|
||||
isEnabled = defaults.object(forKey: SettingsKey.mcpServerEnabled) as? Bool
|
||||
isEnabled =
|
||||
defaults.object(forKey: SettingsKey.mcpServerEnabled) as? Bool
|
||||
?? SettingsKey.defaultMCPServerEnabled
|
||||
port = defaults.object(forKey: SettingsKey.mcpServerPort) as? Int
|
||||
port =
|
||||
defaults.object(forKey: SettingsKey.mcpServerPort) as? Int
|
||||
?? SettingsKey.defaultMCPServerPort
|
||||
let initialDisabledTools = Self.readDisabledTools(defaults)
|
||||
disabledTools = initialDisabledTools
|
||||
|
||||
+19
-15
@@ -39,21 +39,21 @@ struct RootView: View {
|
||||
)
|
||||
} detail: {
|
||||
detail
|
||||
// Floating over the pane rather than docked under it, so switching
|
||||
// to a Workspace no longer resizes the content area — and so the
|
||||
// bar reads as belonging to the session it describes.
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if case .workspace = window.selectedDestination {
|
||||
StatusBarView()
|
||||
.padding(Metrics.StatusBar.floatingInset)
|
||||
// Floating over the pane rather than docked under it, so switching
|
||||
// to a Workspace no longer resizes the content area — and so the
|
||||
// bar reads as belonging to the session it describes.
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
if case .workspace = window.selectedDestination {
|
||||
StatusBarView()
|
||||
.padding(Metrics.StatusBar.floatingInset)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(
|
||||
minWidth: Metrics.Window.detailMin,
|
||||
maxWidth: .infinity,
|
||||
minHeight: Metrics.Window.detailMinHeight,
|
||||
maxHeight: .infinity
|
||||
)
|
||||
.frame(
|
||||
minWidth: Metrics.Window.detailMin,
|
||||
maxWidth: .infinity,
|
||||
minHeight: Metrics.Window.detailMinHeight,
|
||||
maxHeight: .infinity
|
||||
)
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.background {
|
||||
@@ -74,7 +74,11 @@ struct RootView: View {
|
||||
maxHeight: .infinity
|
||||
)
|
||||
.background { FullHeightWindowContent() }
|
||||
.onGeometryChange(for: CGFloat.self) { $0.size.width } action: { layoutWidth = $0 }
|
||||
.onGeometryChange(for: CGFloat.self) {
|
||||
$0.size.width
|
||||
} action: {
|
||||
layoutWidth = $0
|
||||
}
|
||||
.toolbarBackground(.hidden, for: .windowToolbar)
|
||||
.navigationTitle(
|
||||
WindowTitleCatalog.standard(hosts: app.hostLibrary)
|
||||
|
||||
@@ -159,14 +159,16 @@
|
||||
}
|
||||
|
||||
let first = Pane(.sshSession(firstSession))
|
||||
first.customTitle = showsCommandResult
|
||||
first.customTitle =
|
||||
showsCommandResult
|
||||
? text(en: "nginx logs", zh: "Nginx 日志")
|
||||
: "api-01"
|
||||
let sftpSession = SFTPSessionModel.demo(hostID: saved[0].id)
|
||||
sftpSession.selection = ["/etc/nginx/nginx.conf"]
|
||||
let sftp = Pane(.sftpSession(sftpSession))
|
||||
sftp.customTitle = text(en: "nginx config", zh: "Nginx 配置")
|
||||
let secondSession = showsCommandResult
|
||||
let secondSession =
|
||||
showsCommandResult
|
||||
? SSHSessionModel.demo(host: saved[1])
|
||||
: SSHSessionModel.demo(
|
||||
host: saved[1],
|
||||
@@ -196,7 +198,8 @@
|
||||
)
|
||||
}
|
||||
workspace.focusedPaneID = first.id
|
||||
workspace.customTitle = showsCommandResult
|
||||
workspace.customTitle =
|
||||
showsCommandResult
|
||||
? text(en: "Log investigation", zh: "日志调查")
|
||||
: text(en: "Production diagnostics", zh: "生产环境诊断")
|
||||
workspace.broadcastsInput = !showsCommandResult
|
||||
@@ -236,12 +239,13 @@
|
||||
"Aug 27 09:14:03 web-02 systemd[1]: Started nginx.service",
|
||||
]
|
||||
}
|
||||
return ([
|
||||
"\u{001B}]7;file://\(host)/var/log/nginx\u{0007}",
|
||||
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
|
||||
] + history + [
|
||||
prompt + " tail -f /var/log/nginx/access.log",
|
||||
]).joined(separator: "\r\n")
|
||||
return
|
||||
([
|
||||
"\u{001B}]7;file://\(host)/var/log/nginx\u{0007}",
|
||||
"\u{001B}[2mLast login: Thu Aug 27 16:42:18 from 10.0.0.12\u{001B}[0m",
|
||||
] + history + [
|
||||
prompt + " tail -f /var/log/nginx/access.log"
|
||||
]).joined(separator: "\r\n")
|
||||
}
|
||||
|
||||
private func text(en: String, zh: String) -> String {
|
||||
|
||||
@@ -32,8 +32,8 @@ struct TermindCommands: Commands {
|
||||
app.onboarding.recordUse(of: .quickOpen)
|
||||
window?.isQuickOpenPresented = true
|
||||
}
|
||||
.termindShortcut("p")
|
||||
.disabled(window == nil)
|
||||
.termindShortcut("p")
|
||||
.disabled(window == nil)
|
||||
|
||||
Divider()
|
||||
|
||||
|
||||
@@ -75,8 +75,10 @@ nonisolated struct SFTPListTool: SFTPToolDefinition {
|
||||
)
|
||||
let authentication: AgentSSHAuthenticationRegistry
|
||||
let connections: AgentConnectionPool
|
||||
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation) async throws -> AgentToolResult {
|
||||
func perform(
|
||||
client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation
|
||||
) async throws -> AgentToolResult {
|
||||
let files = try await client.listDirectory(at: path)
|
||||
return Self.result(invocation, .array(files.map(AgentToolSupport.remoteFileJSON)))
|
||||
}
|
||||
@@ -95,8 +97,10 @@ nonisolated struct SFTPStatTool: SFTPToolDefinition {
|
||||
)
|
||||
let authentication: AgentSSHAuthenticationRegistry
|
||||
let connections: AgentConnectionPool
|
||||
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation) async throws -> AgentToolResult {
|
||||
func perform(
|
||||
client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation
|
||||
) async throws -> AgentToolResult {
|
||||
guard let file = try await client.attributes(at: path) else { throw AgentToolError.notFound }
|
||||
return Self.result(invocation, AgentToolSupport.remoteFileJSON(file))
|
||||
}
|
||||
@@ -115,15 +119,19 @@ nonisolated struct SFTPReadTool: SFTPToolDefinition {
|
||||
)
|
||||
let authentication: AgentSSHAuthenticationRegistry
|
||||
let connections: AgentConnectionPool
|
||||
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation) async throws -> AgentToolResult {
|
||||
func perform(
|
||||
client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation
|
||||
) async throws -> AgentToolResult {
|
||||
let data = try await client.readFile(at: path, maximumBytes: 128 * 1_024)
|
||||
guard let text = String(data: data, encoding: .utf8) else {
|
||||
throw AgentToolError.invalidArguments("The remote file is not UTF-8 text.")
|
||||
}
|
||||
return Self.result(invocation, .object([
|
||||
"path": .string(path), "content": .string(text), "bytes": .number(Double(data.count)),
|
||||
]))
|
||||
return Self.result(
|
||||
invocation,
|
||||
.object([
|
||||
"path": .string(path), "content": .string(text), "bytes": .number(Double(data.count)),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,10 +151,13 @@ nonisolated struct SFTPWriteTool: SFTPToolDefinition {
|
||||
)
|
||||
let authentication: AgentSSHAuthenticationRegistry
|
||||
let connections: AgentConnectionPool
|
||||
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation) async throws -> AgentToolResult {
|
||||
try await client.writeFile(Data(try arguments.string("content").utf8), at: path,
|
||||
overwrite: arguments.optionalBool("overwrite") ?? false)
|
||||
func perform(
|
||||
client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation
|
||||
) async throws -> AgentToolResult {
|
||||
try await client.writeFile(
|
||||
Data(try arguments.string("content").utf8), at: path,
|
||||
overwrite: arguments.optionalBool("overwrite") ?? false)
|
||||
return Self.result(invocation, .object(["path": .string(path), "written": .bool(true)]))
|
||||
}
|
||||
}
|
||||
@@ -165,8 +176,10 @@ nonisolated struct SFTPMkdirTool: SFTPToolDefinition {
|
||||
)
|
||||
let authentication: AgentSSHAuthenticationRegistry
|
||||
let connections: AgentConnectionPool
|
||||
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation) async throws -> AgentToolResult {
|
||||
func perform(
|
||||
client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation
|
||||
) async throws -> AgentToolResult {
|
||||
try await client.createDirectory(at: path)
|
||||
return Self.result(invocation, .object(["path": .string(path), "created": .bool(true)]))
|
||||
}
|
||||
@@ -187,14 +200,19 @@ nonisolated struct SFTPMoveTool: SFTPToolDefinition {
|
||||
)
|
||||
let authentication: AgentSSHAuthenticationRegistry
|
||||
let connections: AgentConnectionPool
|
||||
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation) async throws -> AgentToolResult {
|
||||
func perform(
|
||||
client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation
|
||||
) async throws -> AgentToolResult {
|
||||
let destination = try AgentToolSupport.normalizeRemotePath(arguments.string("destination"))
|
||||
try await client.rename(from: path, to: destination,
|
||||
overwrite: arguments.optionalBool("overwrite") ?? false)
|
||||
return Self.result(invocation, .object([
|
||||
"source": .string(path), "destination": .string(destination), "moved": .bool(true),
|
||||
]))
|
||||
try await client.rename(
|
||||
from: path, to: destination,
|
||||
overwrite: arguments.optionalBool("overwrite") ?? false)
|
||||
return Self.result(
|
||||
invocation,
|
||||
.object([
|
||||
"source": .string(path), "destination": .string(destination), "moved": .bool(true),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,11 +231,14 @@ nonisolated struct SFTPChmodTool: SFTPToolDefinition {
|
||||
)
|
||||
let authentication: AgentSSHAuthenticationRegistry
|
||||
let connections: AgentConnectionPool
|
||||
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation) async throws -> AgentToolResult {
|
||||
func perform(
|
||||
client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation
|
||||
) async throws -> AgentToolResult {
|
||||
let raw = try arguments.string("mode")
|
||||
guard (3...4).contains(raw.count), raw.allSatisfy({ ("0"..."7").contains(String($0)) }),
|
||||
let mode = UInt32(raw, radix: 8) else {
|
||||
let mode = UInt32(raw, radix: 8)
|
||||
else {
|
||||
throw AgentToolError.invalidArguments("mode must be a 3- or 4-digit octal value.")
|
||||
}
|
||||
try await client.setPermissions(at: path, to: mode)
|
||||
@@ -240,10 +261,15 @@ nonisolated struct SFTPDeleteTool: SFTPToolDefinition {
|
||||
)
|
||||
let authentication: AgentSSHAuthenticationRegistry
|
||||
let connections: AgentConnectionPool
|
||||
func perform(client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation) async throws -> AgentToolResult {
|
||||
if try arguments.string("kind") == "directory" { try await client.removeDirectory(at: path) }
|
||||
else { try await client.removeFile(at: path) }
|
||||
func perform(
|
||||
client: any SFTPClientProtocol, path: String, arguments: Arguments,
|
||||
invocation: AgentToolInvocation
|
||||
) async throws -> AgentToolResult {
|
||||
if try arguments.string("kind") == "directory" {
|
||||
try await client.removeDirectory(at: path)
|
||||
} else {
|
||||
try await client.removeFile(at: path)
|
||||
}
|
||||
return Self.result(invocation, .object(["path": .string(path), "deleted": .bool(true)]))
|
||||
}
|
||||
}
|
||||
@@ -257,10 +283,11 @@ nonisolated extension SFTPListTool {
|
||||
}
|
||||
return [
|
||||
.message(String(localized: "\(rows.count) items", locale: input.locale), .secondary),
|
||||
.list(.init(
|
||||
title: nil, rows: rows,
|
||||
emptyMessage: F.localized("Directory is empty", locale: input.locale)
|
||||
)),
|
||||
.list(
|
||||
.init(
|
||||
title: nil, rows: rows,
|
||||
emptyMessage: F.localized("Directory is empty", locale: input.locale)
|
||||
)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -270,18 +297,23 @@ nonisolated extension SFTPStatTool {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
return F.objectItems(input) { object in
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(&items, "Kind", object["kind"]?.stringValue.map {
|
||||
F.kindLabel($0, locale: input.locale)
|
||||
}, locale: input.locale)
|
||||
F.appendField(
|
||||
&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Kind",
|
||||
object["kind"]?.stringValue.map {
|
||||
F.kindLabel($0, locale: input.locale)
|
||||
}, locale: input.locale)
|
||||
if let bytes = object["size"]?.integerValue {
|
||||
items.append(F.field("Size", F.byteCount(bytes), locale: input.locale))
|
||||
}
|
||||
F.appendField(&items, "Permissions", object["permissions"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(&items, "Modified", object["modified_at"]?.stringValue.map(F.displayDate),
|
||||
locale: input.locale)
|
||||
F.appendField(
|
||||
&items, "Permissions", object["permissions"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Modified", object["modified_at"]?.stringValue.map(F.displayDate),
|
||||
locale: input.locale)
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -291,13 +323,17 @@ nonisolated extension SFTPReadTool {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
return F.objectItems(input) { object in
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
let content = object["content"]?.stringValue ?? ""
|
||||
items.append(content.isEmpty
|
||||
? .message(F.localized("Empty file", locale: input.locale), .secondary)
|
||||
: .text(.init(title: F.localized("Content", locale: input.locale),
|
||||
text: content, style: .monospaced)))
|
||||
items.append(
|
||||
content.isEmpty
|
||||
? .message(F.localized("Empty file", locale: input.locale), .secondary)
|
||||
: .text(
|
||||
.init(
|
||||
title: F.localized("Content", locale: input.locale),
|
||||
text: content, style: .monospaced)))
|
||||
if let bytes = object["bytes"]?.integerValue {
|
||||
items.append(F.field("Size", F.byteCount(bytes), locale: input.locale))
|
||||
}
|
||||
@@ -310,13 +346,16 @@ nonisolated extension SFTPWriteTool {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
return F.objectItems(input) { object in
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
if let content = input.arguments["content"]?.stringValue {
|
||||
items.append(F.field("Size", F.byteCount(content.utf8.count), locale: input.locale))
|
||||
}
|
||||
items.append(F.field("Result", F.localized("Written", locale: input.locale),
|
||||
locale: input.locale))
|
||||
items.append(
|
||||
F.field(
|
||||
"Result", F.localized("Written", locale: input.locale),
|
||||
locale: input.locale))
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -324,7 +363,8 @@ nonisolated extension SFTPWriteTool {
|
||||
nonisolated extension SFTPMkdirTool {
|
||||
static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
||||
SFTPToolPresentation.pathDetail(
|
||||
input, result: AgentToolDetailFormatting.localized(
|
||||
input,
|
||||
result: AgentToolDetailFormatting.localized(
|
||||
"Directory created", locale: input.locale
|
||||
)
|
||||
)
|
||||
@@ -335,10 +375,12 @@ nonisolated extension SFTPMoveTool {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
return F.objectItems(input) { object in
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Source", object["source"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(&items, "Destination", object["destination"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Source", object["source"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Destination", object["destination"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -348,10 +390,12 @@ nonisolated extension SFTPChmodTool {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
return F.objectItems(input) { object in
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(&items, "Permissions", object["mode"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Permissions", object["mode"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -361,13 +405,18 @@ nonisolated extension SFTPDeleteTool {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
return F.objectItems(input) { object in
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(&items, "Kind", input.arguments["kind"]?.stringValue.map {
|
||||
F.kindLabel($0, locale: input.locale)
|
||||
}, locale: input.locale)
|
||||
items.append(F.field("Result", F.localized("Deleted", locale: input.locale),
|
||||
locale: input.locale))
|
||||
F.appendField(
|
||||
&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Kind",
|
||||
input.arguments["kind"]?.stringValue.map {
|
||||
F.kindLabel($0, locale: input.locale)
|
||||
}, locale: input.locale)
|
||||
items.append(
|
||||
F.field(
|
||||
"Result", F.localized("Deleted", locale: input.locale),
|
||||
locale: input.locale))
|
||||
return items
|
||||
}
|
||||
}
|
||||
@@ -380,7 +429,8 @@ nonisolated enum SFTPToolPresentation {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
guard let object = value.objectValue else { return nil }
|
||||
let path = object["path"]?.stringValue ?? ""
|
||||
let name = F.nonempty(object["name"]?.stringValue)
|
||||
let name =
|
||||
F.nonempty(object["name"]?.stringValue)
|
||||
?? F.nonempty(path.split(separator: "/").last.map(String.init))
|
||||
?? F.localized("Unnamed item", locale: locale)
|
||||
let kind = object["kind"]?.stringValue ?? ""
|
||||
@@ -402,8 +452,9 @@ nonisolated enum SFTPToolPresentation {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
return F.objectItems(input) { object in
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
F.appendField(&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Path", object["path"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
items.append(F.field("Result", result, locale: input.locale))
|
||||
return items
|
||||
}
|
||||
@@ -452,7 +503,9 @@ nonisolated extension AgentToolSupport {
|
||||
if component == ".." {
|
||||
guard !components.isEmpty else { throw AgentToolError.invalidPath }
|
||||
components.removeLast()
|
||||
} else { components.append(component) }
|
||||
} else {
|
||||
components.append(component)
|
||||
}
|
||||
}
|
||||
return "/" + components.joined(separator: "/")
|
||||
}
|
||||
|
||||
@@ -7,33 +7,42 @@ nonisolated struct ListHostsTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
||||
)
|
||||
let hosts: AgentHostResolver
|
||||
|
||||
var descriptor: AgentToolDescriptor { Self.descriptor(
|
||||
"list_hosts", "List Vault and temporary Termind SSH targets without credentials.",
|
||||
properties: [:], required: [], target: .host, safety: .locallyReadOnly,
|
||||
concurrency: .parallel,
|
||||
presentation: .init(
|
||||
symbol: "server.rack", activity: .semanticLabel(.hosts), output: .json,
|
||||
actionKind: .list
|
||||
var descriptor: AgentToolDescriptor {
|
||||
Self.descriptor(
|
||||
"list_hosts", "List Vault and temporary Termind SSH targets without credentials.",
|
||||
properties: [:], required: [], target: .host, safety: .locallyReadOnly,
|
||||
concurrency: .parallel,
|
||||
presentation: .init(
|
||||
symbol: "server.rack", activity: .semanticLabel(.hosts), output: .json,
|
||||
actionKind: .list
|
||||
)
|
||||
)
|
||||
) }
|
||||
}
|
||||
|
||||
func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
|
||||
-> AgentToolResult {
|
||||
-> AgentToolResult
|
||||
{
|
||||
let listing = await hosts.listing()
|
||||
let rows = listing.hosts.map { target in AgentJSONValue.object([
|
||||
"id": .string(target.id.uuidString), "display_name": .string(target.displayName),
|
||||
"address": .string(target.address), "tags": .array(target.tags.map(AgentJSONValue.string)),
|
||||
"connection_state": .string(target.connectionState), "temporary": .bool(target.isTemporary),
|
||||
"group_id": target.groupID.map { .string($0.uuidString) } ?? .null,
|
||||
"group_path": target.groupPath.map(AgentJSONValue.string) ?? .null,
|
||||
]) }
|
||||
func groupJSON(_ group: AgentHostGroupTarget) -> AgentJSONValue { .object([
|
||||
"id": .string(group.id.uuidString), "name": .string(group.name),
|
||||
"path": .string(group.path), "children": .array(group.children.map(groupJSON)),
|
||||
]) }
|
||||
return Self.result(invocation, .object([
|
||||
"groups": .array(listing.groups.map(groupJSON)), "hosts": .array(rows),
|
||||
]))
|
||||
let rows = listing.hosts.map { target in
|
||||
AgentJSONValue.object([
|
||||
"id": .string(target.id.uuidString), "display_name": .string(target.displayName),
|
||||
"address": .string(target.address), "tags": .array(target.tags.map(AgentJSONValue.string)),
|
||||
"connection_state": .string(target.connectionState), "temporary": .bool(target.isTemporary),
|
||||
"group_id": target.groupID.map { .string($0.uuidString) } ?? .null,
|
||||
"group_path": target.groupPath.map(AgentJSONValue.string) ?? .null,
|
||||
])
|
||||
}
|
||||
func groupJSON(_ group: AgentHostGroupTarget) -> AgentJSONValue {
|
||||
.object([
|
||||
"id": .string(group.id.uuidString), "name": .string(group.name),
|
||||
"path": .string(group.path), "children": .array(group.children.map(groupJSON)),
|
||||
])
|
||||
}
|
||||
return Self.result(
|
||||
invocation,
|
||||
.object([
|
||||
"groups": .array(listing.groups.map(groupJSON)), "hosts": .array(rows),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,20 +52,25 @@ nonisolated struct SSHWaitTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
||||
)
|
||||
let background: AgentBackgroundCommands
|
||||
|
||||
var descriptor: AgentToolDescriptor { Self.descriptor(
|
||||
"ssh_wait", "Wait for a command ssh_exec reported as still running, and return its output.",
|
||||
properties: ["handle": Self.string(max: 64),
|
||||
"timeout_seconds": Self.integer(min: 1, max: 900)],
|
||||
required: ["handle"], target: .host, safety: .locallyReadOnly,
|
||||
concurrency: .parallel,
|
||||
presentation: .init(
|
||||
symbol: "clock.arrow.circlepath", activity: .semanticLabel(.remoteCommand),
|
||||
output: .streams, actionKind: .wait
|
||||
var descriptor: AgentToolDescriptor {
|
||||
Self.descriptor(
|
||||
"ssh_wait", "Wait for a command ssh_exec reported as still running, and return its output.",
|
||||
properties: [
|
||||
"handle": Self.string(max: 64),
|
||||
"timeout_seconds": Self.integer(min: 1, max: 900),
|
||||
],
|
||||
required: ["handle"], target: .host, safety: .locallyReadOnly,
|
||||
concurrency: .parallel,
|
||||
presentation: .init(
|
||||
symbol: "clock.arrow.circlepath", activity: .semanticLabel(.remoteCommand),
|
||||
output: .streams, actionKind: .wait
|
||||
)
|
||||
)
|
||||
) }
|
||||
}
|
||||
|
||||
func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
|
||||
-> AgentToolResult {
|
||||
-> AgentToolResult
|
||||
{
|
||||
let arguments = try Arguments(invocation)
|
||||
guard let handle = UUID(uuidString: try arguments.string("handle")) else {
|
||||
throw AgentToolError.invalidArguments("handle is not a valid handle.")
|
||||
@@ -66,15 +80,21 @@ nonisolated struct SSHWaitTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
||||
reporting: AgentToolSupport.reporting(invocation, context.report),
|
||||
orWhen: context.whenUserInterjects
|
||||
)
|
||||
guard let exitCode = outcome.exitCode else { return Self.result(invocation, .object([
|
||||
"status": .string("running"), "handle": .string(handle.uuidString),
|
||||
"stdout": .string(outcome.standardOut), "stderr": .string(outcome.standardError),
|
||||
])) }
|
||||
return Self.result(invocation, .object([
|
||||
"status": .string("exited"), "stdout": .string(outcome.standardOut),
|
||||
"stderr": .string(outcome.standardError), "exit_code": .number(Double(exitCode)),
|
||||
"signal": outcome.signal.map(AgentJSONValue.string) ?? .null,
|
||||
]))
|
||||
guard let exitCode = outcome.exitCode else {
|
||||
return Self.result(
|
||||
invocation,
|
||||
.object([
|
||||
"status": .string("running"), "handle": .string(handle.uuidString),
|
||||
"stdout": .string(outcome.standardOut), "stderr": .string(outcome.standardError),
|
||||
]))
|
||||
}
|
||||
return Self.result(
|
||||
invocation,
|
||||
.object([
|
||||
"status": .string("exited"), "stdout": .string(outcome.standardOut),
|
||||
"stderr": .string(outcome.standardError), "exit_code": .number(Double(exitCode)),
|
||||
"signal": outcome.signal.map(AgentJSONValue.string) ?? .null,
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,30 +102,37 @@ nonisolated struct TerminalSuggestTool: AgentToolDefinition, AgentToolSchemaBuil
|
||||
static let presenter = AgentToolDetailPresenter(
|
||||
id: "builtin.terminal_suggest", present: present
|
||||
)
|
||||
var descriptor: AgentToolDescriptor { Self.descriptor(
|
||||
"terminal_suggest", "Create a command card. Only the user can insert or run it in a PTY.",
|
||||
properties: ["command": Self.string(max: 32_768),
|
||||
"explanation": Self.string(max: 4_096)],
|
||||
required: ["command"], target: .user, safety: .locallyReadOnly,
|
||||
concurrency: .parallel,
|
||||
presentation: .init(
|
||||
symbol: "terminal", activity: .semanticArgument(key: "command", fallback: .command),
|
||||
groupsWithAdjacentTools: false, actionKind: .suggest
|
||||
var descriptor: AgentToolDescriptor {
|
||||
Self.descriptor(
|
||||
"terminal_suggest", "Create a command card. Only the user can insert or run it in a PTY.",
|
||||
properties: [
|
||||
"command": Self.string(max: 32_768),
|
||||
"explanation": Self.string(max: 4_096),
|
||||
],
|
||||
required: ["command"], target: .user, safety: .locallyReadOnly,
|
||||
concurrency: .parallel,
|
||||
presentation: .init(
|
||||
symbol: "terminal", activity: .semanticArgument(key: "command", fallback: .command),
|
||||
groupsWithAdjacentTools: false, actionKind: .suggest
|
||||
)
|
||||
)
|
||||
) }
|
||||
}
|
||||
func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
|
||||
-> AgentToolResult {
|
||||
-> AgentToolResult
|
||||
{
|
||||
let arguments = try Arguments(invocation)
|
||||
let command = try arguments.string("command")
|
||||
guard !command.contains("\n"), !command.contains("\r"), !command.contains("\0") else {
|
||||
throw AgentToolError.invalidArguments("A terminal suggestion must be one command line.")
|
||||
}
|
||||
return Self.result(invocation, .object([
|
||||
"command": .string(command),
|
||||
"explanation": .string(arguments.optionalString("explanation") ?? ""),
|
||||
"risk": .string(CommandRiskClassifier.assess(command).risk.label),
|
||||
"requires_user_action": .bool(true),
|
||||
]))
|
||||
return Self.result(
|
||||
invocation,
|
||||
.object([
|
||||
"command": .string(command),
|
||||
"explanation": .string(arguments.optionalString("explanation") ?? ""),
|
||||
"risk": .string(CommandRiskClassifier.assess(command).risk.label),
|
||||
"requires_user_action": .bool(true),
|
||||
]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,12 +146,15 @@ nonisolated extension TerminalSuggestTool {
|
||||
static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
let object = input.result.objectValue ?? [:]
|
||||
let command = input.arguments["command"]?.stringValue
|
||||
let command =
|
||||
input.arguments["command"]?.stringValue
|
||||
?? object["command"]?.stringValue ?? ""
|
||||
var items: [AgentToolDetail.Item] = [F.text("Command", command, locale: input.locale)]
|
||||
if let explanation = F.nonempty(
|
||||
input.arguments["explanation"]?.stringValue ?? object["explanation"]?.stringValue
|
||||
) { items.append(.message(explanation, .secondary)) }
|
||||
) {
|
||||
items.append(.message(explanation, .secondary))
|
||||
}
|
||||
let assessment = CommandRiskClassifier.assess(command)
|
||||
items.append(F.field("Risk", assessment.risk.label, locale: input.locale))
|
||||
items.append(contentsOf: assessment.reasons.map { .message($0, .warning) })
|
||||
@@ -155,10 +185,12 @@ nonisolated extension AgentToolSupport {
|
||||
_ report: @escaping @Sendable (AgentToolResult) -> Void
|
||||
) -> @Sendable (String, String) -> Void {
|
||||
{ out, error in
|
||||
var progress = result(invocation, .object([
|
||||
"status": .string("running"),
|
||||
"stdout": .string(out), "stderr": .string(error),
|
||||
]))
|
||||
var progress = result(
|
||||
invocation,
|
||||
.object([
|
||||
"status": .string("running"),
|
||||
"stdout": .string(out), "stderr": .string(error),
|
||||
]))
|
||||
progress.metadata["presentation_phase"] = .string("command")
|
||||
report(progress)
|
||||
}
|
||||
@@ -167,14 +199,16 @@ nonisolated extension AgentToolSupport {
|
||||
fileprivate static func exitedResult(
|
||||
_ invocation: AgentToolInvocation, _ output: SSHExecResult
|
||||
) -> AgentToolResult {
|
||||
result(invocation, .object([
|
||||
"status": .string("exited"),
|
||||
"stdout": .string(output.stdout), "stderr": .string(output.stderr),
|
||||
"exit_code": .number(Double(output.exitCode)),
|
||||
"signal": output.signal.map(AgentJSONValue.string) ?? .null,
|
||||
"stdout_truncated": .bool(output.stdoutTruncated),
|
||||
"stderr_truncated": .bool(output.stderrTruncated),
|
||||
]), truncated: output.stdoutTruncated || output.stderrTruncated)
|
||||
result(
|
||||
invocation,
|
||||
.object([
|
||||
"status": .string("exited"),
|
||||
"stdout": .string(output.stdout), "stderr": .string(output.stderr),
|
||||
"exit_code": .number(Double(output.exitCode)),
|
||||
"signal": output.signal.map(AgentJSONValue.string) ?? .null,
|
||||
"stdout_truncated": .bool(output.stdoutTruncated),
|
||||
"stderr_truncated": .bool(output.stderrTruncated),
|
||||
]), truncated: output.stdoutTruncated || output.stderrTruncated)
|
||||
}
|
||||
|
||||
/// Picks up a command `ssh_exec` left running.
|
||||
@@ -191,7 +225,8 @@ nonisolated struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
||||
let pool: AgentConnectionPool
|
||||
let background: AgentBackgroundCommands
|
||||
|
||||
var descriptor: AgentToolDescriptor { Self.descriptor(
|
||||
var descriptor: AgentToolDescriptor {
|
||||
Self.descriptor(
|
||||
"ssh_exec", "Execute one command over a dedicated non-PTY SSH exec channel.",
|
||||
properties: [
|
||||
"host_id": Self.string(), "command": Self.string(max: 32_768),
|
||||
@@ -228,151 +263,157 @@ nonisolated struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
||||
"interrupted": "Stopped waiting for “{activity}” on {target}",
|
||||
]
|
||||
)
|
||||
) }
|
||||
)
|
||||
}
|
||||
|
||||
func preflight(_ invocation: AgentToolInvocation) async throws -> AgentToolPreflight {
|
||||
var checked = invocation
|
||||
let arguments = try Arguments(invocation)
|
||||
checked.targetHostID = try arguments.uuid("host_id")
|
||||
let command = try arguments.string("command")
|
||||
if arguments.optionalString("stdin") != nil && arguments.optionalString("secret_handle") != nil {
|
||||
throw AgentToolError.invalidArguments("stdin and secret_handle are mutually exclusive.")
|
||||
}
|
||||
let assessment = CommandRiskClassifier.assess(command)
|
||||
let safe = assessment.risk == .safe
|
||||
return AgentToolPreflight(
|
||||
invocation: checked,
|
||||
safety: safe ? .locallyReadOnly : .requiresAuthorization,
|
||||
reasons: assessment.reasons,
|
||||
// Whether a command may run beside another is a property of
|
||||
// the command, not of `ssh_exec`, and it is decided by the
|
||||
// same locally-proven evidence that just decided `safety`.
|
||||
// A command the classifier cannot vouch for makes its whole
|
||||
// turn serial, so nothing observes the host while it is
|
||||
// being changed.
|
||||
//
|
||||
// The ceiling on what this buys is worth stating: commands
|
||||
// aimed at one host queue behind each other inside
|
||||
// `SSHExecSession` regardless, because a libssh2 session
|
||||
// cannot be driven from two places at once. The win is
|
||||
// several hosts at once, and host work overlapping `fetch`,
|
||||
// `web_search`, and MCP.
|
||||
concurrency: safe ? .parallel : .sequential
|
||||
)
|
||||
var checked = invocation
|
||||
let arguments = try Arguments(invocation)
|
||||
checked.targetHostID = try arguments.uuid("host_id")
|
||||
let command = try arguments.string("command")
|
||||
if arguments.optionalString("stdin") != nil && arguments.optionalString("secret_handle") != nil {
|
||||
throw AgentToolError.invalidArguments("stdin and secret_handle are mutually exclusive.")
|
||||
}
|
||||
let assessment = CommandRiskClassifier.assess(command)
|
||||
let safe = assessment.risk == .safe
|
||||
return AgentToolPreflight(
|
||||
invocation: checked,
|
||||
safety: safe ? .locallyReadOnly : .requiresAuthorization,
|
||||
reasons: assessment.reasons,
|
||||
// Whether a command may run beside another is a property of
|
||||
// the command, not of `ssh_exec`, and it is decided by the
|
||||
// same locally-proven evidence that just decided `safety`.
|
||||
// A command the classifier cannot vouch for makes its whole
|
||||
// turn serial, so nothing observes the host while it is
|
||||
// being changed.
|
||||
//
|
||||
// The ceiling on what this buys is worth stating: commands
|
||||
// aimed at one host queue behind each other inside
|
||||
// `SSHExecSession` regardless, because a libssh2 session
|
||||
// cannot be driven from two places at once. The win is
|
||||
// several hosts at once, and host work overlapping `fetch`,
|
||||
// `web_search`, and MCP.
|
||||
concurrency: safe ? .parallel : .sequential
|
||||
)
|
||||
}
|
||||
|
||||
func execute(
|
||||
_ invocation: AgentToolInvocation, context: AgentToolExecutionContext
|
||||
) async throws -> AgentToolResult {
|
||||
let arguments = try Arguments(invocation)
|
||||
let hostID = try arguments.uuid("host_id")
|
||||
let command = try arguments.string("command")
|
||||
var input = arguments.optionalString("stdin").map { Data($0.utf8) }
|
||||
if let rawHandle = arguments.optionalString("secret_handle") {
|
||||
guard let handleID = UUID(uuidString: rawHandle) else {
|
||||
throw AgentToolError.invalidArguments(
|
||||
"secret_handle must be a handle issued by request_user_secret in this "
|
||||
+ "run. Omit secret_handle and stdin_purpose when the command needs "
|
||||
+ "no secret input; otherwise call request_user_secret again."
|
||||
)
|
||||
}
|
||||
let purpose = try arguments.string("stdin_purpose")
|
||||
let secret: String
|
||||
do {
|
||||
secret = try await context.secretBroker.consume(
|
||||
SecretHandle(id: handleID),
|
||||
matching: SecretBinding(
|
||||
runID: context.runID, toolName: "ssh_exec", hostID: hostID,
|
||||
purpose: purpose
|
||||
)
|
||||
)
|
||||
} catch SecretBrokerError.notFound {
|
||||
throw AgentToolError.invalidArguments(
|
||||
"secret_handle is unavailable or has already been used. Call "
|
||||
+ "request_user_secret again before retrying ssh_exec."
|
||||
)
|
||||
} catch SecretBrokerError.bindingMismatch {
|
||||
throw AgentToolError.invalidArguments(
|
||||
"secret_handle was issued for a different run, host, tool, or purpose. "
|
||||
+ "Call request_user_secret again with matching values."
|
||||
)
|
||||
}
|
||||
input = Data(secret.utf8)
|
||||
}
|
||||
// Live output, so a command that takes a minute is a card
|
||||
// filling in rather than a spinner. Reported, never returned:
|
||||
// the model is owed one result per call and gets it below.
|
||||
let stream = AgentCommandOutputStream(
|
||||
report: AgentToolSupport.reporting(invocation, context.report)
|
||||
let arguments = try Arguments(invocation)
|
||||
let hostID = try arguments.uuid("host_id")
|
||||
let command = try arguments.string("command")
|
||||
var input = arguments.optionalString("stdin").map { Data($0.utf8) }
|
||||
if let rawHandle = arguments.optionalString("secret_handle") {
|
||||
guard let handleID = UUID(uuidString: rawHandle) else {
|
||||
throw AgentToolError.invalidArguments(
|
||||
"secret_handle must be a handle issued by request_user_secret in this "
|
||||
+ "run. Omit secret_handle and stdin_purpose when the command needs "
|
||||
+ "no secret input; otherwise call request_user_secret again."
|
||||
)
|
||||
let cwd = arguments.optionalString("cwd")
|
||||
// A `let` copy, because the task below outlives this call and a
|
||||
// captured `var` would be shared mutable state across it.
|
||||
let stdin = input
|
||||
let deadline = Duration.seconds(
|
||||
arguments.optionalInt("timeout_seconds")
|
||||
?? AgentToolSupport.defaultReportingDeadline
|
||||
)
|
||||
// Connecting is inside the deadline too. A hang is a hang
|
||||
// whether it is DNS, a jump host or a handshake, and a turn that
|
||||
// ends on time is what lets the user be heard at all.
|
||||
let handover = AgentCommandHandover()
|
||||
let running = Task { () async throws -> SSHExecResult in
|
||||
var connection = try await authentication.execConnection(
|
||||
hostID: hostID, invocationID: invocation.id,
|
||||
interaction: context.sshInteraction, pool: pool
|
||||
}
|
||||
let purpose = try arguments.string("stdin_purpose")
|
||||
let secret: String
|
||||
do {
|
||||
secret = try await context.secretBroker.consume(
|
||||
SecretHandle(id: handleID),
|
||||
matching: SecretBinding(
|
||||
runID: context.runID, toolName: "ssh_exec", hostID: hostID,
|
||||
purpose: purpose
|
||||
)
|
||||
var retriedDeadSession = false
|
||||
while true {
|
||||
let session = connection.client
|
||||
// The call may already have given up while this was
|
||||
// connecting. Starting anyway would leave a command
|
||||
// running, printing and finishing with nobody holding
|
||||
// a handle to it.
|
||||
guard retriedDeadSession || handover.beginRunning(on: session) else {
|
||||
throw AgentCommandNotStarted()
|
||||
}
|
||||
context.report(AgentToolResult(
|
||||
callID: invocation.call.id, content: "",
|
||||
metadata: ["presentation_phase": .string("command")]
|
||||
))
|
||||
do {
|
||||
return try await session.execute(
|
||||
command, workingDirectory: cwd, stdin: stdin,
|
||||
timeout: AgentToolSupport.maximumCommandLifetime,
|
||||
outputLimit: 256 * 1_024,
|
||||
onOutput: stream.handler
|
||||
)
|
||||
} catch SSHExecSessionError.channelUnavailable
|
||||
where !retriedDeadSession
|
||||
{
|
||||
// Safe because no command channel opened. The
|
||||
// authentication actor returned with the connection
|
||||
// keeps one-use credentials for this retry, while its
|
||||
// shared registry lease already ended.
|
||||
retriedDeadSession = true
|
||||
context.report(AgentToolResult(
|
||||
callID: invocation.call.id, content: "",
|
||||
metadata: ["presentation_phase": .string("running")]
|
||||
))
|
||||
await pool.disconnect(hostID: hostID, kind: .exec)
|
||||
connection.client = try await authentication.openExec(
|
||||
hostID: hostID, authentication: connection.authentication,
|
||||
pool: pool
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
} catch SecretBrokerError.notFound {
|
||||
throw AgentToolError.invalidArguments(
|
||||
"secret_handle is unavailable or has already been used. Call "
|
||||
+ "request_user_secret again before retrying ssh_exec."
|
||||
)
|
||||
} catch SecretBrokerError.bindingMismatch {
|
||||
throw AgentToolError.invalidArguments(
|
||||
"secret_handle was issued for a different run, host, tool, or purpose. "
|
||||
+ "Call request_user_secret again with matching values."
|
||||
)
|
||||
}
|
||||
input = Data(secret.utf8)
|
||||
}
|
||||
// Live output, so a command that takes a minute is a card
|
||||
// filling in rather than a spinner. Reported, never returned:
|
||||
// the model is owed one result per call and gets it below.
|
||||
let stream = AgentCommandOutputStream(
|
||||
report: AgentToolSupport.reporting(invocation, context.report)
|
||||
)
|
||||
let cwd = arguments.optionalString("cwd")
|
||||
// A `let` copy, because the task below outlives this call and a
|
||||
// captured `var` would be shared mutable state across it.
|
||||
let stdin = input
|
||||
let deadline = Duration.seconds(
|
||||
arguments.optionalInt("timeout_seconds")
|
||||
?? AgentToolSupport.defaultReportingDeadline
|
||||
)
|
||||
// Connecting is inside the deadline too. A hang is a hang
|
||||
// whether it is DNS, a jump host or a handshake, and a turn that
|
||||
// ends on time is what lets the user be heard at all.
|
||||
let handover = AgentCommandHandover()
|
||||
let running = Task { () async throws -> SSHExecResult in
|
||||
var connection = try await authentication.execConnection(
|
||||
hostID: hostID, invocationID: invocation.id,
|
||||
interaction: context.sshInteraction, pool: pool
|
||||
)
|
||||
var retriedDeadSession = false
|
||||
while true {
|
||||
let session = connection.client
|
||||
// The call may already have given up while this was
|
||||
// connecting. Starting anyway would leave a command
|
||||
// running, printing and finishing with nobody holding
|
||||
// a handle to it.
|
||||
guard retriedDeadSession || handover.beginRunning(on: session) else {
|
||||
throw AgentCommandNotStarted()
|
||||
}
|
||||
context.report(
|
||||
AgentToolResult(
|
||||
callID: invocation.call.id, content: "",
|
||||
metadata: ["presentation_phase": .string("command")]
|
||||
))
|
||||
do {
|
||||
return try await session.execute(
|
||||
command, workingDirectory: cwd, stdin: stdin,
|
||||
timeout: AgentToolSupport.maximumCommandLifetime,
|
||||
outputLimit: 256 * 1_024,
|
||||
onOutput: stream.handler
|
||||
)
|
||||
} catch SSHExecSessionError.channelUnavailable
|
||||
where !retriedDeadSession
|
||||
{
|
||||
// Safe because no command channel opened. The
|
||||
// authentication actor returned with the connection
|
||||
// keeps one-use credentials for this retry, while its
|
||||
// shared registry lease already ended.
|
||||
retriedDeadSession = true
|
||||
context.report(
|
||||
AgentToolResult(
|
||||
callID: invocation.call.id, content: "",
|
||||
metadata: ["presentation_phase": .string("running")]
|
||||
))
|
||||
await pool.disconnect(hostID: hostID, kind: .exec)
|
||||
connection.client = try await authentication.openExec(
|
||||
hostID: hostID, authentication: connection.authentication,
|
||||
pool: pool
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard await AgentBackgroundCommands.settled(running, within: deadline) else {
|
||||
guard let session = handover.giveUp() else {
|
||||
// Still connecting, so there is nothing to hand over —
|
||||
// no session, no output, no handle worth giving. The
|
||||
// connection is left to finish into the pool rather than
|
||||
// torn down, so the retry this invites is the fast one.
|
||||
return Self.result(invocation, .object([
|
||||
"status": .string("connecting"),
|
||||
"note": .string(String(
|
||||
guard await AgentBackgroundCommands.settled(running, within: deadline) else {
|
||||
guard let session = handover.giveUp() else {
|
||||
// Still connecting, so there is nothing to hand over —
|
||||
// no session, no output, no handle worth giving. The
|
||||
// connection is left to finish into the pool rather than
|
||||
// torn down, so the retry this invites is the fast one.
|
||||
return Self.result(
|
||||
invocation,
|
||||
.object([
|
||||
"status": .string("connecting"),
|
||||
"note": .string(
|
||||
String(
|
||||
localized: """
|
||||
The host has not finished answering after \
|
||||
\(deadline.components.seconds)s, so the command has not \
|
||||
@@ -380,33 +421,36 @@ nonisolated struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
||||
running this again shortly is likely to be quick.
|
||||
"""
|
||||
)),
|
||||
]), truncated: false)
|
||||
}
|
||||
// It outlived its turn. The session goes with it — the
|
||||
// channel is still being pumped, so it can be neither shared
|
||||
// nor swept — and the next command for this host opens a
|
||||
// fresh one.
|
||||
await pool.detachExec(hostID: hostID, session: session)
|
||||
let handle = await background.adopt(
|
||||
hostID: hostID, command: command, stream: stream,
|
||||
task: running, session: session
|
||||
)
|
||||
let captured = stream.captured
|
||||
return Self.result(invocation, .object([
|
||||
"status": .string("running"),
|
||||
"handle": .string(handle.uuidString),
|
||||
"stdout": .string(captured.standardOut),
|
||||
"stderr": .string(captured.standardError),
|
||||
"note": .string(String(
|
||||
]), truncated: false)
|
||||
}
|
||||
// It outlived its turn. The session goes with it — the
|
||||
// channel is still being pumped, so it can be neither shared
|
||||
// nor swept — and the next command for this host opens a
|
||||
// fresh one.
|
||||
await pool.detachExec(hostID: hostID, session: session)
|
||||
let handle = await background.adopt(
|
||||
hostID: hostID, command: command, stream: stream,
|
||||
task: running, session: session
|
||||
)
|
||||
let captured = stream.captured
|
||||
return Self.result(
|
||||
invocation,
|
||||
.object([
|
||||
"status": .string("running"),
|
||||
"handle": .string(handle.uuidString),
|
||||
"stdout": .string(captured.standardOut),
|
||||
"stderr": .string(captured.standardError),
|
||||
"note": .string(
|
||||
String(
|
||||
localized: """
|
||||
Still running after \(deadline.components.seconds)s. The output \
|
||||
above is what it has printed so far. Call ssh_wait with this \
|
||||
handle for the rest; leaving it unasked-about abandons it.
|
||||
"""
|
||||
)),
|
||||
]))
|
||||
}
|
||||
return AgentToolSupport.exitedResult(invocation, try await running.value)
|
||||
]))
|
||||
}
|
||||
return AgentToolSupport.exitedResult(invocation, try await running.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,15 +466,17 @@ nonisolated enum SSHCommandPresentation {
|
||||
return F.objectItems(input) { object in
|
||||
var items: [AgentToolDetail.Item] = []
|
||||
if let status = object["status"]?.stringValue {
|
||||
items.append(F.field(
|
||||
"Status", F.statusLabel(status, locale: input.locale), locale: input.locale
|
||||
))
|
||||
items.append(
|
||||
F.field(
|
||||
"Status", F.statusLabel(status, locale: input.locale), locale: input.locale
|
||||
))
|
||||
}
|
||||
if let code = object["exit_code"]?.integerValue {
|
||||
items.append(F.field("Exit code", String(code), locale: input.locale, monospaced: true))
|
||||
}
|
||||
F.appendField(&items, "Signal", object["signal"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
F.appendField(
|
||||
&items, "Signal", object["signal"]?.stringValue,
|
||||
locale: input.locale, monospaced: true)
|
||||
let stdout = object["stdout"]?.stringValue ?? ""
|
||||
let stderr = object["stderr"]?.stringValue ?? ""
|
||||
if !stdout.isEmpty { items.append(F.text("Standard output", stdout, locale: input.locale)) }
|
||||
@@ -460,79 +506,85 @@ nonisolated extension ListHostsTool {
|
||||
) -> [AgentToolDetail.Item] {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
return F.objectItems(input) { object in
|
||||
let hostValues = object["hosts"]?.arrayValue ?? []
|
||||
let groupValues = object["groups"]?.arrayValue ?? []
|
||||
var groups: [HostGroupDetailProjection] = []
|
||||
let hostValues = object["hosts"]?.arrayValue ?? []
|
||||
let groupValues = object["groups"]?.arrayValue ?? []
|
||||
var groups: [HostGroupDetailProjection] = []
|
||||
|
||||
func walk(_ rows: [AgentJSONValue], prefix: String = "") {
|
||||
for row in rows {
|
||||
guard let fields = row.objectValue else { continue }
|
||||
let name = F.nonempty(fields["name"]?.stringValue)
|
||||
?? F.localized("Unnamed group", locale: input.locale)
|
||||
let computed = prefix.isEmpty ? name : "\(prefix) / \(name)"
|
||||
let title = F.nonempty(fields["path"]?.stringValue) ?? computed
|
||||
if let id = fields["id"]?.stringValue {
|
||||
groups.append(.init(id: id, title: title))
|
||||
func walk(_ rows: [AgentJSONValue], prefix: String = "") {
|
||||
for row in rows {
|
||||
guard let fields = row.objectValue else { continue }
|
||||
let name =
|
||||
F.nonempty(fields["name"]?.stringValue)
|
||||
?? F.localized("Unnamed group", locale: input.locale)
|
||||
let computed = prefix.isEmpty ? name : "\(prefix) / \(name)"
|
||||
let title = F.nonempty(fields["path"]?.stringValue) ?? computed
|
||||
if let id = fields["id"]?.stringValue {
|
||||
groups.append(.init(id: id, title: title))
|
||||
}
|
||||
walk(fields["children"]?.arrayValue ?? [], prefix: computed)
|
||||
}
|
||||
walk(fields["children"]?.arrayValue ?? [], prefix: computed)
|
||||
}
|
||||
}
|
||||
walk(groupValues)
|
||||
walk(groupValues)
|
||||
|
||||
var grouped: [String: [AgentToolDetail.ListRow]] = [:]
|
||||
var ungrouped: [AgentToolDetail.ListRow] = []
|
||||
var temporary: [AgentToolDetail.ListRow] = []
|
||||
var connected = 0
|
||||
for hostValue in hostValues {
|
||||
guard let host = hostValue.objectValue else { continue }
|
||||
if host["connection_state"]?.stringValue == "connected" { connected += 1 }
|
||||
let row = hostDetailRow(host, locale: input.locale)
|
||||
if host["temporary"]?.boolValue == true {
|
||||
temporary.append(row)
|
||||
} else if let groupID = host["group_id"]?.stringValue,
|
||||
groups.contains(where: { $0.id == groupID })
|
||||
{
|
||||
grouped[groupID, default: []].append(row)
|
||||
} else {
|
||||
ungrouped.append(row)
|
||||
var grouped: [String: [AgentToolDetail.ListRow]] = [:]
|
||||
var ungrouped: [AgentToolDetail.ListRow] = []
|
||||
var temporary: [AgentToolDetail.ListRow] = []
|
||||
var connected = 0
|
||||
for hostValue in hostValues {
|
||||
guard let host = hostValue.objectValue else { continue }
|
||||
if host["connection_state"]?.stringValue == "connected" { connected += 1 }
|
||||
let row = hostDetailRow(host, locale: input.locale)
|
||||
if host["temporary"]?.boolValue == true {
|
||||
temporary.append(row)
|
||||
} else if let groupID = host["group_id"]?.stringValue,
|
||||
groups.contains(where: { $0.id == groupID })
|
||||
{
|
||||
grouped[groupID, default: []].append(row)
|
||||
} else {
|
||||
ungrouped.append(row)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard !hostValues.isEmpty || !groups.isEmpty else {
|
||||
return [.message(F.localized("No hosts available", locale: input.locale), .secondary)]
|
||||
}
|
||||
func hostCount(_ count: Int) -> String {
|
||||
String(localized: "\(count) hosts", locale: input.locale)
|
||||
}
|
||||
var summary = [hostCount(hostValues.count)]
|
||||
if connected > 0 {
|
||||
summary.append(String(localized: "\(connected) connected", locale: input.locale))
|
||||
}
|
||||
if !temporary.isEmpty {
|
||||
summary.append(String(localized: "\(temporary.count) temporary", locale: input.locale))
|
||||
}
|
||||
var sections = groups.map { group in
|
||||
let rows = grouped[group.id] ?? []
|
||||
return AgentToolDetail.ListSection(
|
||||
title: "\(group.title) · \(hostCount(rows.count))", rows: rows
|
||||
)
|
||||
}
|
||||
if !groups.isEmpty || !ungrouped.isEmpty {
|
||||
sections.append(.init(
|
||||
title: "\(F.localized("Ungrouped", locale: input.locale)) · \(hostCount(ungrouped.count))",
|
||||
rows: ungrouped
|
||||
))
|
||||
}
|
||||
if !temporary.isEmpty {
|
||||
sections.append(.init(
|
||||
title: "\(F.localized("Temporary", locale: input.locale)) · \(hostCount(temporary.count))",
|
||||
rows: temporary
|
||||
))
|
||||
}
|
||||
return [.groupedList(.init(
|
||||
summary: summary.joined(separator: " · "), sections: sections,
|
||||
isScrollable: hostValues.count > 12
|
||||
))]
|
||||
guard !hostValues.isEmpty || !groups.isEmpty else {
|
||||
return [.message(F.localized("No hosts available", locale: input.locale), .secondary)]
|
||||
}
|
||||
func hostCount(_ count: Int) -> String {
|
||||
String(localized: "\(count) hosts", locale: input.locale)
|
||||
}
|
||||
var summary = [hostCount(hostValues.count)]
|
||||
if connected > 0 {
|
||||
summary.append(String(localized: "\(connected) connected", locale: input.locale))
|
||||
}
|
||||
if !temporary.isEmpty {
|
||||
summary.append(String(localized: "\(temporary.count) temporary", locale: input.locale))
|
||||
}
|
||||
var sections = groups.map { group in
|
||||
let rows = grouped[group.id] ?? []
|
||||
return AgentToolDetail.ListSection(
|
||||
title: "\(group.title) · \(hostCount(rows.count))", rows: rows
|
||||
)
|
||||
}
|
||||
if !groups.isEmpty || !ungrouped.isEmpty {
|
||||
sections.append(
|
||||
.init(
|
||||
title: "\(F.localized("Ungrouped", locale: input.locale)) · \(hostCount(ungrouped.count))",
|
||||
rows: ungrouped
|
||||
))
|
||||
}
|
||||
if !temporary.isEmpty {
|
||||
sections.append(
|
||||
.init(
|
||||
title: "\(F.localized("Temporary", locale: input.locale)) · \(hostCount(temporary.count))",
|
||||
rows: temporary
|
||||
))
|
||||
}
|
||||
return [
|
||||
.groupedList(
|
||||
.init(
|
||||
summary: summary.joined(separator: " · "), sections: sections,
|
||||
isScrollable: hostValues.count > 12
|
||||
))
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,7 +593,8 @@ nonisolated extension ListHostsTool {
|
||||
) -> AgentToolDetail.ListRow {
|
||||
typealias F = AgentToolDetailFormatting
|
||||
let address = F.nonempty(host["address"]?.stringValue)
|
||||
let name = F.nonempty(host["display_name"]?.stringValue) ?? address
|
||||
let name =
|
||||
F.nonempty(host["display_name"]?.stringValue) ?? address
|
||||
?? F.localized("Unnamed host", locale: locale)
|
||||
let state = connectionDetailState(host["connection_state"]?.stringValue ?? "", locale: locale)
|
||||
let subtitle = [address ?? F.localized("Address unavailable", locale: locale), state.label]
|
||||
@@ -562,11 +615,12 @@ nonisolated extension ListHostsTool {
|
||||
case "reconnecting": (F.localized("Reconnecting", locale: locale), AgentToolDetail.Tint.warning)
|
||||
case "disconnected": (F.localized("Not connected", locale: locale), AgentToolDetail.Tint.secondary)
|
||||
case "failed": (F.localized("Failed", locale: locale), AgentToolDetail.Tint.failure)
|
||||
default: (
|
||||
F.nonempty(F.humanized(raw))?.capitalized
|
||||
?? F.localized("Unknown", locale: locale),
|
||||
AgentToolDetail.Tint.secondary
|
||||
)
|
||||
default:
|
||||
(
|
||||
F.nonempty(F.humanized(raw))?.capitalized
|
||||
?? F.localized("Unknown", locale: locale),
|
||||
AgentToolDetail.Tint.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,22 +29,22 @@ nonisolated struct AgentHostListing: Hashable, Sendable {
|
||||
/// never serialized into a tool result.
|
||||
@MainActor
|
||||
final class AgentHostResolver: Sendable {
|
||||
typealias HostKeyDiscovering = @Sendable (
|
||||
_ hostID: Host.ID, _ host: String, _ port: Int,
|
||||
_ proxy: SSHTransportProxy?, _ jumps: [SSHRouteHopConfiguration]
|
||||
) async throws -> SSHHostKeyObservation
|
||||
typealias HostKeyDiscovering =
|
||||
@Sendable (
|
||||
_ hostID: Host.ID, _ host: String, _ port: Int,
|
||||
_ proxy: SSHTransportProxy?, _ jumps: [SSHRouteHopConfiguration]
|
||||
) async throws -> SSHHostKeyObservation
|
||||
|
||||
private let store: HostLibraryModel
|
||||
private let discoverHostKey: HostKeyDiscovering?
|
||||
private var temporaryTargets: @MainActor @Sendable () -> [AgentHostTarget]
|
||||
private var temporaryConfiguration:
|
||||
@MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration?
|
||||
private var temporaryConfiguration: @MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration?
|
||||
|
||||
init(
|
||||
store: HostLibraryModel,
|
||||
temporaryTargets: @escaping @MainActor @Sendable () -> [AgentHostTarget] = { [] },
|
||||
temporaryConfiguration: @escaping
|
||||
@MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration? = { _ in nil },
|
||||
temporaryConfiguration: @escaping @MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration? = { _ in nil
|
||||
},
|
||||
discoverHostKey: HostKeyDiscovering? = nil
|
||||
) {
|
||||
self.store = store
|
||||
@@ -55,8 +55,7 @@ final class AgentHostResolver: Sendable {
|
||||
|
||||
func configureTemporaryTargets(
|
||||
targets: @escaping @MainActor @Sendable () -> [AgentHostTarget],
|
||||
configuration: @escaping
|
||||
@MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration?
|
||||
configuration: @escaping @MainActor @Sendable (UUID) throws -> SSHConnectionConfiguration?
|
||||
) {
|
||||
temporaryTargets = targets
|
||||
temporaryConfiguration = configuration
|
||||
@@ -74,12 +73,13 @@ final class AgentHostResolver: Sendable {
|
||||
}
|
||||
return AgentHostListing(
|
||||
groups: groupTree(paths: paths),
|
||||
hosts: saved + temporaryTargets().map { target in
|
||||
var target = target
|
||||
target.groupID = nil
|
||||
target.groupPath = nil
|
||||
return target
|
||||
}
|
||||
hosts: saved
|
||||
+ temporaryTargets().map { target in
|
||||
var target = target
|
||||
target.groupID = nil
|
||||
target.groupPath = nil
|
||||
return target
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -123,10 +123,11 @@ final class AgentHostResolver: Sendable {
|
||||
children: children
|
||||
)
|
||||
}
|
||||
let roots = ordered(store.groups.filter { group in
|
||||
guard let parentID = group.parentID else { return true }
|
||||
return all[parentID] == nil
|
||||
})
|
||||
let roots = ordered(
|
||||
store.groups.filter { group in
|
||||
guard let parentID = group.parentID else { return true }
|
||||
return all[parentID] == nil
|
||||
})
|
||||
var result = roots.map { build($0, ancestors: []) }
|
||||
for group in ordered(store.groups) where !emitted.contains(group.id) {
|
||||
result.append(build(group, ancestors: []))
|
||||
@@ -188,7 +189,8 @@ final class AgentHostResolver: Sendable {
|
||||
for: node, submissions: submissions, forcedHostID: forcedHostID
|
||||
) {
|
||||
let existing = submissions[node.id]
|
||||
let identity = existing?.identityID.flatMap(store.identity)
|
||||
let identity =
|
||||
existing?.identityID.flatMap(store.identity)
|
||||
?? store.identity(for: node)
|
||||
let request = AgentSSHAuthenticationRequest(
|
||||
invocationID: invocationID, hostID: node.id,
|
||||
@@ -302,9 +304,11 @@ final class AgentHostResolver: Sendable {
|
||||
configuration.hostID, configuration.host, configuration.port,
|
||||
configuration.proxy, configuration.jumps
|
||||
)
|
||||
displayName = temporaryTargets().first { $0.id == configuration.hostID }?.displayName
|
||||
displayName =
|
||||
temporaryTargets().first { $0.id == configuration.hostID }?.displayName
|
||||
?? configuration.host
|
||||
address = configuration.port == 22
|
||||
address =
|
||||
configuration.port == 22
|
||||
? configuration.host : "\(configuration.host):\(configuration.port)"
|
||||
trustedCount = configuration.expectedHostKeys.count
|
||||
configuration.expectedHostKeys.insert(observation.identity)
|
||||
@@ -796,7 +800,10 @@ actor AgentConnectionPool {
|
||||
}
|
||||
}
|
||||
|
||||
private struct Key: Hashable { var hostID: UUID; var kind: Kind }
|
||||
private struct Key: Hashable {
|
||||
var hostID: UUID
|
||||
var kind: Kind
|
||||
}
|
||||
|
||||
private struct Entry {
|
||||
var client: Client
|
||||
@@ -864,9 +871,10 @@ actor AgentConnectionPool {
|
||||
func exec(
|
||||
hostID: UUID, configuration override: ConfigurationResolving? = nil
|
||||
) async throws -> any SSHExecSessionProtocol {
|
||||
guard case .exec(let session) = try await resolve(
|
||||
hostID: hostID, kind: .exec, configuration: override
|
||||
)
|
||||
guard
|
||||
case .exec(let session) = try await resolve(
|
||||
hostID: hostID, kind: .exec, configuration: override
|
||||
)
|
||||
else { throw AgentToolError.invalidHost }
|
||||
return session
|
||||
}
|
||||
@@ -874,9 +882,10 @@ actor AgentConnectionPool {
|
||||
func sftp(
|
||||
hostID: UUID, configuration override: ConfigurationResolving? = nil
|
||||
) async throws -> any SFTPClientProtocol {
|
||||
guard case .sftp(let client) = try await resolve(
|
||||
hostID: hostID, kind: .sftp, configuration: override
|
||||
)
|
||||
guard
|
||||
case .sftp(let client) = try await resolve(
|
||||
hostID: hostID, kind: .sftp, configuration: override
|
||||
)
|
||||
else { throw AgentToolError.invalidHost }
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -198,12 +198,13 @@ final class AppCommandRegistry {
|
||||
),
|
||||
]
|
||||
if canShowAssistant {
|
||||
commands.append(AppCommandDescriptor(
|
||||
id: "action.show-assistant", title: String(localized: "Show Assistant in Sidebar"),
|
||||
subtitle: String(localized: "Action"), symbol: "sidebar.left",
|
||||
section: .actions,
|
||||
keywords: ["sidebar", "assistant", "context", "details"], target: .showAssistant
|
||||
))
|
||||
commands.append(
|
||||
AppCommandDescriptor(
|
||||
id: "action.show-assistant", title: String(localized: "Show Assistant in Sidebar"),
|
||||
subtitle: String(localized: "Action"), symbol: "sidebar.left",
|
||||
section: .actions,
|
||||
keywords: ["sidebar", "assistant", "context", "details"], target: .showAssistant
|
||||
))
|
||||
}
|
||||
return commands
|
||||
}
|
||||
|
||||
@@ -331,8 +331,8 @@ final class ConnectionJourney {
|
||||
}
|
||||
}
|
||||
|
||||
private extension ConnectionPurpose {
|
||||
var logName: String {
|
||||
extension ConnectionPurpose {
|
||||
fileprivate var logName: String {
|
||||
switch self {
|
||||
case .sshSession: "SSH session"
|
||||
case .sftpSession: "SFTP session"
|
||||
|
||||
@@ -23,11 +23,12 @@ final class RemoteSessionConnectionController {
|
||||
func trustHostKey(
|
||||
_ observation: SSHHostKeyObservation, using store: any RemoteSessionHostStore
|
||||
) {
|
||||
store.trust(KnownHostEntry(
|
||||
hostname: observation.hostname, port: observation.port,
|
||||
algorithm: observation.algorithm, fingerprint: observation.fingerprint,
|
||||
addedAt: Date()
|
||||
))
|
||||
store.trust(
|
||||
KnownHostEntry(
|
||||
hostname: observation.hostname, port: observation.port,
|
||||
algorithm: observation.algorithm, fingerprint: observation.fingerprint,
|
||||
addedAt: Date()
|
||||
))
|
||||
if let pendingHostKeyHostID { temporaryHostKeys[pendingHostKeyHostID] = nil }
|
||||
clearHostKeyPrompt()
|
||||
journey.resume()
|
||||
|
||||
@@ -85,10 +85,13 @@ nonisolated enum TermindSSHError: LocalizedError, Sendable {
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .socket(let message), .proxy(let message), .route(let message),
|
||||
.library(let message), .authenticationFailed(let message): message
|
||||
.library(let message), .authenticationFailed(let message):
|
||||
message
|
||||
case .routeHost(_, _, _, _, let message): message
|
||||
case .hostKeyChanged(let expected, let received):
|
||||
String(localized: "The host key changed. Expected one of \(expected.joined(separator: ", ")), received \(received).")
|
||||
String(
|
||||
localized:
|
||||
"The host key changed. Expected one of \(expected.joined(separator: ", ")), received \(received).")
|
||||
case .missingCredential: String(localized: "The selected identity has no usable password or private key.")
|
||||
case .keyPassphraseRequired: String(localized: "Enter the Passphrase for the selected key.")
|
||||
case .missingUsername: String(localized: "The selected host has no login username.")
|
||||
@@ -137,7 +140,8 @@ nonisolated enum TermindSSHError: LocalizedError, Sendable {
|
||||
case .socket, .library, .disconnected: true
|
||||
case .proxy, .route, .routeHost, .authenticationFailed, .hostKeyChanged,
|
||||
.missingCredential, .keyPassphraseRequired, .missingUsername,
|
||||
.untrustedHost, .mismatchedHostKey: false
|
||||
.untrustedHost, .mismatchedHostKey:
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,9 +129,11 @@ final class SessionReconnectController {
|
||||
retryTask = nil
|
||||
}
|
||||
}
|
||||
nonisolated
|
||||
|
||||
private nonisolated extension Duration {
|
||||
var timeInterval: TimeInterval {
|
||||
extension Duration
|
||||
{
|
||||
fileprivate var timeInterval: TimeInterval {
|
||||
let components = self.components
|
||||
return TimeInterval(components.seconds)
|
||||
+ TimeInterval(components.attoseconds) / 1_000_000_000_000_000_000
|
||||
|
||||
@@ -56,7 +56,8 @@ final class OutboxRunner {
|
||||
try store.markSucceeded(job.id)
|
||||
} catch {
|
||||
let attempt = job.attemptCount + 1
|
||||
let retryAt = attempt >= maximumAttempts
|
||||
let retryAt =
|
||||
attempt >= maximumAttempts
|
||||
? nil
|
||||
: Date().addingTimeInterval(pow(2, Double(attempt)))
|
||||
try store.markFailed(
|
||||
|
||||
@@ -63,7 +63,8 @@ final class AIConfigurationModel {
|
||||
self.mcpServers = mcpServers
|
||||
self.roles = roles
|
||||
self.lastSelectedModel = lastSelectedModel
|
||||
self.lastSelectedPermissionMode = permissionDefaults
|
||||
self.lastSelectedPermissionMode =
|
||||
permissionDefaults
|
||||
.flatMap { $0.string(forKey: SettingsKey.agentPermissionMode) }
|
||||
.flatMap(AgentPermissionMode.init(rawValue:))
|
||||
?? lastSelectedPermissionMode
|
||||
@@ -185,9 +186,10 @@ final class AIConfigurationModel {
|
||||
}
|
||||
|
||||
func hasMCPNamespaceConflict(_ namespaceID: String, excluding id: MCPServer.ID) -> Bool {
|
||||
!namespaceID.isEmpty && mcpServers.contains {
|
||||
$0.id != id && $0.effectiveNamespaceID == namespaceID
|
||||
}
|
||||
!namespaceID.isEmpty
|
||||
&& mcpServers.contains {
|
||||
$0.id != id && $0.effectiveNamespaceID == namespaceID
|
||||
}
|
||||
}
|
||||
|
||||
/// Every tool the model may be offered: enabled tools on enabled servers.
|
||||
|
||||
@@ -40,17 +40,21 @@ final class AssistantConversationStore {
|
||||
}
|
||||
|
||||
private func forkRoot(for title: String, sourceID: UUID) -> String {
|
||||
guard let range = title.range(
|
||||
of: #" \([0-9]+\)$"#, options: .regularExpression
|
||||
) else { return title }
|
||||
guard
|
||||
let range = title.range(
|
||||
of: #" \([0-9]+\)$"#, options: .regularExpression
|
||||
)
|
||||
else { return title }
|
||||
let candidate = String(title[..<range.lowerBound])
|
||||
let belongsToExistingSequence = conversations.contains { conversation in
|
||||
guard conversation.id != sourceID else { return false }
|
||||
let other = conversation.derivedTitle
|
||||
if other == candidate { return true }
|
||||
guard let suffix = other.range(
|
||||
of: #" \([0-9]+\)$"#, options: .regularExpression
|
||||
) else { return false }
|
||||
guard
|
||||
let suffix = other.range(
|
||||
of: #" \([0-9]+\)$"#, options: .regularExpression
|
||||
)
|
||||
else { return false }
|
||||
return other[..<suffix.lowerBound] == candidate
|
||||
}
|
||||
return belongsToExistingSequence ? candidate : title
|
||||
@@ -108,22 +112,30 @@ final class AssistantConversationStore {
|
||||
// Empty is "never summarized", not a name of nothing — `derivedTitle`
|
||||
// has to be able to tell those apart to fall back.
|
||||
conversation.title = value.title.isEmpty ? nil : value.title
|
||||
conversation.customTitle = value.metadata.customTitle.isEmpty
|
||||
conversation.customTitle =
|
||||
value.metadata.customTitle.isEmpty
|
||||
? nil : value.metadata.customTitle
|
||||
conversation.isPinned = value.metadata.isPinned
|
||||
conversation.archivedAt = value.metadata.archivedAt
|
||||
conversation.toolCards = value.toolCards.map { card in
|
||||
let state: AgentToolCard.State
|
||||
if card.result?.isInterrupted == true { state = .interrupted }
|
||||
else if card.result?.isError == true { state = .failed }
|
||||
else if card.result != nil || card.state == "finished" { state = .completed }
|
||||
if card.result?.isInterrupted == true {
|
||||
state = .interrupted
|
||||
} else if card.result?.isError == true {
|
||||
state = .failed
|
||||
} else if card.result != nil || card.state == "finished" {
|
||||
state = .completed
|
||||
}
|
||||
// Not `.running`, which is what this used to restore to. Every
|
||||
// unfinished run is marked interrupted at launch, so a card
|
||||
// that was mid-flight when the process died is not running
|
||||
// now and never will be — it came back as a spinner that
|
||||
// nothing would ever stop.
|
||||
else if card.state == "started" { state = .interrupted }
|
||||
else { state = .proposed }
|
||||
else if card.state == "started" {
|
||||
state = .interrupted
|
||||
} else {
|
||||
state = .proposed
|
||||
}
|
||||
return AgentToolCard(
|
||||
invocation: card.invocation, descriptor: card.descriptor,
|
||||
state: state, result: card.result, review: card.review
|
||||
@@ -187,20 +199,23 @@ final class AssistantLibraryModel {
|
||||
var pinned = pinnedConversations.filter { $0.id != id }
|
||||
var chats = activeConversations.filter { $0.id != id }
|
||||
if toPinned {
|
||||
let index = nextID.flatMap { next in pinned.firstIndex { $0.id == next } }
|
||||
let index =
|
||||
nextID.flatMap { next in pinned.firstIndex { $0.id == next } }
|
||||
?? pinned.endIndex
|
||||
pinned.insert(source, at: index)
|
||||
} else {
|
||||
let index = nextID.flatMap { next in chats.firstIndex { $0.id == next } }
|
||||
let index =
|
||||
nextID.flatMap { next in chats.firstIndex { $0.id == next } }
|
||||
?? chats.endIndex
|
||||
chats.insert(source, at: index)
|
||||
}
|
||||
|
||||
return pinned.enumerated().map {
|
||||
StoredAgentConversationOrder(id: $0.element.id, isPinned: true, sortIndex: Double($0.offset))
|
||||
} + chats.enumerated().map {
|
||||
StoredAgentConversationOrder(id: $0.element.id, isPinned: false, sortIndex: Double($0.offset))
|
||||
}
|
||||
+ chats.enumerated().map {
|
||||
StoredAgentConversationOrder(id: $0.element.id, isPinned: false, sortIndex: Double($0.offset))
|
||||
}
|
||||
}
|
||||
|
||||
func applyOrder(_ updates: [StoredAgentConversationOrder]) {
|
||||
@@ -323,10 +338,11 @@ final class AssistantLibraryModel {
|
||||
/// search field is not a way to read them.
|
||||
func search(_ query: String, scope: SearchScope) -> [ConversationModel] {
|
||||
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let candidates: [ConversationModel] = switch scope {
|
||||
case .active: pinnedConversations + activeConversations
|
||||
case .archived: archivedConversations
|
||||
}
|
||||
let candidates: [ConversationModel] =
|
||||
switch scope {
|
||||
case .active: pinnedConversations + activeConversations
|
||||
case .archived: archivedConversations
|
||||
}
|
||||
guard !trimmed.isEmpty else { return candidates }
|
||||
return candidates.filter { $0.matches(trimmed) }
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ final class ConnectionHistoryModel {
|
||||
entries: [ConnectionHistoryEntry] = [],
|
||||
repository: (any ConnectionHistoryRepository)? = nil
|
||||
) {
|
||||
self.repository = repository
|
||||
self.repository =
|
||||
repository
|
||||
?? InMemoryConnectionHistoryRepository(entries: entries)
|
||||
}
|
||||
|
||||
@@ -66,8 +67,7 @@ final class ConnectionHistoryModel {
|
||||
cachedEntries = nil
|
||||
storageError = nil
|
||||
revision &+= 1
|
||||
}
|
||||
catch { storageError = error.localizedDescription }
|
||||
} catch { storageError = error.localizedDescription }
|
||||
}
|
||||
|
||||
func clear(for hostID: Host.ID) {
|
||||
@@ -76,8 +76,7 @@ final class ConnectionHistoryModel {
|
||||
cachedEntries = nil
|
||||
storageError = nil
|
||||
revision &+= 1
|
||||
}
|
||||
catch { storageError = error.localizedDescription }
|
||||
} catch { storageError = error.localizedDescription }
|
||||
}
|
||||
|
||||
func clearAll() {
|
||||
@@ -86,7 +85,6 @@ final class ConnectionHistoryModel {
|
||||
cachedEntries = []
|
||||
storageError = nil
|
||||
revision &+= 1
|
||||
}
|
||||
catch { storageError = error.localizedDescription }
|
||||
} catch { storageError = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
@@ -282,9 +282,10 @@ final class HostLibraryModel {
|
||||
return String(localized: "No username")
|
||||
}
|
||||
let hasAvailablePassword = passwordAvailability(for: identity) == .available
|
||||
let hasUsableKey = identity.keyID.flatMap { key($0) }.map {
|
||||
keyAvailability(for: $0) == .available
|
||||
} == true
|
||||
let hasUsableKey =
|
||||
identity.keyID.flatMap { key($0) }.map {
|
||||
keyAvailability(for: $0) == .available
|
||||
} == true
|
||||
guard hasAvailablePassword || hasUsableKey else {
|
||||
return String(localized: "No saved credential")
|
||||
}
|
||||
@@ -899,7 +900,10 @@ final class HostLibraryModel {
|
||||
if previousStorage != updated.passwordStorage, !previousReference.isEmpty {
|
||||
guard let oldPassword else {
|
||||
throw TermindSSHError.library(
|
||||
String(localized: "The saved password is unavailable on this Mac. Enter it again before changing storage.")
|
||||
String(
|
||||
localized:
|
||||
"The saved password is unavailable on this Mac. Enter it again before changing storage."
|
||||
)
|
||||
)
|
||||
}
|
||||
try keychain.setString(
|
||||
@@ -964,7 +968,8 @@ final class HostLibraryModel {
|
||||
// The newly committed destination stays authoritative. Retaining
|
||||
// the source is safer than rolling a successful save backward.
|
||||
storageError = String(
|
||||
localized: "The password was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
|
||||
localized:
|
||||
"The password was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1002,7 +1007,8 @@ final class HostLibraryModel {
|
||||
do {
|
||||
password =
|
||||
identity.credentialRef.isEmpty
|
||||
? nil : try keychain.string(
|
||||
? nil
|
||||
: try keychain.string(
|
||||
for: identity.credentialRef, storage: identity.passwordStorage
|
||||
)
|
||||
try keychain.removePassword(
|
||||
@@ -1053,9 +1059,10 @@ final class HostLibraryModel {
|
||||
_ = try SSHUserCertificate.parse(key.certificate, matching: key.publicKey)
|
||||
}
|
||||
let keySnapshot = keys
|
||||
let changesLocation = previousKey.map {
|
||||
$0.storage != key.storage || $0.credentialRef != key.credentialRef
|
||||
} ?? false
|
||||
let changesLocation =
|
||||
previousKey.map {
|
||||
$0.storage != key.storage || $0.credentialRef != key.credentialRef
|
||||
} ?? false
|
||||
if changesLocation,
|
||||
previousKey?.storage == .secureEnclave || key.storage == .secureEnclave
|
||||
{
|
||||
@@ -1063,7 +1070,8 @@ final class HostLibraryModel {
|
||||
String(localized: "Secure Enclave keys cannot be moved to another storage location.")
|
||||
)
|
||||
}
|
||||
let previousCredential = try (credential != nil || changesLocation)
|
||||
let previousCredential =
|
||||
try (credential != nil || changesLocation)
|
||||
? previousKey.flatMap { try keyCredential(for: $0) }
|
||||
: nil
|
||||
let destinationCredential = credential ?? (changesLocation ? previousCredential : nil)
|
||||
@@ -1113,7 +1121,8 @@ final class HostLibraryModel {
|
||||
)
|
||||
} catch {
|
||||
storageError = String(
|
||||
localized: "The key was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
|
||||
localized:
|
||||
"The key was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1519,11 +1528,12 @@ final class HostLibraryModel {
|
||||
@discardableResult
|
||||
private func persistCore() -> Bool {
|
||||
do {
|
||||
try vault.save(VaultSnapshot(
|
||||
hosts: hosts, groups: groups, identities: identities,
|
||||
keys: keys, knownHosts: knownHosts, snippets: snippets,
|
||||
snippetPackages: snippetPackages, portForwardings: portForwardings
|
||||
))
|
||||
try vault.save(
|
||||
VaultSnapshot(
|
||||
hosts: hosts, groups: groups, identities: identities,
|
||||
keys: keys, knownHosts: knownHosts, snippets: snippets,
|
||||
snippetPackages: snippetPackages, portForwardings: portForwardings
|
||||
))
|
||||
storageError = nil
|
||||
return true
|
||||
} catch {
|
||||
|
||||
@@ -47,12 +47,18 @@ final class SFTPHistoryModel {
|
||||
}
|
||||
|
||||
func delete(_ id: SFTPHistoryEntry.ID) {
|
||||
do { try repository.delete(id); storageError = nil; revision &+= 1 }
|
||||
catch { storageError = error.localizedDescription }
|
||||
do {
|
||||
try repository.delete(id)
|
||||
storageError = nil
|
||||
revision &+= 1
|
||||
} catch { storageError = error.localizedDescription }
|
||||
}
|
||||
|
||||
func clear(for hostID: Host.ID) {
|
||||
do { try repository.clear(for: hostID); storageError = nil; revision &+= 1 }
|
||||
catch { storageError = error.localizedDescription }
|
||||
do {
|
||||
try repository.clear(for: hostID)
|
||||
storageError = nil
|
||||
revision &+= 1
|
||||
} catch { storageError = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,13 @@ final class ToolAuditModel {
|
||||
repository: (any ToolAuditRepository)? = nil,
|
||||
hostName: @escaping (Host.ID) -> String? = { _ in nil }
|
||||
) {
|
||||
self.repository = repository ?? InMemoryToolAuditRepository(
|
||||
entries: Array(
|
||||
entries.sorted { $0.startedAt > $1.startedAt }.prefix(Self.globalLimit)
|
||||
self.repository =
|
||||
repository
|
||||
?? InMemoryToolAuditRepository(
|
||||
entries: Array(
|
||||
entries.sorted { $0.startedAt > $1.startedAt }.prefix(Self.globalLimit)
|
||||
)
|
||||
)
|
||||
)
|
||||
self.hostName = hostName
|
||||
recoverInterruptedCalls()
|
||||
}
|
||||
|
||||
@@ -97,10 +97,11 @@ final class MCPServerTools {
|
||||
groups: [.userInteraction],
|
||||
secretConsumerTools: [TermindAgentPolicy.sshExecToolName]
|
||||
),
|
||||
additional: TermindAgentTools.tools(in: TermindAgentToolEnvironment(
|
||||
hosts: hosts, connections: connections, authentication: authentication,
|
||||
background: background
|
||||
))
|
||||
additional: TermindAgentTools.tools(
|
||||
in: TermindAgentToolEnvironment(
|
||||
hosts: hosts, connections: connections, authentication: authentication,
|
||||
background: background
|
||||
))
|
||||
).filtering {
|
||||
exportedNames.contains($0.name) && !disabledTools.contains($0.name)
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ protocol HostRepository: AnyObject {
|
||||
final class InMemoryHostRepository: HostRepository {
|
||||
private var value: HostLibrarySnapshot
|
||||
private var revision: Int64 = 0
|
||||
private var continuations: [UUID: AsyncStream<RepositoryChange<HostLibrarySnapshot>>.Continuation]
|
||||
= [:]
|
||||
private var continuations: [UUID: AsyncStream<RepositoryChange<HostLibrarySnapshot>>.Continuation] = [:]
|
||||
|
||||
init(snapshot: HostLibrarySnapshot = .empty) { value = snapshot }
|
||||
|
||||
|
||||
@@ -45,4 +45,3 @@ final class InMemoryToolAuditRepository: ToolAuditRepository {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ nonisolated struct SpeechTranscriptionFailure: Error, Equatable, Sendable {
|
||||
case .recognizerUnavailable:
|
||||
String(localized: "Apple Speech Recognition is not currently available for your system language.")
|
||||
case .audioInputUnavailable:
|
||||
String(localized: "Termind could not start an audio input. Check that a microphone is connected and available.")
|
||||
String(
|
||||
localized: "Termind could not start an audio input. Check that a microphone is connected and available."
|
||||
)
|
||||
case .noSpeech:
|
||||
String(localized: "No recognizable speech was captured. Try speaking closer to the microphone.")
|
||||
case .recognitionFailed:
|
||||
|
||||
@@ -283,7 +283,8 @@ nonisolated enum Metrics {
|
||||
/// Space to the right of the navigation sidebar in Vault's complete
|
||||
/// working layout: two dividers, the main surface, and an Edit/New
|
||||
/// detail panel.
|
||||
static let trailingContentMinWidth: CGFloat = 1
|
||||
static let trailingContentMinWidth: CGFloat =
|
||||
1
|
||||
+ Metrics.VaultContent.min
|
||||
+ 1
|
||||
+ Metrics.VaultDetail.min
|
||||
@@ -482,11 +483,13 @@ struct TermindSidebarRowBackground: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.foregroundStyle(SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.primary)
|
||||
))
|
||||
.foregroundStyle(
|
||||
SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.primary)
|
||||
)
|
||||
)
|
||||
.fontWeight(isSelected ? .semibold : .regular)
|
||||
.background { backgroundFill }
|
||||
.overlay { menuHost }
|
||||
@@ -568,29 +571,32 @@ extension View {
|
||||
tint: Color? = nil,
|
||||
isInteractive: Bool = false
|
||||
) -> some View {
|
||||
modifier(TermindGlassShape(
|
||||
shape: RoundedRectangle(cornerRadius: cornerRadius),
|
||||
tint: tint,
|
||||
isInteractive: isInteractive
|
||||
))
|
||||
modifier(
|
||||
TermindGlassShape(
|
||||
shape: RoundedRectangle(cornerRadius: cornerRadius),
|
||||
tint: tint,
|
||||
isInteractive: isInteractive
|
||||
))
|
||||
}
|
||||
|
||||
func termindGlassCapsule(
|
||||
tint: Color? = nil,
|
||||
isInteractive: Bool = false
|
||||
) -> some View {
|
||||
modifier(TermindGlassShape(
|
||||
shape: Capsule(), tint: tint, isInteractive: isInteractive
|
||||
))
|
||||
modifier(
|
||||
TermindGlassShape(
|
||||
shape: Capsule(), tint: tint, isInteractive: isInteractive
|
||||
))
|
||||
}
|
||||
|
||||
func termindGlassCircle(
|
||||
tint: Color? = nil,
|
||||
isInteractive: Bool = false
|
||||
) -> some View {
|
||||
modifier(TermindGlassShape(
|
||||
shape: Circle(), tint: tint, isInteractive: isInteractive
|
||||
))
|
||||
modifier(
|
||||
TermindGlassShape(
|
||||
shape: Circle(), tint: tint, isInteractive: isInteractive
|
||||
))
|
||||
}
|
||||
|
||||
func termindGlassBar() -> some View {
|
||||
|
||||
@@ -149,8 +149,8 @@ struct TagField: View {
|
||||
.onTapGesture {
|
||||
commit(item.row)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: listMaxHeight)
|
||||
}
|
||||
.frame(maxHeight: listMaxHeight)
|
||||
}
|
||||
|
||||
private var tagListItems: [TagSuggestionListItem] {
|
||||
@@ -245,7 +245,7 @@ struct TagField: View {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated fileprivate struct TagSuggestionListItem: Identifiable, Hashable, Sendable {
|
||||
nonisolated private struct TagSuggestionListItem: Identifiable, Hashable, Sendable {
|
||||
let row: TagField.Row
|
||||
let isHighlighted: Bool
|
||||
var id: TagField.Row { row }
|
||||
|
||||
@@ -13,9 +13,11 @@ nonisolated struct LinuxDistributionIconCatalog: Codable, Sendable {
|
||||
var entries: [LinuxDistributionIconEntry]
|
||||
|
||||
private static let bundled: Self? = {
|
||||
guard let url = Bundle.main.url(
|
||||
forResource: "LinuxDistributionIcons", withExtension: "json"
|
||||
), let data = try? Data(contentsOf: url) else { return nil }
|
||||
guard
|
||||
let url = Bundle.main.url(
|
||||
forResource: "LinuxDistributionIcons", withExtension: "json"
|
||||
), let data = try? Data(contentsOf: url)
|
||||
else { return nil }
|
||||
return try? JSONDecoder().decode(Self.self, from: data)
|
||||
}()
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ nonisolated struct SerialPortDescriptor: Identifiable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
nonisolated enum SerialDataBits: Int, CaseIterable, Identifiable, Sendable {
|
||||
case five = 5, six = 6, seven = 7, eight = 8
|
||||
case five = 5
|
||||
case six = 6
|
||||
case seven = 7
|
||||
case eight = 8
|
||||
var id: Int { rawValue }
|
||||
}
|
||||
|
||||
@@ -29,7 +32,8 @@ nonisolated enum SerialParity: String, CaseIterable, Identifiable, Sendable {
|
||||
}
|
||||
|
||||
nonisolated enum SerialStopBits: Int, CaseIterable, Identifiable, Sendable {
|
||||
case one = 1, two = 2
|
||||
case one = 1
|
||||
case two = 2
|
||||
var id: Int { rawValue }
|
||||
}
|
||||
|
||||
@@ -91,15 +95,18 @@ nonisolated struct SerialConnectionConfiguration: Hashable, Sendable {
|
||||
"\(baudRate) \(dataBits.rawValue)-\(parityLetter)-\(stopBits.rawValue)"
|
||||
}
|
||||
private var parityLetter: String {
|
||||
switch parity { case .none: "N"; case .even: "E"; case .odd: "O" }
|
||||
switch parity {
|
||||
case .none: "N"
|
||||
case .even: "E"
|
||||
case .odd: "O"
|
||||
}
|
||||
}
|
||||
|
||||
func transformInteractiveInput(_ data: Data) -> Data {
|
||||
guard enterKey != .carriageReturn, data.contains(0x0D) else { return data }
|
||||
var result = Data()
|
||||
for byte in data {
|
||||
if byte == 0x0D { result.append(contentsOf: enterKey.bytes) }
|
||||
else { result.append(byte) }
|
||||
if byte == 0x0D { result.append(contentsOf: enterKey.bytes) } else { result.append(byte) }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ nonisolated struct DetectedDistribution: Codable, Hashable, Sendable {
|
||||
init(from decoder: any Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let proposedID = try container.decode(String.self, forKey: .id)
|
||||
let proposedLikeIDs = try container.decodeIfPresent(
|
||||
[String].self, forKey: .likeIDs
|
||||
) ?? []
|
||||
let proposedLikeIDs =
|
||||
try container.decodeIfPresent(
|
||||
[String].self, forKey: .likeIDs
|
||||
) ?? []
|
||||
guard let value = Self(id: proposedID, likeIDs: proposedLikeIDs) else {
|
||||
throw DecodingError.dataCorruptedError(
|
||||
forKey: .id, in: container,
|
||||
|
||||
@@ -61,11 +61,12 @@ nonisolated struct SessionContext: Hashable, Sendable {
|
||||
chips.append(ContextChip(kind: .host, label: hostName, content: hostName))
|
||||
}
|
||||
if let workingDirectory, showsWorkingDirectoryChip {
|
||||
chips.append(ContextChip(
|
||||
kind: .directory,
|
||||
label: workingDirectoryDisplayLabel ?? workingDirectory,
|
||||
content: workingDirectory
|
||||
))
|
||||
chips.append(
|
||||
ContextChip(
|
||||
kind: .directory,
|
||||
label: workingDirectoryDisplayLabel ?? workingDirectory,
|
||||
content: workingDirectory
|
||||
))
|
||||
}
|
||||
return chips
|
||||
}
|
||||
|
||||
@@ -179,10 +179,12 @@ nonisolated enum AgentToolPresentation {
|
||||
// MCP output is remote-defined and intentionally keeps the established
|
||||
// JSON presentation. Built-ins below never fall back to JSON syntax.
|
||||
if card.descriptor.target == .mcp {
|
||||
guard let output = detailOutput(
|
||||
from: result, presentation: card.descriptor.presentation,
|
||||
review: card.review
|
||||
) else { return nil }
|
||||
guard
|
||||
let output = detailOutput(
|
||||
from: result, presentation: card.descriptor.presentation,
|
||||
review: card.review
|
||||
)
|
||||
else { return nil }
|
||||
return AgentToolDetail(items: [
|
||||
.text(.init(title: nil, text: output, style: .monospaced))
|
||||
])
|
||||
@@ -190,10 +192,12 @@ nonisolated enum AgentToolPresentation {
|
||||
|
||||
let raw = result.untrustedPayload ?? result.content
|
||||
guard let value = try? AgentJSONValue.decode(Data(raw.utf8)) else {
|
||||
guard let output = detailOutput(
|
||||
from: result, presentation: card.descriptor.presentation,
|
||||
review: card.review
|
||||
) else { return nil }
|
||||
guard
|
||||
let output = detailOutput(
|
||||
from: result, presentation: card.descriptor.presentation,
|
||||
review: card.review
|
||||
)
|
||||
else { return nil }
|
||||
let text = output.isEmpty ? String(localized: "Completed without output") : output
|
||||
return AgentToolDetail(items: [
|
||||
.message(text, result.isError ? .failure : .secondary)
|
||||
@@ -213,11 +217,13 @@ nonisolated enum AgentToolPresentation {
|
||||
items: AgentToolDetailFormatting.genericItems(value, locale: locale)
|
||||
)
|
||||
}
|
||||
return AgentToolDetail(items: presenter.present(.init(
|
||||
result: value,
|
||||
arguments: card.invocation.call.arguments.objectValue ?? [:],
|
||||
locale: locale
|
||||
)))
|
||||
return AgentToolDetail(
|
||||
items: presenter.present(
|
||||
.init(
|
||||
result: value,
|
||||
arguments: card.invocation.call.arguments.objectValue ?? [:],
|
||||
locale: locale
|
||||
)))
|
||||
}
|
||||
|
||||
/// A bounded rendering projection. The full transformed output stays beside
|
||||
@@ -351,9 +357,11 @@ nonisolated enum AgentToolPresentation {
|
||||
for card: AgentToolCard, locale: Locale
|
||||
) -> RenderedOperation {
|
||||
guard let presentation = card.descriptor.presentation else {
|
||||
if isChinese(locale), let recovered = recoveredBuiltInOperation(
|
||||
for: card, locale: locale
|
||||
) {
|
||||
if isChinese(locale),
|
||||
let recovered = recoveredBuiltInOperation(
|
||||
for: card, locale: locale
|
||||
)
|
||||
{
|
||||
return recovered
|
||||
}
|
||||
return RenderedOperation(
|
||||
@@ -484,7 +492,8 @@ nonisolated enum AgentToolPresentation {
|
||||
return RenderedOperation(text: oneLine, isArgumentValue: true)
|
||||
}
|
||||
}
|
||||
let text = card.descriptor.name == "web_search"
|
||||
let text =
|
||||
card.descriptor.name == "web_search"
|
||||
? "" : activityName(metadata.activity, locale: locale)
|
||||
return RenderedOperation(text: text, isArgumentValue: false)
|
||||
}
|
||||
@@ -494,7 +503,8 @@ nonisolated enum AgentToolPresentation {
|
||||
) -> String {
|
||||
guard let first = cards.first else { return "" }
|
||||
guard let presentation = first.descriptor.presentation else {
|
||||
let activity = cards.count == 1
|
||||
let activity =
|
||||
cards.count == 1
|
||||
? operation(for: first, locale: locale)
|
||||
: RenderedOperation(
|
||||
text: toolCallCount(cards.count, locale: locale), isArgumentValue: false
|
||||
@@ -719,41 +729,43 @@ nonisolated enum AgentToolPresentation {
|
||||
if isChinese(locale) {
|
||||
let action = localizedActionVerb(kind, locale: locale)
|
||||
let displayedActivity = isArgumentValue ? "“\(activity)”" : activity
|
||||
let rendered: String = switch state {
|
||||
case .proposed, .reviewing:
|
||||
localized("Tool action preparing: \(action) \(displayedActivity)", locale: locale)
|
||||
case .waitingForApproval, .waitingForUser:
|
||||
localized("Tool action waiting: \(action) \(displayedActivity)", locale: locale)
|
||||
case .running:
|
||||
localized("Tool action running: \(action) \(displayedActivity)", locale: locale)
|
||||
case .completed:
|
||||
localized("Tool action completed: \(action) \(displayedActivity)", locale: locale)
|
||||
case .denied:
|
||||
localized("Tool action denied: \(action) \(displayedActivity)", locale: locale)
|
||||
case .failed:
|
||||
localized("Tool action failed: \(action) \(displayedActivity)", locale: locale)
|
||||
case .interrupted:
|
||||
localized("Tool action interrupted: \(action) \(displayedActivity)", locale: locale)
|
||||
}
|
||||
let rendered: String =
|
||||
switch state {
|
||||
case .proposed, .reviewing:
|
||||
localized("Tool action preparing: \(action) \(displayedActivity)", locale: locale)
|
||||
case .waitingForApproval, .waitingForUser:
|
||||
localized("Tool action waiting: \(action) \(displayedActivity)", locale: locale)
|
||||
case .running:
|
||||
localized("Tool action running: \(action) \(displayedActivity)", locale: locale)
|
||||
case .completed:
|
||||
localized("Tool action completed: \(action) \(displayedActivity)", locale: locale)
|
||||
case .denied:
|
||||
localized("Tool action denied: \(action) \(displayedActivity)", locale: locale)
|
||||
case .failed:
|
||||
localized("Tool action failed: \(action) \(displayedActivity)", locale: locale)
|
||||
case .interrupted:
|
||||
localized("Tool action interrupted: \(action) \(displayedActivity)", locale: locale)
|
||||
}
|
||||
guard let target, !target.isEmpty else { return rendered }
|
||||
return localized("Tool action target: \(rendered) \(target)", locale: locale)
|
||||
}
|
||||
if activity.isEmpty {
|
||||
let rendered: String = switch state {
|
||||
case .proposed, .reviewing:
|
||||
"Preparing to \(actionName(kind, form: .infinitive, locale: locale))"
|
||||
case .waitingForApproval, .waitingForUser:
|
||||
"Waiting to \(actionName(kind, form: .infinitive, locale: locale))"
|
||||
case .running: actionName(kind, form: .progressive, locale: locale)
|
||||
case .completed: actionName(kind, form: .past, locale: locale)
|
||||
case .denied:
|
||||
"Did not \(actionName(kind, form: .infinitive, locale: locale))"
|
||||
case .failed:
|
||||
"Failed to \(actionName(kind, form: .infinitive, locale: locale))"
|
||||
case .interrupted:
|
||||
"Interrupted while "
|
||||
+ lowercaseFirst(actionName(kind, form: .progressive, locale: locale))
|
||||
}
|
||||
let rendered: String =
|
||||
switch state {
|
||||
case .proposed, .reviewing:
|
||||
"Preparing to \(actionName(kind, form: .infinitive, locale: locale))"
|
||||
case .waitingForApproval, .waitingForUser:
|
||||
"Waiting to \(actionName(kind, form: .infinitive, locale: locale))"
|
||||
case .running: actionName(kind, form: .progressive, locale: locale)
|
||||
case .completed: actionName(kind, form: .past, locale: locale)
|
||||
case .denied:
|
||||
"Did not \(actionName(kind, form: .infinitive, locale: locale))"
|
||||
case .failed:
|
||||
"Failed to \(actionName(kind, form: .infinitive, locale: locale))"
|
||||
case .interrupted:
|
||||
"Interrupted while "
|
||||
+ lowercaseFirst(actionName(kind, form: .progressive, locale: locale))
|
||||
}
|
||||
guard let target, !target.isEmpty else { return rendered }
|
||||
return "\(rendered) on \(target)"
|
||||
}
|
||||
@@ -852,44 +864,45 @@ nonisolated enum AgentToolPresentation {
|
||||
form: ActionForm, locale: Locale
|
||||
) -> String {
|
||||
if isChinese(locale) { return localizedActionVerb(kind, locale: locale) }
|
||||
let forms: ActionForms = switch kind {
|
||||
case .ask: .init(infinitive: "ask", progressive: "Asking", past: "Asked")
|
||||
case .compare: .init(infinitive: "compare", progressive: "Comparing", past: "Compared")
|
||||
case .copy: .init(infinitive: "copy", progressive: "Copying", past: "Copied")
|
||||
case .create: .init(infinitive: "create", progressive: "Creating", past: "Created")
|
||||
case .delete: .init(infinitive: "delete", progressive: "Deleting", past: "Deleted")
|
||||
case .edit: .init(infinitive: "edit", progressive: "Editing", past: "Edited")
|
||||
case .fetch: .init(infinitive: "fetch", progressive: "Fetching", past: "Fetched")
|
||||
case .inspect: .init(infinitive: "inspect", progressive: "Inspecting", past: "Inspected")
|
||||
case .list: .init(infinitive: "list", progressive: "Listing", past: "Listed")
|
||||
case .move: .init(infinitive: "move", progressive: "Moving", past: "Moved")
|
||||
case .present: .init(infinitive: "present", progressive: "Writing", past: "Presented")
|
||||
case .pull: .init(infinitive: "pull", progressive: "Pulling", past: "Pulled")
|
||||
case .push: .init(infinitive: "push", progressive: "Pushing", past: "Pushed")
|
||||
case .read: .init(infinitive: "read", progressive: "Reading", past: "Read")
|
||||
case .request: .init(infinitive: "request", progressive: "Requesting", past: "Requested")
|
||||
case .run: .init(infinitive: "run", progressive: "Running", past: "Ran")
|
||||
case .search: .init(infinitive: "search", progressive: "Searching", past: "Searched")
|
||||
case .suggest: .init(infinitive: "suggest", progressive: "Suggesting", past: "Suggested")
|
||||
case .update: .init(infinitive: "update", progressive: "Updating", past: "Updated")
|
||||
case .wait: .init(infinitive: "wait for", progressive: "Waiting for", past: "Waited for")
|
||||
case .write: .init(infinitive: "write", progressive: "Writing", past: "Wrote")
|
||||
case .changePermissions:
|
||||
.init(
|
||||
infinitive: "change permissions for",
|
||||
progressive: "Changing permissions for",
|
||||
past: "Changed permissions for"
|
||||
)
|
||||
case .use: .init(infinitive: "use", progressive: "Using", past: "Used")
|
||||
// Open vocabulary. The raw value is the verb an app chose, so using it
|
||||
// uninflected reads better than a generic "act on".
|
||||
default:
|
||||
.init(
|
||||
infinitive: kind.rawValue,
|
||||
progressive: "\(kind.rawValue.capitalized)ing",
|
||||
past: "\(kind.rawValue.capitalized)ed"
|
||||
)
|
||||
}
|
||||
let forms: ActionForms =
|
||||
switch kind {
|
||||
case .ask: .init(infinitive: "ask", progressive: "Asking", past: "Asked")
|
||||
case .compare: .init(infinitive: "compare", progressive: "Comparing", past: "Compared")
|
||||
case .copy: .init(infinitive: "copy", progressive: "Copying", past: "Copied")
|
||||
case .create: .init(infinitive: "create", progressive: "Creating", past: "Created")
|
||||
case .delete: .init(infinitive: "delete", progressive: "Deleting", past: "Deleted")
|
||||
case .edit: .init(infinitive: "edit", progressive: "Editing", past: "Edited")
|
||||
case .fetch: .init(infinitive: "fetch", progressive: "Fetching", past: "Fetched")
|
||||
case .inspect: .init(infinitive: "inspect", progressive: "Inspecting", past: "Inspected")
|
||||
case .list: .init(infinitive: "list", progressive: "Listing", past: "Listed")
|
||||
case .move: .init(infinitive: "move", progressive: "Moving", past: "Moved")
|
||||
case .present: .init(infinitive: "present", progressive: "Writing", past: "Presented")
|
||||
case .pull: .init(infinitive: "pull", progressive: "Pulling", past: "Pulled")
|
||||
case .push: .init(infinitive: "push", progressive: "Pushing", past: "Pushed")
|
||||
case .read: .init(infinitive: "read", progressive: "Reading", past: "Read")
|
||||
case .request: .init(infinitive: "request", progressive: "Requesting", past: "Requested")
|
||||
case .run: .init(infinitive: "run", progressive: "Running", past: "Ran")
|
||||
case .search: .init(infinitive: "search", progressive: "Searching", past: "Searched")
|
||||
case .suggest: .init(infinitive: "suggest", progressive: "Suggesting", past: "Suggested")
|
||||
case .update: .init(infinitive: "update", progressive: "Updating", past: "Updated")
|
||||
case .wait: .init(infinitive: "wait for", progressive: "Waiting for", past: "Waited for")
|
||||
case .write: .init(infinitive: "write", progressive: "Writing", past: "Wrote")
|
||||
case .changePermissions:
|
||||
.init(
|
||||
infinitive: "change permissions for",
|
||||
progressive: "Changing permissions for",
|
||||
past: "Changed permissions for"
|
||||
)
|
||||
case .use: .init(infinitive: "use", progressive: "Using", past: "Used")
|
||||
// Open vocabulary. The raw value is the verb an app chose, so using it
|
||||
// uninflected reads better than a generic "act on".
|
||||
default:
|
||||
.init(
|
||||
infinitive: kind.rawValue,
|
||||
progressive: "\(kind.rawValue.capitalized)ing",
|
||||
past: "\(kind.rawValue.capitalized)ed"
|
||||
)
|
||||
}
|
||||
return forms[form]
|
||||
}
|
||||
|
||||
|
||||
@@ -135,8 +135,9 @@ nonisolated enum ContextAttachmentImporter {
|
||||
}
|
||||
var pages: [String] = []
|
||||
for index in 0..<document.pageCount {
|
||||
guard let value = document.page(at: index)?.string?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty
|
||||
guard
|
||||
let value = document.page(at: index)?.string?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty
|
||||
else { continue }
|
||||
pages.append("--- Page \(index + 1) ---\n\(value)")
|
||||
}
|
||||
@@ -240,18 +241,24 @@ nonisolated enum ContextAttachmentImporter {
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: dimension,
|
||||
]
|
||||
guard let image = CGImageSourceCreateThumbnailAtIndex(
|
||||
source, 0, options as CFDictionary
|
||||
) else { throw ImportError.unreadableImage }
|
||||
guard
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(
|
||||
source, 0, options as CFDictionary
|
||||
)
|
||||
else { throw ImportError.unreadableImage }
|
||||
let alpha = image.alphaInfo
|
||||
let hasAlpha = alpha == .first || alpha == .last
|
||||
let hasAlpha =
|
||||
alpha == .first || alpha == .last
|
||||
|| alpha == .premultipliedFirst || alpha == .premultipliedLast
|
||||
let type = hasAlpha ? UTType.png : UTType.jpeg
|
||||
let output = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(
|
||||
output, type.identifier as CFString, 1, nil
|
||||
) else { throw ImportError.unreadableImage }
|
||||
let properties: [CFString: Any] = hasAlpha
|
||||
guard
|
||||
let destination = CGImageDestinationCreateWithData(
|
||||
output, type.identifier as CFString, 1, nil
|
||||
)
|
||||
else { throw ImportError.unreadableImage }
|
||||
let properties: [CFString: Any] =
|
||||
hasAlpha
|
||||
? [:] : [kCGImageDestinationLossyCompressionQuality: 0.84]
|
||||
CGImageDestinationAddImage(destination, image, properties as CFDictionary)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
@@ -324,9 +331,15 @@ nonisolated private final class ParagraphTextParser: NSObject, XMLParserDelegate
|
||||
return output.replacingOccurrences(of: #"\n{3,}"#, with: "\n\n", options: .regularExpression)
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes attributeDict: [String: String] = [:]) {
|
||||
func parser(
|
||||
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = elementName.split(separator: ":").last.map(String.init) ?? elementName
|
||||
if name == "t" { capturesText = true; captured = "" }
|
||||
if name == "t" {
|
||||
capturesText = true
|
||||
captured = ""
|
||||
}
|
||||
if name == "tab" { output += "\t" }
|
||||
}
|
||||
|
||||
@@ -336,7 +349,10 @@ nonisolated private final class ParagraphTextParser: NSObject, XMLParserDelegate
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
|
||||
let name = elementName.split(separator: ":").last.map(String.init) ?? elementName
|
||||
if name == "t" { output += captured; capturesText = false }
|
||||
if name == "t" {
|
||||
output += captured
|
||||
capturesText = false
|
||||
}
|
||||
if cellElements.contains(name) { output += "\t" }
|
||||
if paragraphElements.contains(name) { output += "\n" }
|
||||
}
|
||||
@@ -356,10 +372,19 @@ nonisolated private final class SharedStringsParser: NSObject, XMLParserDelegate
|
||||
return strings
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes attributeDict: [String: String] = [:]) {
|
||||
func parser(
|
||||
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = elementName.split(separator: ":").last.map(String.init) ?? elementName
|
||||
if name == "si" { inItem = true; value = "" }
|
||||
if name == "t", inItem { inText = true; text = "" }
|
||||
if name == "si" {
|
||||
inItem = true
|
||||
value = ""
|
||||
}
|
||||
if name == "t", inItem {
|
||||
inText = true
|
||||
text = ""
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
@@ -368,8 +393,14 @@ nonisolated private final class SharedStringsParser: NSObject, XMLParserDelegate
|
||||
|
||||
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName: String?) {
|
||||
let name = elementName.split(separator: ":").last.map(String.init) ?? elementName
|
||||
if name == "t", inText { value += text; inText = false }
|
||||
if name == "si" { strings.append(value); inItem = false }
|
||||
if name == "t", inText {
|
||||
value += text
|
||||
inText = false
|
||||
}
|
||||
if name == "si" {
|
||||
strings.append(value)
|
||||
inItem = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +422,10 @@ nonisolated private final class WorksheetTextParser: NSObject, XMLParserDelegate
|
||||
return rows.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?, attributes attributeDict: [String: String] = [:]) {
|
||||
func parser(
|
||||
_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName: String?,
|
||||
attributes attributeDict: [String: String] = [:]
|
||||
) {
|
||||
let name = elementName.split(separator: ":").last.map(String.init) ?? elementName
|
||||
if name == "row" { cells = [] }
|
||||
if name == "c" {
|
||||
@@ -400,7 +434,10 @@ nonisolated private final class WorksheetTextParser: NSObject, XMLParserDelegate
|
||||
let column = Self.columnIndex(cellReference)
|
||||
while cells.count < column { cells.append("") }
|
||||
}
|
||||
if name == "v" || name == "t" { captureElement = name; captured = "" }
|
||||
if name == "v" || name == "t" {
|
||||
captureElement = name
|
||||
captured = ""
|
||||
}
|
||||
}
|
||||
|
||||
func parser(_ parser: XMLParser, foundCharacters string: String) {
|
||||
|
||||
@@ -128,33 +128,40 @@ nonisolated struct ContextChip: Identifiable, Hashable, Sendable {
|
||||
var cursor = suffix.startIndex
|
||||
while let opening = suffix.range(of: prefix, range: cursor..<suffix.endIndex) {
|
||||
let labelStart = opening.upperBound
|
||||
guard let labelEnd = suffix.range(
|
||||
of: "\">", range: labelStart..<suffix.endIndex
|
||||
) else { break }
|
||||
guard
|
||||
let labelEnd = suffix.range(
|
||||
of: "\">", range: labelStart..<suffix.endIndex
|
||||
)
|
||||
else { break }
|
||||
let contentStart = labelEnd.upperBound
|
||||
let closing = "</\(kind.blockName)>"
|
||||
guard let contentEnd = suffix.range(
|
||||
of: closing, range: contentStart..<suffix.endIndex
|
||||
) else { break }
|
||||
guard
|
||||
let contentEnd = suffix.range(
|
||||
of: closing, range: contentStart..<suffix.endIndex
|
||||
)
|
||||
else { break }
|
||||
let label = String(suffix[labelStart..<labelEnd.lowerBound])
|
||||
let storedIndex = unusedAttachments.firstIndex {
|
||||
$0.label.replacingOccurrences(of: "\"", with: "'") == label
|
||||
&& $0.kind == kind.agentAttachmentKind
|
||||
}
|
||||
let attachment: AgentContextAttachment? = storedIndex.map {
|
||||
unusedAttachments.remove(at: $0)
|
||||
} ?? kind.fallbackAttachment(
|
||||
label: label,
|
||||
content: String(suffix[contentStart..<contentEnd.lowerBound])
|
||||
)
|
||||
matches.append((
|
||||
opening.lowerBound,
|
||||
ContextChipReference(
|
||||
id: attachment?.id ?? UUID(), kind: kind,
|
||||
label: attachment?.label ?? label,
|
||||
attachment: attachment
|
||||
let attachment: AgentContextAttachment? =
|
||||
storedIndex.map {
|
||||
unusedAttachments.remove(at: $0)
|
||||
}
|
||||
?? kind.fallbackAttachment(
|
||||
label: label,
|
||||
content: String(suffix[contentStart..<contentEnd.lowerBound])
|
||||
)
|
||||
))
|
||||
matches.append(
|
||||
(
|
||||
opening.lowerBound,
|
||||
ContextChipReference(
|
||||
id: attachment?.id ?? UUID(), kind: kind,
|
||||
label: attachment?.label ?? label,
|
||||
attachment: attachment
|
||||
)
|
||||
))
|
||||
cursor = contentEnd.upperBound
|
||||
}
|
||||
}
|
||||
@@ -185,8 +192,8 @@ nonisolated struct ContextChipReference: Identifiable, Hashable, Sendable {
|
||||
var attachment: AgentContextAttachment? = nil
|
||||
}
|
||||
|
||||
private extension ContextChip.Kind {
|
||||
nonisolated var agentAttachmentKind: AgentContextAttachment.Kind? {
|
||||
extension ContextChip.Kind {
|
||||
fileprivate nonisolated var agentAttachmentKind: AgentContextAttachment.Kind? {
|
||||
switch self {
|
||||
case .file: .file
|
||||
case .output: .output
|
||||
@@ -198,7 +205,7 @@ private extension ContextChip.Kind {
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func fallbackAttachment(
|
||||
fileprivate nonisolated func fallbackAttachment(
|
||||
label: String, content: String
|
||||
) -> AgentContextAttachment? {
|
||||
guard let kind = agentAttachmentKind else { return nil }
|
||||
@@ -212,7 +219,7 @@ private extension ContextChip.Kind {
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated func fallbackFilename(for label: String) -> String {
|
||||
fileprivate nonisolated func fallbackFilename(for label: String) -> String {
|
||||
if self == .file {
|
||||
let stem = (label as NSString).deletingPathExtension
|
||||
return "\(stem.isEmpty ? label : stem) extracted.txt"
|
||||
@@ -657,8 +664,7 @@ final class ConversationModel: Identifiable {
|
||||
private var inputWaiters: [UUID: CheckedContinuation<AgentUserInputResponse, Never>] = [:]
|
||||
private var userInputTimeoutTask: Task<Void, Never>?
|
||||
private var sshAuthenticationWaiters: [UUID: CheckedContinuation<AgentSSHAuthenticationResponse, Never>] = [:]
|
||||
private var sshHostKeyWaiters:
|
||||
[UUID: CheckedContinuation<AgentSSHHostKeyResponse, Never>] = [:]
|
||||
private var sshHostKeyWaiters: [UUID: CheckedContinuation<AgentSSHHostKeyResponse, Never>] = [:]
|
||||
|
||||
/// Whether the agent may only look.
|
||||
///
|
||||
@@ -897,7 +903,8 @@ final class ConversationModel: Identifiable {
|
||||
else { return nil }
|
||||
let original = transcript[target]
|
||||
let visible = original.authoredText ?? ContextChip.visiblePrompt(in: original.text)
|
||||
let suffix = original.text.hasPrefix(visible)
|
||||
let suffix =
|
||||
original.text.hasPrefix(visible)
|
||||
? String(original.text.dropFirst(visible.count)) : ""
|
||||
return AgentTranscriptMessage(
|
||||
role: .user, text: authoredText + suffix, authoredText: authoredText,
|
||||
@@ -920,14 +927,18 @@ final class ConversationModel: Identifiable {
|
||||
contextEstimateTracker.upsert(replacement)
|
||||
|
||||
if let visibleIndex = messages.firstIndex(where: { $0.id == id }) {
|
||||
messages.replaceSubrange(visibleIndex..., with: [ChatMessage(
|
||||
id: replacement.id, role: .user,
|
||||
text: replacement.authoredText ?? ContextChip.visiblePrompt(in: replacement.text),
|
||||
contextChips: ContextChip.visibleReferences(
|
||||
in: replacement.text, images: replacement.images,
|
||||
contextAttachments: replacement.contextAttachments
|
||||
), images: replacement.images, createdAt: replacement.createdAt
|
||||
)])
|
||||
messages.replaceSubrange(
|
||||
visibleIndex...,
|
||||
with: [
|
||||
ChatMessage(
|
||||
id: replacement.id, role: .user,
|
||||
text: replacement.authoredText ?? ContextChip.visiblePrompt(in: replacement.text),
|
||||
contextChips: ContextChip.visibleReferences(
|
||||
in: replacement.text, images: replacement.images,
|
||||
contextAttachments: replacement.contextAttachments
|
||||
), images: replacement.images, createdAt: replacement.createdAt
|
||||
)
|
||||
])
|
||||
}
|
||||
toolCards.removeAll {
|
||||
removedRunIDs.contains($0.invocation.runID)
|
||||
@@ -1023,10 +1034,10 @@ final class ConversationModel: Identifiable {
|
||||
let runIDMap = Dictionary(uniqueKeysWithValues: originalRunIDs.map { ($0, UUID()) })
|
||||
let clonedMessageRunIDs: [UUID: UUID] = Dictionary(
|
||||
uniqueKeysWithValues: source.compactMap { message -> (UUID, UUID)? in
|
||||
guard let oldRunID = messageRunIDs[message.id],
|
||||
let newRunID = runIDMap[oldRunID], let newMessageID = messageIDMap[message.id]
|
||||
else { return nil }
|
||||
return (newMessageID, newRunID)
|
||||
guard let oldRunID = messageRunIDs[message.id],
|
||||
let newRunID = runIDMap[oldRunID], let newMessageID = messageIDMap[message.id]
|
||||
else { return nil }
|
||||
return (newMessageID, newRunID)
|
||||
}
|
||||
)
|
||||
let clonedRuns = originalRunIDs.compactMap { oldRunID -> AgentRunSummary? in
|
||||
@@ -1084,9 +1095,10 @@ final class ConversationModel: Identifiable {
|
||||
grouping: toolCards.filter { $0.terminalSuggestion == nil },
|
||||
by: { $0.invocation.runID }
|
||||
)
|
||||
let toolProcessMessageIDs = Set(transcript.compactMap { message -> UUID? in
|
||||
message.role == .assistant && !message.toolCalls.isEmpty ? message.id : nil
|
||||
})
|
||||
let toolProcessMessageIDs = Set(
|
||||
transcript.compactMap { message -> UUID? in
|
||||
message.role == .assistant && !message.toolCalls.isEmpty ? message.id : nil
|
||||
})
|
||||
var processRunIDs: [UUID: UUID] = [:]
|
||||
for message in transcript where message.role == .assistant && !message.toolCalls.isEmpty {
|
||||
if let runID = messageRunIDs[message.id] {
|
||||
@@ -1128,21 +1140,25 @@ final class ConversationModel: Identifiable {
|
||||
guard owner == runID else { return nil }
|
||||
return visibleByID[messageID]
|
||||
}
|
||||
let workHasNonReasoningOutput = !cards.isEmpty
|
||||
let workHasNonReasoningOutput =
|
||||
!cards.isEmpty
|
||||
|| processMessages.contains { !$0.text.isEmpty }
|
||||
let hasVisibleReasoning = processMessages.contains { message in
|
||||
message.id == activeReasoningMessageID && !message.reasoning.isEmpty
|
||||
}
|
||||
let visibleProcess = processMessages.filter { message in
|
||||
let livePlaceholder = message.isStreaming && message.text.isEmpty
|
||||
let livePlaceholder =
|
||||
message.isStreaming && message.text.isEmpty
|
||||
&& message.reasoning.isEmpty
|
||||
let toolProcess = toolProcessMessageIDs.contains(message.id)
|
||||
let toolProcess =
|
||||
toolProcessMessageIDs.contains(message.id)
|
||||
&& (!message.text.isEmpty
|
||||
|| (message.isStreaming && message.reasoning.isEmpty))
|
||||
// Working belongs to the Work as a whole. Reasoning does not
|
||||
// consume this slot: a later empty turn may still be replaced
|
||||
// by its next reasoning block. Any non-reasoning output does.
|
||||
return (livePlaceholder
|
||||
return
|
||||
(livePlaceholder
|
||||
&& !hasVisibleReasoning
|
||||
&& !workHasNonReasoningOutput)
|
||||
|| toolProcess
|
||||
@@ -1153,7 +1169,8 @@ final class ConversationModel: Identifiable {
|
||||
let hasUnfinishedGroupableTool = groupableCards.contains {
|
||||
!$0.state.isTerminal
|
||||
}
|
||||
let latestToolReasoning = processMessages
|
||||
let latestToolReasoning =
|
||||
processMessages
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
.reversed()
|
||||
.compactMap { message -> AgentToolReasoning? in
|
||||
@@ -1169,7 +1186,8 @@ final class ConversationModel: Identifiable {
|
||||
// Once there is a tool surface to carry it, reasoning becomes a
|
||||
// transient title instead of a separate structural row. Before the
|
||||
// atomic batch is published it remains visible on its own.
|
||||
let reasoningItems = !hasUnfinishedGroupableTool
|
||||
let reasoningItems =
|
||||
!hasUnfinishedGroupableTool
|
||||
? processMessages.flatMap { message -> [AgentWorkItem] in
|
||||
guard message.id == activeReasoningMessageID else { return [] }
|
||||
return message.reasoning.map(AgentWorkItem.reasoning)
|
||||
@@ -1178,7 +1196,8 @@ final class ConversationModel: Identifiable {
|
||||
let activeReasoningBlockID = processMessages.first(where: {
|
||||
$0.id == activeReasoningMessageID && $0.isStreaming && $0.text.isEmpty
|
||||
})?.reasoning.last?.id
|
||||
var items = reasoningItems + visibleProcess.map(AgentWorkItem.message)
|
||||
var items =
|
||||
reasoningItems + visibleProcess.map(AgentWorkItem.message)
|
||||
+ cards.map(AgentWorkItem.tool)
|
||||
items.sort {
|
||||
if $0.orderedAt != $1.orderedAt { return $0.orderedAt < $1.orderedAt }
|
||||
@@ -1203,7 +1222,8 @@ final class ConversationModel: Identifiable {
|
||||
// The work row follows the clock of the first thing it actually
|
||||
// presents. A stopped direct answer has no work item, so its stop
|
||||
// event is the row's place in the timeline.
|
||||
let orderedAt = items.first?.orderedAt
|
||||
let orderedAt =
|
||||
items.first?.orderedAt
|
||||
?? stored?.finishedAt
|
||||
?? stored?.startedAt
|
||||
?? .now
|
||||
@@ -1215,14 +1235,17 @@ final class ConversationModel: Identifiable {
|
||||
&& processRunIDs[$0.id] == nil
|
||||
&& $0.createdAt < orderedAt
|
||||
})?.createdAt
|
||||
let fallbackState: AgentRunState = cards.allSatisfy {
|
||||
$0.state == .completed
|
||||
} ? .completed : .interrupted
|
||||
var run = stored ?? AgentRunSummary(
|
||||
id: runID, state: fallbackState,
|
||||
startedAt: items.first?.orderedAt ?? orderedAt,
|
||||
finishedAt: items.last?.orderedAt
|
||||
)
|
||||
let fallbackState: AgentRunState =
|
||||
cards.allSatisfy {
|
||||
$0.state == .completed
|
||||
} ? .completed : .interrupted
|
||||
var run =
|
||||
stored
|
||||
?? AgentRunSummary(
|
||||
id: runID, state: fallbackState,
|
||||
startedAt: items.first?.orderedAt ?? orderedAt,
|
||||
finishedAt: items.last?.orderedAt
|
||||
)
|
||||
if activeRunID == runID { run.state = runState }
|
||||
return AgentWorkGroup(
|
||||
run: run, items: items, orderedAt: orderedAt,
|
||||
@@ -1256,11 +1279,13 @@ final class ConversationModel: Identifiable {
|
||||
if card.descriptor.presentation?.groupsWithAdjacentTools == false {
|
||||
grouped.append(.tool(card))
|
||||
} else if !insertedBatch {
|
||||
grouped.append(.tools(AgentToolBatch(
|
||||
cards: batched,
|
||||
runID: runID,
|
||||
reasoning: reasoning
|
||||
)))
|
||||
grouped.append(
|
||||
.tools(
|
||||
AgentToolBatch(
|
||||
cards: batched,
|
||||
runID: runID,
|
||||
reasoning: reasoning
|
||||
)))
|
||||
insertedBatch = true
|
||||
}
|
||||
} else {
|
||||
@@ -1274,12 +1299,13 @@ final class ConversationModel: Identifiable {
|
||||
var timeline: [TimelineItem] {
|
||||
var items: [TimelineItem] = []
|
||||
let groups = workGroups
|
||||
let groupedProcessIDs: Set<UUID> = Set(groups.flatMap { group -> [UUID] in
|
||||
group.items.compactMap { item -> UUID? in
|
||||
guard case .message(let message) = item else { return nil }
|
||||
return message.id
|
||||
}
|
||||
})
|
||||
let groupedProcessIDs: Set<UUID> = Set(
|
||||
groups.flatMap { group -> [UUID] in
|
||||
group.items.compactMap { item -> UUID? in
|
||||
guard case .message(let message) = item else { return nil }
|
||||
return message.id
|
||||
}
|
||||
})
|
||||
var emittedGroups = Set<UUID>()
|
||||
for message in messages {
|
||||
for group in groups where group.anchorMessageID == message.id {
|
||||
@@ -1291,15 +1317,17 @@ final class ConversationModel: Identifiable {
|
||||
// bubble reads as the model having said nothing. While it is still
|
||||
// streaming it stays only until this turn receives reasoning; that
|
||||
// live text then owns the activity treatment until turnFinished.
|
||||
guard message.role != .assistant || !message.text.isEmpty
|
||||
|| (message.isStreaming && message.reasoning.isEmpty)
|
||||
guard
|
||||
message.role != .assistant || !message.text.isEmpty
|
||||
|| (message.isStreaming && message.reasoning.isEmpty)
|
||||
else { continue }
|
||||
items.append(.message(message))
|
||||
}
|
||||
items += groups.filter { !emittedGroups.contains($0.id) }.map(TimelineItem.work)
|
||||
let commands = toolCards.compactMap(\.terminalSuggestion)
|
||||
guard !commands.isEmpty || !plans.isEmpty || !compactions.isEmpty
|
||||
|| compactionActivity != nil
|
||||
guard
|
||||
!commands.isEmpty || !plans.isEmpty || !compactions.isEmpty
|
||||
|| compactionActivity != nil
|
||||
else {
|
||||
return items
|
||||
}
|
||||
@@ -1307,7 +1335,8 @@ final class ConversationModel: Identifiable {
|
||||
// run and a compaction happens between runs, so there are never many of
|
||||
// either, and two more cursors inside that loop would be two more things
|
||||
// to keep right for no gain.
|
||||
var extra = commands.map(TimelineItem.command)
|
||||
var extra =
|
||||
commands.map(TimelineItem.command)
|
||||
+ plans.map(TimelineItem.plan)
|
||||
+ compactions.map(TimelineItem.compaction)
|
||||
if let compactionActivity { extra.append(.compactionActivity(compactionActivity)) }
|
||||
@@ -1364,7 +1393,8 @@ final class ConversationModel: Identifiable {
|
||||
inCharacters: AgentContextEstimator.characterUnits(in: $1.text)
|
||||
)
|
||||
}
|
||||
let boundary = summarized.reversed().first(where: { !$0.isCompaction })?.id
|
||||
let boundary =
|
||||
summarized.reversed().first(where: { !$0.isCompaction })?.id
|
||||
?? compactionRecords.last?.compactedThroughMessageID
|
||||
return AgentCompactionRecord(
|
||||
compactedThroughMessageID: boundary,
|
||||
@@ -1436,12 +1466,13 @@ final class ConversationModel: Identifiable {
|
||||
}
|
||||
|
||||
func attach(_ chip: ContextChip) {
|
||||
guard !composer.attachments.contains(where: {
|
||||
if let image = chip.image, let existing = $0.image {
|
||||
return existing.data == image.data
|
||||
}
|
||||
return $0.kind == chip.kind && $0.content == chip.content
|
||||
})
|
||||
guard
|
||||
!composer.attachments.contains(where: {
|
||||
if let image = chip.image, let existing = $0.image {
|
||||
return existing.data == image.data
|
||||
}
|
||||
return $0.kind == chip.kind && $0.content == chip.content
|
||||
})
|
||||
else { return }
|
||||
composer.attachments.append(chip)
|
||||
refreshAttachedContextEstimate()
|
||||
@@ -1453,7 +1484,8 @@ final class ConversationModel: Identifiable {
|
||||
}
|
||||
|
||||
func beginAttachmentImport(label: String) -> PendingContextAttachment? {
|
||||
guard composer.attachments.count + composer.pendingAttachments.count
|
||||
guard
|
||||
composer.attachments.count + composer.pendingAttachments.count
|
||||
< ContextAttachmentImporter.maximumAttachments
|
||||
else {
|
||||
composer.attachmentImportError = String(
|
||||
@@ -2199,15 +2231,17 @@ final class ConversationModel: Identifiable {
|
||||
if let activeRunID { messageRunIDs[message.id] = activeRunID }
|
||||
let pending = pendingSteering.first(where: { $0.id == message.id })
|
||||
let visibleText = pending?.draft ?? ContextChip.visiblePrompt(in: message.text)
|
||||
let contextChips = pending?.attachments.map {
|
||||
ContextChipReference(
|
||||
id: $0.id, kind: $0.kind, label: $0.label,
|
||||
attachment: $0.contextAttachment
|
||||
let contextChips =
|
||||
pending?.attachments.map {
|
||||
ContextChipReference(
|
||||
id: $0.id, kind: $0.kind, label: $0.label,
|
||||
attachment: $0.contextAttachment
|
||||
)
|
||||
}
|
||||
?? ContextChip.visibleReferences(
|
||||
in: message.text, images: message.images,
|
||||
contextAttachments: message.contextAttachments
|
||||
)
|
||||
} ?? ContextChip.visibleReferences(
|
||||
in: message.text, images: message.images,
|
||||
contextAttachments: message.contextAttachments
|
||||
)
|
||||
pendingSteering.removeAll { $0.id == message.id }
|
||||
messages.append(
|
||||
ChatMessage(
|
||||
@@ -2360,9 +2394,10 @@ final class ConversationModel: Identifiable {
|
||||
guard let source = transcript.first(where: { $0.id == sourceMessageID }),
|
||||
!source.toolCalls.isEmpty
|
||||
else { return }
|
||||
let stagedCallIDs = Set(stagedIDs.compactMap {
|
||||
stagedToolCards[$0]?.invocation.call.id
|
||||
})
|
||||
let stagedCallIDs = Set(
|
||||
stagedIDs.compactMap {
|
||||
stagedToolCards[$0]?.invocation.call.id
|
||||
})
|
||||
guard source.toolCalls.allSatisfy({ stagedCallIDs.contains($0.id) }) else {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -35,12 +35,13 @@ private struct ConversationAttachmentDropModifier: ViewModifier {
|
||||
@ViewBuilder
|
||||
private var decoration: some View {
|
||||
if targetedKind != nil {
|
||||
DropTargetOverlay(presentation: DropTargetPresentation(
|
||||
title: String(localized: "Attach to Chat"),
|
||||
detail: String(localized: "Files and images"),
|
||||
systemImage: targetedKind == .images
|
||||
? "photo.badge.plus" : "paperclip"
|
||||
))
|
||||
DropTargetOverlay(
|
||||
presentation: DropTargetPresentation(
|
||||
title: String(localized: "Attach to Chat"),
|
||||
detail: String(localized: "Files and images"),
|
||||
systemImage: targetedKind == .images
|
||||
? "photo.badge.plus" : "paperclip"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,10 +146,11 @@ private struct ChipView: View {
|
||||
styledContent(removable: false)
|
||||
}
|
||||
}
|
||||
.help(Text(
|
||||
remove == nil
|
||||
? String(localized: "Always included") : String(localized: "Remove attachment")
|
||||
))
|
||||
.help(
|
||||
Text(
|
||||
remove == nil
|
||||
? String(localized: "Always included") : String(localized: "Remove attachment")
|
||||
))
|
||||
}
|
||||
|
||||
private func styledContent(removable: Bool) -> some View {
|
||||
@@ -158,9 +159,9 @@ private struct ChipView: View {
|
||||
systemName: removable && (isHovering || isFocused)
|
||||
? "xmark" : chip.kind.symbol
|
||||
)
|
||||
.font(.caption2)
|
||||
.frame(width: 12, height: 12)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
.font(.caption2)
|
||||
.frame(width: 12, height: 12)
|
||||
.contentTransition(.symbolEffect(.replace))
|
||||
Text(chip.label).font(.caption).lineLimit(1)
|
||||
}
|
||||
.padding(.horizontal, Metrics.Space.s)
|
||||
@@ -214,9 +215,10 @@ struct FlowLayout: Layout {
|
||||
let width = proposal.width ?? naturalWidth
|
||||
let rows = arrange(subviews: subviews, in: width)
|
||||
let height = rows.last.map { $0.y + $0.height } ?? 0
|
||||
let usedWidth = rows.map { row in
|
||||
row.items.last.map { $0.x + $0.size.width } ?? 0
|
||||
}.max() ?? 0
|
||||
let usedWidth =
|
||||
rows.map { row in
|
||||
row.items.last.map { $0.x + $0.size.width } ?? 0
|
||||
}.max() ?? 0
|
||||
return CGSize(width: fillsWidth ? width : usedWidth, height: height)
|
||||
}
|
||||
|
||||
@@ -524,7 +526,7 @@ struct ComposerView: View {
|
||||
sessionContext: sessionContext, conversation: conversation,
|
||||
cancelPending: cancelAttachmentImport
|
||||
)
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
.transition(.opacity.combined(with: .move(edge: .bottom)))
|
||||
}
|
||||
|
||||
draftEditor($composer.draft)
|
||||
@@ -1075,9 +1077,11 @@ struct ComposerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var slashModelChoices: [(
|
||||
argument: String, provider: ModelProvider, model: AIModel
|
||||
)] {
|
||||
private var slashModelChoices:
|
||||
[(
|
||||
argument: String, provider: ModelProvider, model: AIModel
|
||||
)]
|
||||
{
|
||||
modelProviders.flatMap { provider in
|
||||
provider.assistantModels.map { model in
|
||||
(
|
||||
@@ -1376,9 +1380,10 @@ struct ComposerView: View {
|
||||
|
||||
private func handleContextPaste() -> Bool {
|
||||
let pasteboard = NSPasteboard.general
|
||||
let urls = pasteboard.readObjects(
|
||||
forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]
|
||||
) as? [URL] ?? []
|
||||
let urls =
|
||||
pasteboard.readObjects(
|
||||
forClasses: [NSURL.self], options: [.urlReadingFileURLsOnly: true]
|
||||
) as? [URL] ?? []
|
||||
if !urls.isEmpty {
|
||||
conversation.importAttachmentFiles(urls)
|
||||
return true
|
||||
@@ -1392,8 +1397,9 @@ struct ComposerView: View {
|
||||
guard let text = pasteboard.string(forType: .string),
|
||||
ContextAttachmentImporter.shouldAttachPastedText(text)
|
||||
else { return false }
|
||||
do { conversation.attach(try ContextAttachmentImporter.pastedText(text)) }
|
||||
catch { conversation.composer.attachmentImportError = error.localizedDescription }
|
||||
do { conversation.attach(try ContextAttachmentImporter.pastedText(text)) } catch {
|
||||
conversation.composer.attachmentImportError = error.localizedDescription
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1717,9 +1723,10 @@ struct ComposerView: View {
|
||||
private var modelOptionItems: [AssistantModelOptionListItem] {
|
||||
guard !modelProviders.isEmpty else { return [.empty] }
|
||||
return modelProviders.flatMap { provider in
|
||||
[.provider(provider)] + provider.assistantModels.map { model in
|
||||
.model(provider: provider, model: model, selected: isSelected(model, on: provider))
|
||||
}
|
||||
[.provider(provider)]
|
||||
+ provider.assistantModels.map { model in
|
||||
.model(provider: provider, model: model, selected: isSelected(model, on: provider))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1848,7 +1848,8 @@ private final class NativeFlowView: NSView {
|
||||
let available = max(1, bounds.width)
|
||||
var y: CGFloat = 0
|
||||
for row in rows(for: available) {
|
||||
let rowWidth = row.reduce(CGFloat.zero) { $0 + $1.size.width }
|
||||
let rowWidth =
|
||||
row.reduce(CGFloat.zero) { $0 + $1.size.width }
|
||||
+ spacing * CGFloat(max(0, row.count - 1))
|
||||
var x: CGFloat =
|
||||
horizontalAlignment == .trailing ? max(0, available - rowWidth) : 0
|
||||
@@ -1873,9 +1874,10 @@ private final class NativeFlowView: NSView {
|
||||
override var intrinsicContentSize: NSSize {
|
||||
let available = max(1, bounds.width > 0 ? bounds.width : superview?.bounds.width ?? 1)
|
||||
let rows = rows(for: available)
|
||||
let height = rows.reduce(CGFloat.zero) { total, row in
|
||||
total + (row.map(\.size.height).max() ?? 0)
|
||||
}
|
||||
let height =
|
||||
rows.reduce(CGFloat.zero) { total, row in
|
||||
total + (row.map(\.size.height).max() ?? 0)
|
||||
}
|
||||
+ spacing * CGFloat(max(0, rows.count - 1))
|
||||
return NSSize(width: NSView.noIntrinsicMetric, height: height)
|
||||
}
|
||||
@@ -1956,7 +1958,8 @@ private final class NativeDisclosureHeader: NSControl {
|
||||
"Transcript.ScanningTitle"
|
||||
)
|
||||
}
|
||||
let showsTransientTitle = transientMarkdownTitle != nil
|
||||
let showsTransientTitle =
|
||||
transientMarkdownTitle != nil
|
||||
&& transientUntil.map { $0 > .now } == true
|
||||
if showsTransientTitle, let transientMarkdownTitle {
|
||||
titleLabel.attributedStringValue = TermindMarkdownParser.singleLineAttributedText(
|
||||
@@ -2008,7 +2011,8 @@ private final class NativeDisclosureHeader: NSControl {
|
||||
row.topAnchor.constraint(equalTo: topAnchor),
|
||||
row.bottomAnchor.constraint(equalTo: bottomAnchor),
|
||||
])
|
||||
let accessibleTitle = showsTransientTitle
|
||||
let accessibleTitle =
|
||||
showsTransientTitle
|
||||
? transientMarkdownTitle.map(TermindMarkdownParser.plainText) ?? title
|
||||
: title
|
||||
setAccessibilityLabel(accessibleTitle)
|
||||
@@ -2167,10 +2171,11 @@ private final class NativeChipButton: NativeButton {
|
||||
|
||||
override func updateLayer() {
|
||||
super.updateLayer()
|
||||
layer?.backgroundColor = nativeHierarchicalColor(
|
||||
.quaternaryLabelColor,
|
||||
opacity: 0.5
|
||||
).cgColor
|
||||
layer?.backgroundColor =
|
||||
nativeHierarchicalColor(
|
||||
.quaternaryLabelColor,
|
||||
opacity: 0.5
|
||||
).cgColor
|
||||
}
|
||||
override func viewDidChangeEffectiveAppearance() {
|
||||
super.viewDidChangeEffectiveAppearance()
|
||||
@@ -2236,10 +2241,11 @@ private final class NativeRiskBadge: NSView {
|
||||
|
||||
override func updateLayer() {
|
||||
super.updateLayer()
|
||||
layer?.backgroundColor = TermindColor.resolvedNative(
|
||||
Self.color(assessment.risk).withAlphaComponent(0.12),
|
||||
for: effectiveAppearance
|
||||
).cgColor
|
||||
layer?.backgroundColor =
|
||||
TermindColor.resolvedNative(
|
||||
Self.color(assessment.risk).withAlphaComponent(0.12),
|
||||
for: effectiveAppearance
|
||||
).cgColor
|
||||
}
|
||||
|
||||
override func viewDidChangeEffectiveAppearance() {
|
||||
@@ -2282,10 +2288,11 @@ private final class NativeBadge: NSView {
|
||||
|
||||
override func updateLayer() {
|
||||
super.updateLayer()
|
||||
layer?.backgroundColor = TermindColor.resolvedNative(
|
||||
NSColor.quaternaryLabelColor.withAlphaComponent(0.5),
|
||||
for: effectiveAppearance
|
||||
).cgColor
|
||||
layer?.backgroundColor =
|
||||
TermindColor.resolvedNative(
|
||||
NSColor.quaternaryLabelColor.withAlphaComponent(0.5),
|
||||
for: effectiveAppearance
|
||||
).cgColor
|
||||
}
|
||||
|
||||
override func viewDidChangeEffectiveAppearance() {
|
||||
@@ -2380,10 +2387,11 @@ private final class NativeVerticalRule: NSView {
|
||||
|
||||
override func updateLayer() {
|
||||
super.updateLayer()
|
||||
layer?.backgroundColor = TermindColor.resolvedNative(
|
||||
.quaternaryLabelColor,
|
||||
for: effectiveAppearance
|
||||
).cgColor
|
||||
layer?.backgroundColor =
|
||||
TermindColor.resolvedNative(
|
||||
.quaternaryLabelColor,
|
||||
for: effectiveAppearance
|
||||
).cgColor
|
||||
}
|
||||
|
||||
override func viewDidChangeEffectiveAppearance() {
|
||||
@@ -2601,11 +2609,13 @@ private nonisolated final class NativeScanningMaskLayer: CALayer {
|
||||
let bandWidth = min(max(bounds.width * 0.32, 40), 96)
|
||||
let origin = -bandWidth + (bounds.width + bandWidth) * progress
|
||||
let alphas: [CGFloat] = [0, 0.34, 0.82, 1, 0.82, 0.34, 0]
|
||||
guard let gradient = CGGradient(
|
||||
colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
||||
colors: alphas.map { CGColor(gray: 1, alpha: $0) } as CFArray,
|
||||
locations: [0, 1 / 6, 2 / 6, 3 / 6, 4 / 6, 5 / 6, 1]
|
||||
) else { return }
|
||||
guard
|
||||
let gradient = CGGradient(
|
||||
colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
||||
colors: alphas.map { CGColor(gray: 1, alpha: $0) } as CFArray,
|
||||
locations: [0, 1 / 6, 2 / 6, 3 / 6, 4 / 6, 5 / 6, 1]
|
||||
)
|
||||
else { return }
|
||||
|
||||
context.saveGState()
|
||||
context.setBlendMode(.destinationOut)
|
||||
@@ -2760,16 +2770,18 @@ private final class NativeCard: NSView {
|
||||
required init?(coder: NSCoder) { fatalError() }
|
||||
override func updateLayer() {
|
||||
super.updateLayer()
|
||||
layer?.backgroundColor = TermindColor.resolvedNative(
|
||||
fillProvider(),
|
||||
for: effectiveAppearance
|
||||
).cgColor
|
||||
if let strokeProvider {
|
||||
layer?.borderWidth = 1
|
||||
layer?.borderColor = TermindColor.resolvedNative(
|
||||
strokeProvider(),
|
||||
layer?.backgroundColor =
|
||||
TermindColor.resolvedNative(
|
||||
fillProvider(),
|
||||
for: effectiveAppearance
|
||||
).cgColor
|
||||
if let strokeProvider {
|
||||
layer?.borderWidth = 1
|
||||
layer?.borderColor =
|
||||
TermindColor.resolvedNative(
|
||||
strokeProvider(),
|
||||
for: effectiveAppearance
|
||||
).cgColor
|
||||
} else {
|
||||
layer?.borderWidth = 0
|
||||
layer?.borderColor = nil
|
||||
|
||||
@@ -130,7 +130,8 @@ struct AssistantPanelView: View {
|
||||
|
||||
private var workspaceAssistantSurface: some View {
|
||||
let exitsAsWhole = window.sidebarAssistantConversation != nil
|
||||
let dropConversation = window.sidebarAssistantConversation
|
||||
let dropConversation =
|
||||
window.sidebarAssistantConversation
|
||||
?? window.sidebar.assistantDraft
|
||||
|
||||
return VStack(spacing: 0) {
|
||||
@@ -284,32 +285,32 @@ private struct AssistantConversationList: View {
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.onChange(of: visibleConversationIDs) {
|
||||
guard let renamingID, !visibleConversationIDs.contains(renamingID) else { return }
|
||||
cancelRename()
|
||||
}
|
||||
.onChange(of: selectedID) {
|
||||
guard case .active = scope,
|
||||
let id = selectedID,
|
||||
let conversation = window.assistantLibrary.conversation(id: id)
|
||||
else { return }
|
||||
setExpanded(true, for: conversation.isPinned ? .pinned : .chats)
|
||||
}
|
||||
.alert(
|
||||
"Delete Conversation?",
|
||||
isPresented: Binding(
|
||||
get: { deleteTarget != nil },
|
||||
set: { if !$0 { deleteTarget = nil } }
|
||||
), presenting: deleteTarget
|
||||
) { conversation in
|
||||
Button("Cancel", role: .cancel) { deleteTarget = nil }
|
||||
Button("Delete", role: .destructive) {
|
||||
window.deleteAssistantConversation(conversation.id)
|
||||
deleteTarget = nil
|
||||
.onChange(of: visibleConversationIDs) {
|
||||
guard let renamingID, !visibleConversationIDs.contains(renamingID) else { return }
|
||||
cancelRename()
|
||||
}
|
||||
.onChange(of: selectedID) {
|
||||
guard case .active = scope,
|
||||
let id = selectedID,
|
||||
let conversation = window.assistantLibrary.conversation(id: id)
|
||||
else { return }
|
||||
setExpanded(true, for: conversation.isPinned ? .pinned : .chats)
|
||||
}
|
||||
.alert(
|
||||
"Delete Conversation?",
|
||||
isPresented: Binding(
|
||||
get: { deleteTarget != nil },
|
||||
set: { if !$0 { deleteTarget = nil } }
|
||||
), presenting: deleteTarget
|
||||
) { conversation in
|
||||
Button("Cancel", role: .cancel) { deleteTarget = nil }
|
||||
Button("Delete", role: .destructive) {
|
||||
window.deleteAssistantConversation(conversation.id)
|
||||
deleteTarget = nil
|
||||
}
|
||||
} message: { _ in
|
||||
Text("This permanently removes the conversation and its scratch files.")
|
||||
}
|
||||
} message: { _ in
|
||||
Text("This permanently removes the conversation and its scratch files.")
|
||||
}
|
||||
}
|
||||
|
||||
private var visibleConversationIDs: Set<ConversationModel.ID> {
|
||||
@@ -357,13 +358,15 @@ private struct AssistantConversationList: View {
|
||||
// that asked for it tears down the Button mid-dispatch.
|
||||
animatesStructuralChanges: false,
|
||||
draggedID: { isSearching ? nil : Self.draggedConversationID(in: window) },
|
||||
onMove: isSearching ? nil : { id, target in
|
||||
setExpanded(true, for: target.section)
|
||||
window.moveAssistantConversation(
|
||||
id, toPinned: target.section == .pinned, before: target.beforeID
|
||||
)
|
||||
window.dragging = nil
|
||||
},
|
||||
onMove: isSearching
|
||||
? nil
|
||||
: { id, target in
|
||||
setExpanded(true, for: target.section)
|
||||
window.moveAssistantConversation(
|
||||
id, toPinned: target.section == .pinned, before: target.beforeID
|
||||
)
|
||||
window.dragging = nil
|
||||
},
|
||||
blankAreaMenu: { Self.blankAreaMenu(in: window, locale: locale) },
|
||||
isInteractive: isInteractive
|
||||
) { item in
|
||||
@@ -652,7 +655,8 @@ nonisolated enum AssistantConversationDropResolver {
|
||||
) -> AssistantConversationDropTarget? {
|
||||
guard items.count == rowFrames.count, !items.isEmpty else { return nil }
|
||||
|
||||
let index = rowFrames.firstIndex { $0.contains(CGPoint(x: $0.midX, y: pointerY)) }
|
||||
let index =
|
||||
rowFrames.firstIndex { $0.contains(CGPoint(x: $0.midX, y: pointerY)) }
|
||||
?? rowFrames.firstIndex { pointerY < $0.midY }
|
||||
?? (items.count - 1)
|
||||
|
||||
@@ -922,10 +926,11 @@ final class AssistantConversationListContainer: NSView {
|
||||
}
|
||||
|
||||
private func updateAppearanceLayers() {
|
||||
insertionLine.layer?.backgroundColor = TermindColor.resolvedNative(
|
||||
TermindColor.nativeAccent,
|
||||
for: effectiveAppearance
|
||||
).cgColor
|
||||
insertionLine.layer?.backgroundColor =
|
||||
TermindColor.resolvedNative(
|
||||
TermindColor.nativeAccent,
|
||||
for: effectiveAppearance
|
||||
).cgColor
|
||||
}
|
||||
|
||||
/// A row's own menu is installed by SwiftUI on its hosting view, which
|
||||
@@ -976,9 +981,11 @@ final class AssistantConversationListContainer: NSView {
|
||||
let point = listView.convert(sender.draggingLocation, from: nil)
|
||||
let items = listView.content
|
||||
let frames = items.indices.map { listView.rectForRow(at: $0) }
|
||||
guard let target = AssistantConversationDropResolver.target(
|
||||
items: items, rowFrames: frames, pointerY: point.y
|
||||
) else {
|
||||
guard
|
||||
let target = AssistantConversationDropResolver.target(
|
||||
items: items, rowFrames: frames, pointerY: point.y
|
||||
)
|
||||
else {
|
||||
clearTarget()
|
||||
return false
|
||||
}
|
||||
@@ -1172,8 +1179,8 @@ private struct CompactAssistantHeader: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
func compactAssistantHeader() -> some View {
|
||||
extension View {
|
||||
fileprivate func compactAssistantHeader() -> some View {
|
||||
modifier(CompactAssistantHeader())
|
||||
}
|
||||
}
|
||||
@@ -1282,11 +1289,12 @@ struct AssistantConversationRow: View {
|
||||
private var rowContent: some View {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Image(systemName: conversation.isArchived ? "archivebox" : "sparkles")
|
||||
.foregroundStyle(SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.secondary)
|
||||
))
|
||||
.foregroundStyle(
|
||||
SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.secondary)
|
||||
))
|
||||
if isRenaming {
|
||||
SidebarInlineRenameField(
|
||||
text: $renameTitle,
|
||||
@@ -1312,17 +1320,19 @@ struct AssistantConversationRow: View {
|
||||
// The row's own foreground, so on a selected card in a
|
||||
// key window this reads white with the title beside it
|
||||
// rather than as an accent-coloured glyph on accent.
|
||||
.foregroundStyle(SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.secondary)
|
||||
))
|
||||
.foregroundStyle(
|
||||
SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.secondary)
|
||||
))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(Text(
|
||||
conversation.isArchived
|
||||
? localized("Unarchive") : localized("Archive")
|
||||
))
|
||||
.help(
|
||||
Text(
|
||||
conversation.isArchived
|
||||
? localized("Unarchive") : localized("Archive")
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1354,7 +1364,7 @@ struct AssistantView: View {
|
||||
conversation: conversation,
|
||||
onNewChat: { window.openNewAssistantChat() }
|
||||
)
|
||||
.background(TermindColor.windowSurface)
|
||||
.background(TermindColor.windowSurface)
|
||||
} else {
|
||||
ConversationView(
|
||||
conversation: window.assistantLibrary.draftConversation,
|
||||
|
||||
@@ -59,12 +59,9 @@ final class ExternalAuthCoordinator {
|
||||
|
||||
private(set) var pending: [Pending] = []
|
||||
private(set) var presentationRevision: UInt64 = 0
|
||||
private var sshWaiters:
|
||||
[UUID: CheckedContinuation<AgentSSHAuthenticationResponse, Never>] = [:]
|
||||
private var secretWaiters:
|
||||
[UUID: CheckedContinuation<AgentUserInputResponse, Never>] = [:]
|
||||
private var hostKeyWaiters:
|
||||
[UUID: CheckedContinuation<AgentSSHHostKeyResponse, Never>] = [:]
|
||||
private var sshWaiters: [UUID: CheckedContinuation<AgentSSHAuthenticationResponse, Never>] = [:]
|
||||
private var secretWaiters: [UUID: CheckedContinuation<AgentUserInputResponse, Never>] = [:]
|
||||
private var hostKeyWaiters: [UUID: CheckedContinuation<AgentSSHHostKeyResponse, Never>] = [:]
|
||||
private var secretTimeoutTask: Task<Void, Never>?
|
||||
|
||||
var current: Pending? { pending.first }
|
||||
@@ -162,9 +159,10 @@ final class ExternalAuthCoordinator {
|
||||
|
||||
private func append(_ request: Request, clientName: String, runID: UUID) {
|
||||
let wasEmpty = pending.isEmpty
|
||||
pending.append(Pending(
|
||||
runID: runID, clientName: String(clientName.prefix(160)), request: request
|
||||
))
|
||||
pending.append(
|
||||
Pending(
|
||||
runID: runID, clientName: String(clientName.prefix(160)), request: request
|
||||
))
|
||||
if wasEmpty { presentCurrent() }
|
||||
}
|
||||
|
||||
|
||||
@@ -364,9 +364,10 @@ struct HostChooserView: View {
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.onChange(of: serialConfiguration.devicePath) {
|
||||
serialConfiguration.deviceName = serialPorts.first {
|
||||
$0.path == serialConfiguration.devicePath
|
||||
}?.name
|
||||
serialConfiguration.deviceName =
|
||||
serialPorts.first {
|
||||
$0.path == serialConfiguration.devicePath
|
||||
}?.name
|
||||
serialError = nil
|
||||
}
|
||||
.onChange(of: serialBaudRate) {
|
||||
@@ -396,9 +397,9 @@ struct HostChooserView: View {
|
||||
"\(chooser.selected.count) selected", locale: locale
|
||||
)
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.monospacedDigit()
|
||||
|
||||
Button(selectAllLabel) {
|
||||
toggleAllResults()
|
||||
@@ -623,26 +624,26 @@ struct HostChooserRow: View {
|
||||
isSelected: isChecked ?? isSelected
|
||||
) {
|
||||
HStack(spacing: Metrics.Space.m) {
|
||||
// Two channels on purpose, and they mean different things: the box
|
||||
// says what you have chosen, the row's background says where the
|
||||
// arrow keys are. Conflating them would make the cursor look like a
|
||||
// pick you had not made.
|
||||
if let isChecked {
|
||||
SelectionMark(mode: .multiple, isSelected: isChecked)
|
||||
}
|
||||
// Two channels on purpose, and they mean different things: the box
|
||||
// says what you have chosen, the row's background says where the
|
||||
// arrow keys are. Conflating them would make the cursor look like a
|
||||
// pick you had not made.
|
||||
if let isChecked {
|
||||
SelectionMark(mode: .multiple, isSelected: isChecked)
|
||||
}
|
||||
|
||||
StatusDot(status: app.hostLibrary.status(of: host.id))
|
||||
StatusDot(status: app.hostLibrary.status(of: host.id))
|
||||
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
|
||||
Text(host.name)
|
||||
.lineLimit(1)
|
||||
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
|
||||
Text(host.name)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(app.hostLibrary.loginAddress(for: host))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
Text(app.hostLibrary.loginAddress(for: host))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
|
||||
}
|
||||
} trailing: {
|
||||
|
||||
@@ -171,7 +171,8 @@ enum HostMenuEntries {
|
||||
NativeMenuEntry(title: String(localized: "Edit")) {
|
||||
window.openHostEditor(host: host)
|
||||
},
|
||||
NativeMenuEntry(title: String(localized: "Copy SSH Command"),
|
||||
NativeMenuEntry(
|
||||
title: String(localized: "Copy SSH Command"),
|
||||
isEnabled: app.hostLibrary.sshCommand(for: host) != nil
|
||||
) {
|
||||
guard let command = app.hostLibrary.sshCommand(for: host) else { return }
|
||||
|
||||
@@ -315,7 +315,8 @@ struct HostsPageView: ManagedCardPage {
|
||||
if nested == 0 {
|
||||
return String(localized: "This group contains \(hostCount).")
|
||||
}
|
||||
let groupCount = nested == 1
|
||||
let groupCount =
|
||||
nested == 1
|
||||
? String(localized: "1 nested group")
|
||||
: String(localized: "\(nested) nested groups")
|
||||
return String(localized: "This group contains \(hostCount) and \(groupCount). Nested groups will be deleted.")
|
||||
@@ -436,7 +437,7 @@ struct HostsPagePresentation: ManagedCardPresentationModifier {
|
||||
|
||||
private var confirmationTitle: Text {
|
||||
switch state.confirmation {
|
||||
case .deleteGroup(let group): Text("Delete “\(group.name)”?" )
|
||||
case .deleteGroup(let group): Text("Delete “\(group.name)”?")
|
||||
case nil: Text("")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,9 +102,7 @@ struct NewGroupSheet: View {
|
||||
}
|
||||
|
||||
private func close() {
|
||||
if let requestedBack { requestedBack() }
|
||||
else if let requestedClose { requestedClose() }
|
||||
else { dismiss() }
|
||||
if let requestedBack { requestedBack() } else if let requestedClose { requestedClose() } else { dismiss() }
|
||||
}
|
||||
|
||||
private func load() {
|
||||
|
||||
@@ -220,7 +220,8 @@ struct KeychainPageView: ManagedCardPage {
|
||||
private func delete(keys: [SSHKeyRef], identities: [Identity]) {
|
||||
for identity in identities {
|
||||
if !app.credentials.delete(identityID: identity.id) {
|
||||
state.errorMessage = app.credentials.storageError
|
||||
state.errorMessage =
|
||||
app.credentials.storageError
|
||||
?? String(localized: "The identity could not be deleted.")
|
||||
}
|
||||
}
|
||||
@@ -429,9 +430,10 @@ private struct SoftwareKeyEditor: View {
|
||||
existing: SSHKeyRef? = nil, title: String,
|
||||
symbol: String, close: @escaping () -> Void,
|
||||
loadPrivateKey: (() throws -> String)? = nil,
|
||||
save: @escaping (
|
||||
String, TagColor, KeyStorage, String, String?, String, SSHKeyMaterial
|
||||
) async throws -> SSHKeyRef
|
||||
save:
|
||||
@escaping (
|
||||
String, TagColor, KeyStorage, String, String?, String, SSHKeyMaterial
|
||||
) async throws -> SSHKeyRef
|
||||
) {
|
||||
self.existing = existing
|
||||
self.title = title
|
||||
@@ -943,7 +945,8 @@ private struct GenerateKeySheet: View {
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(
|
||||
isWorking || (storage != .secureEnclave && (!passwordsMatch || (!passphrase.isEmpty && rounds <= 0)))
|
||||
isWorking
|
||||
|| (storage != .secureEnclave && (!passwordsMatch || (!passphrase.isEmpty && rounds <= 0)))
|
||||
|| (storage == .secureEnclave && !SecureEnclaveKey.isAvailable)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -240,9 +240,7 @@ struct NewIdentitySheet: View {
|
||||
}
|
||||
|
||||
private func close() {
|
||||
if let requestedBack { requestedBack() }
|
||||
else if let requestedClose { requestedClose() }
|
||||
else { dismiss() }
|
||||
if let requestedBack { requestedBack() } else if let requestedClose { requestedClose() } else { dismiss() }
|
||||
}
|
||||
|
||||
private var isValid: Bool {
|
||||
|
||||
@@ -318,10 +318,11 @@ nonisolated struct QuickOpenResults: Equatable, Sendable {
|
||||
|
||||
func listItems(selectedID: AppCommandDescriptor.ID?) -> [QuickOpenListItem] {
|
||||
sections.flatMap { section in
|
||||
[.section(section)] + commands.compactMap { command in
|
||||
guard command.section == section else { return nil }
|
||||
return .command(command, isSelected: command.id == selectedID)
|
||||
}
|
||||
[.section(section)]
|
||||
+ commands.compactMap { command in
|
||||
guard command.section == section else { return nil }
|
||||
return .command(command, isSelected: command.id == selectedID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +331,8 @@ nonisolated struct QuickOpenResults: Equatable, Sendable {
|
||||
by offset: Int
|
||||
) -> AppCommandDescriptor.ID? {
|
||||
guard !commands.isEmpty else { return nil }
|
||||
let current = selectedID.flatMap { id in commands.firstIndex { $0.id == id } }
|
||||
let current =
|
||||
selectedID.flatMap { id in commands.firstIndex { $0.id == id } }
|
||||
?? (offset > 0 ? -1 : commands.count)
|
||||
let next = min(max(current + offset, 0), commands.count - 1)
|
||||
return commands[next].id
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import ListViewKit
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
import ListViewKit
|
||||
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
@@ -160,9 +160,7 @@ struct SFTPBrowserView: View {
|
||||
case compact
|
||||
|
||||
init(width: CGFloat) {
|
||||
if width >= 760 { self = .full }
|
||||
else if width >= 520 { self = .medium }
|
||||
else { self = .compact }
|
||||
if width >= 760 { self = .full } else if width >= 520 { self = .medium } else { self = .compact }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,14 +478,16 @@ struct SFTPBrowserView: View {
|
||||
let components = RemotePath.displayedComponents(
|
||||
of: sftpSession.path, home: sftpSession.homeResolution
|
||||
)
|
||||
let tailCount = switch layout {
|
||||
case .full: 3
|
||||
case .medium: 2
|
||||
case .compact: 1
|
||||
}
|
||||
let tailCount =
|
||||
switch layout {
|
||||
case .full: 3
|
||||
case .medium: 2
|
||||
case .compact: 1
|
||||
}
|
||||
let collapses = components.count > tailCount + 1
|
||||
let visible = collapses ? Array(components.suffix(tailCount)) : Array(components.dropFirst())
|
||||
let hidden = collapses
|
||||
let hidden =
|
||||
collapses
|
||||
? Array(components.dropFirst().dropLast(tailCount))
|
||||
: []
|
||||
|
||||
@@ -648,7 +648,8 @@ struct SFTPBrowserView: View {
|
||||
}
|
||||
|
||||
private func fileListItems(layout: Layout) -> [SFTPFileListItem] {
|
||||
let emphasized = isPaneFocused && !pathFocused
|
||||
let emphasized =
|
||||
isPaneFocused && !pathFocused
|
||||
&& controlActiveState == .key
|
||||
let layoutKey: Int
|
||||
switch layout {
|
||||
@@ -990,7 +991,8 @@ struct SFTPBrowserView: View {
|
||||
let files = sftpSession.filteredFiles
|
||||
guard !files.isEmpty else { return .ignored }
|
||||
let currentID = selectionCursor ?? sftpSession.selectedFiles.first?.id
|
||||
let currentIndex = currentID.flatMap { id in files.firstIndex { $0.id == id } }
|
||||
let currentIndex =
|
||||
currentID.flatMap { id in files.firstIndex { $0.id == id } }
|
||||
?? (offset > 0 ? -1 : files.count)
|
||||
let targetIndex = min(max(currentIndex + offset, 0), files.count - 1)
|
||||
let target = files[targetIndex]
|
||||
|
||||
@@ -389,7 +389,8 @@ final class SFTPSessionModel: Identifiable {
|
||||
return
|
||||
}
|
||||
status = reconnecting ? .reconnecting : .connecting
|
||||
hint = reconnecting
|
||||
hint =
|
||||
reconnecting
|
||||
? String(localized: "Reconnecting") : String(localized: "Connecting")
|
||||
connectionTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
@@ -490,7 +491,8 @@ final class SFTPSessionModel: Identifiable {
|
||||
authenticationHost = host
|
||||
}
|
||||
let submitted = self.suppliedAuthentications[authenticationHost.id]
|
||||
let retryNeedsPassphrase = submitted?.passphrase == nil
|
||||
let retryNeedsPassphrase =
|
||||
submitted?.passphrase == nil
|
||||
&& (submitted?.keyID ?? store.identity(for: authenticationHost)?.keyID)
|
||||
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
|
||||
let returnsToIdentity = submitted?.passphrase != nil
|
||||
@@ -1175,7 +1177,9 @@ final class SFTPSessionModel: Identifiable {
|
||||
}
|
||||
}
|
||||
for var job in batch.jobs {
|
||||
if remoteResolution.skipped.contains(where: { job.remotePath == $0 || job.remotePath.hasPrefix($0 + "/") })
|
||||
if remoteResolution.skipped.contains(where: {
|
||||
job.remotePath == $0 || job.remotePath.hasPrefix($0 + "/")
|
||||
})
|
||||
|| skippedLocalPrefixes.contains(where: {
|
||||
job.localURL.path == $0.path || job.localURL.path.hasPrefix($0.path + "/")
|
||||
})
|
||||
@@ -1198,11 +1202,13 @@ final class SFTPSessionModel: Identifiable {
|
||||
}
|
||||
if job.hasConflict {
|
||||
if job.direction == .upload {
|
||||
guard let resolved = try await resolveRemoteConflict(
|
||||
path: job.remotePath,
|
||||
conflictingTargetIsDirectory: job.conflictingTargetIsDirectory,
|
||||
policy: policy, client: client
|
||||
) else { continue }
|
||||
guard
|
||||
let resolved = try await resolveRemoteConflict(
|
||||
path: job.remotePath,
|
||||
conflictingTargetIsDirectory: job.conflictingTargetIsDirectory,
|
||||
policy: policy, client: client
|
||||
)
|
||||
else { continue }
|
||||
job.remotePath = resolved.path
|
||||
job.hasConflict = resolved.overwrite
|
||||
} else {
|
||||
@@ -1268,11 +1274,13 @@ final class SFTPSessionModel: Identifiable {
|
||||
job.hasConflict = false
|
||||
}
|
||||
if job.hasConflict {
|
||||
guard let resolved = try await resolveRemoteConflict(
|
||||
path: job.destinationPath,
|
||||
conflictingTargetIsDirectory: job.conflictingTargetIsDirectory,
|
||||
policy: policy, client: client
|
||||
) else { continue }
|
||||
guard
|
||||
let resolved = try await resolveRemoteConflict(
|
||||
path: job.destinationPath,
|
||||
conflictingTargetIsDirectory: job.conflictingTargetIsDirectory,
|
||||
policy: policy, client: client
|
||||
)
|
||||
else { continue }
|
||||
job.destinationPath = resolved.path
|
||||
job.hasConflict = resolved.overwrite
|
||||
}
|
||||
@@ -1354,7 +1362,8 @@ final class SFTPSessionModel: Identifiable {
|
||||
let submission = suppliedAuthentications[host.id]
|
||||
let selectedKeyID = submission?.keyID ?? identity?.keyID
|
||||
let username = submission?.username ?? identity?.username ?? ""
|
||||
let needsPassphrase = requiresPassphrase
|
||||
let needsPassphrase =
|
||||
requiresPassphrase
|
||||
?? (!username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
&& selectedKeyID.flatMap { store.key($0) }?.requiresPassphraseEntry == true)
|
||||
pendingAuthentication = SSHAuthenticationPrompt(
|
||||
@@ -1483,7 +1492,9 @@ final class SFTPSessionModel: Identifiable {
|
||||
for original in directories {
|
||||
if result.skipped.contains(where: {
|
||||
original == $0 || original.hasPrefix($0 + "/")
|
||||
}) { continue }
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
var destination = original
|
||||
for mapping in result.mappings
|
||||
where destination == mapping.0 || destination.hasPrefix(mapping.0 + "/") {
|
||||
|
||||
@@ -103,9 +103,12 @@ struct MCPServerEditorPage: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
} else if namespaceID.isEmpty {
|
||||
Label("Name must produce an ID containing ASCII letters or numbers.", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
Label(
|
||||
"Name must produce an ID containing ASCII letters or numbers.",
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
} else if hasNamespaceConflict {
|
||||
Label("Another MCP server already uses this ID.", systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
@@ -297,7 +300,9 @@ struct MCPServerEditorPage: View {
|
||||
|
||||
private func binding<Value>(_ keyPath: WritableKeyPath<MCPServer, Value>) -> Binding<Value> {
|
||||
Binding(
|
||||
get: { app.aiConfiguration.mcpServer(serverID)?[keyPath: keyPath] ?? MCPServer(name: "")[keyPath: keyPath] },
|
||||
get: {
|
||||
app.aiConfiguration.mcpServer(serverID)?[keyPath: keyPath] ?? MCPServer(name: "")[keyPath: keyPath]
|
||||
},
|
||||
set: { newValue in
|
||||
guard var server = app.aiConfiguration.mcpServer(serverID) else { return }
|
||||
server[keyPath: keyPath] = newValue
|
||||
@@ -336,7 +341,8 @@ private struct ToolRow: View {
|
||||
|
||||
private var qualifiedToolName: String {
|
||||
guard let server = app.aiConfiguration.mcpServer(serverID) else { return tool.id }
|
||||
let leaf = MCPToolNaming.functionNames(for: server.tools)[tool.id]
|
||||
let leaf =
|
||||
MCPToolNaming.functionNames(for: server.tools)[tool.id]
|
||||
?? MCPToolNaming.functionName(from: tool.id)
|
||||
return MCPToolNaming.qualifiedName(
|
||||
namespace: server.effectiveNamespaceID, function: leaf
|
||||
@@ -384,8 +390,8 @@ private struct ToolRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private extension MCPTool.AccessPolicy {
|
||||
var displayName: LocalizedStringKey {
|
||||
extension MCPTool.AccessPolicy {
|
||||
fileprivate var displayName: LocalizedStringKey {
|
||||
switch self {
|
||||
case .disabled: "Disabled"
|
||||
case .alwaysAsk: "Always Ask"
|
||||
|
||||
@@ -13,10 +13,12 @@ struct MCPServerSettings: View {
|
||||
|
||||
var body: some View {
|
||||
Section("Server") {
|
||||
Toggle("Enable", isOn: Binding(
|
||||
get: { server.isEnabled && app.proAccess.canUse(.mcpServer) },
|
||||
set: { server.setEnabled($0) }
|
||||
))
|
||||
Toggle(
|
||||
"Enable",
|
||||
isOn: Binding(
|
||||
get: { server.isEnabled && app.proAccess.canUse(.mcpServer) },
|
||||
set: { server.setEnabled($0) }
|
||||
))
|
||||
|
||||
LabeledContent("Status") {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
@@ -28,10 +30,13 @@ struct MCPServerSettings: View {
|
||||
}
|
||||
LabeledContent("Connected clients", value: "\(server.connectedClients)")
|
||||
LabeledContent("Port") {
|
||||
TextField("Port", value: Binding(
|
||||
get: { server.port },
|
||||
set: { server.setPort($0) }
|
||||
), format: .number.grouping(.never))
|
||||
TextField(
|
||||
"Port",
|
||||
value: Binding(
|
||||
get: { server.port },
|
||||
set: { server.setPort($0) }
|
||||
), format: .number.grouping(.never)
|
||||
)
|
||||
.frame(width: 100)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
@@ -68,15 +73,20 @@ struct MCPServerSettings: View {
|
||||
|
||||
Section("Exposed Tools") {
|
||||
ForEach(MCPServerTools.exportedNames.sorted(), id: \.self) { name in
|
||||
Toggle(name, isOn: Binding(
|
||||
get: { !server.disabledTools.contains(name) },
|
||||
set: { server.setToolEnabled($0, name: name) }
|
||||
))
|
||||
Toggle(
|
||||
name,
|
||||
isOn: Binding(
|
||||
get: { !server.disabledTools.contains(name) },
|
||||
set: { server.setToolEnabled($0, name: name) }
|
||||
)
|
||||
)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
Text("Tool changes are enforced immediately. Existing clients see the new list when they reconnect or list tools again.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(
|
||||
"Tool changes are enforced immediately. Existing clients see the new list when they reconnect or list tools again."
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Call History") {
|
||||
@@ -139,7 +149,9 @@ private struct ExternalToolCallRow: View {
|
||||
HStack {
|
||||
Text(row.clientName)
|
||||
Text(row.startedAt, style: .relative)
|
||||
if let duration = row.duration { Text(duration, format: .number.precision(.fractionLength(2))) + Text("s") }
|
||||
if let duration = row.duration {
|
||||
Text(duration, format: .number.precision(.fractionLength(2))) + Text("s")
|
||||
}
|
||||
}
|
||||
.font(.caption2).foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
@@ -196,7 +196,9 @@ struct ModelListEditor: View {
|
||||
/// Roles whose model is among the ticked ones.
|
||||
private var brokenRoles: [AIRole] {
|
||||
AIRole.allCases.filter { role in
|
||||
guard let ref = app.aiConfiguration.assignment(for: role), ref.modelProviderID == modelProviderID else { return false }
|
||||
guard let ref = app.aiConfiguration.assignment(for: role), ref.modelProviderID == modelProviderID else {
|
||||
return false
|
||||
}
|
||||
return state.checked.contains(ref.modelID)
|
||||
}
|
||||
}
|
||||
@@ -507,7 +509,8 @@ struct ModelListEditor: View {
|
||||
}
|
||||
|
||||
private func reasoningLevelRow(_ effort: ReasoningEffort, model: AIModel) -> some View {
|
||||
let mapping = model.reasoningProfile?.mapping(for: effort)
|
||||
let mapping =
|
||||
model.reasoningProfile?.mapping(for: effort)
|
||||
?? ReasoningLevelMapping(isSupported: false)
|
||||
let supportedCount = model.reasoningProfile?.levels.values.filter(\.isSupported).count ?? 0
|
||||
|
||||
@@ -603,7 +606,8 @@ struct ModelListEditor: View {
|
||||
) -> Binding<Value> where Value: Hashable {
|
||||
Binding(
|
||||
get: {
|
||||
app.aiConfiguration.modelProvider(modelProviderID)?.model(model.id)?[keyPath: keyPath] ?? model[keyPath: keyPath]
|
||||
app.aiConfiguration.modelProvider(modelProviderID)?.model(model.id)?[keyPath: keyPath]
|
||||
?? model[keyPath: keyPath]
|
||||
},
|
||||
set: { newValue in
|
||||
guard var updated = app.aiConfiguration.modelProvider(modelProviderID)?.model(model.id) else { return }
|
||||
@@ -751,8 +755,8 @@ struct ModelListEditor: View {
|
||||
}
|
||||
}
|
||||
|
||||
private extension AIModel {
|
||||
var withoutReasoningOverride: AIModel {
|
||||
extension AIModel {
|
||||
fileprivate var withoutReasoningOverride: AIModel {
|
||||
var copy = self
|
||||
copy.reasoningProfile = nil
|
||||
return copy
|
||||
@@ -825,7 +829,7 @@ private struct CandidatePicker: View {
|
||||
let ids = filtered.map(\.id)
|
||||
chooser.toggleAll(visible: ids, enabled: Set(selectable.map(\.id)))
|
||||
}
|
||||
.disabled(selectable.isEmpty)
|
||||
.disabled(selectable.isEmpty)
|
||||
Button("Select None") { chooser.selected.subtract(filtered.map(\.id)) }
|
||||
.disabled(chooser.selected.isEmpty)
|
||||
Spacer(minLength: 0)
|
||||
@@ -844,7 +848,7 @@ private struct CandidatePicker: View {
|
||||
isChosen: item.isChosen
|
||||
) {
|
||||
chooser.activate(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,8 +871,8 @@ private struct CandidatePicker: View {
|
||||
Button(
|
||||
chooser.selected.isEmpty ? "Add" : "Add \(chooser.selected.count)"
|
||||
) { add(chooser.selected) }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(chooser.selected.isEmpty)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(chooser.selected.isEmpty)
|
||||
}
|
||||
.padding(Metrics.Space.l)
|
||||
}
|
||||
|
||||
@@ -350,7 +350,10 @@ struct ModelProviderEditorPage: View {
|
||||
/// the provider exists.
|
||||
private func binding<Value>(_ keyPath: WritableKeyPath<ModelProvider, Value>) -> Binding<Value> {
|
||||
Binding(
|
||||
get: { app.aiConfiguration.modelProvider(modelProviderID)?[keyPath: keyPath] ?? ModelProvider(name: "")[keyPath: keyPath] },
|
||||
get: {
|
||||
app.aiConfiguration.modelProvider(modelProviderID)?[keyPath: keyPath]
|
||||
?? ModelProvider(name: "")[keyPath: keyPath]
|
||||
},
|
||||
set: { newValue in
|
||||
guard var provider = app.aiConfiguration.modelProvider(modelProviderID) else { return }
|
||||
provider[keyPath: keyPath] = newValue
|
||||
|
||||
@@ -91,13 +91,13 @@ struct SettingsPageView: View {
|
||||
|
||||
var body: some View {
|
||||
detail.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(TermindColor.windowSurface)
|
||||
.onChange(of: window.settingsRoute) { oldRoute, newRoute in
|
||||
cleanUpDraft(from: oldRoute, movingTo: newRoute)
|
||||
}
|
||||
.onDisappear {
|
||||
cleanUpDraft(from: window.settingsRoute, movingTo: .index)
|
||||
}
|
||||
.background(TermindColor.windowSurface)
|
||||
.onChange(of: window.settingsRoute) { oldRoute, newRoute in
|
||||
cleanUpDraft(from: oldRoute, movingTo: newRoute)
|
||||
}
|
||||
.onDisappear {
|
||||
cleanUpDraft(from: window.settingsRoute, movingTo: .index)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -264,7 +264,9 @@ private struct SettingsIndexPage: View {
|
||||
}
|
||||
|
||||
private func categoryButton(_ category: SettingsCategory) -> some View {
|
||||
Button { select(category) } label: {
|
||||
Button {
|
||||
select(category)
|
||||
} label: {
|
||||
SettingsCategoryRow(category: category)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
@@ -292,9 +294,11 @@ private struct SettingsCategoryRow: View {
|
||||
|
||||
enum SettingsDraftPolicy {
|
||||
static func shouldDiscard(_ provider: ModelProvider) -> Bool {
|
||||
let hasDefaultName = provider.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hasDefaultName =
|
||||
provider.name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
== String(localized: "New Provider")
|
||||
let hasConfiguration = !provider.baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
let hasConfiguration =
|
||||
!provider.baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| !provider.apiPath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
|| provider.hasCredential || !provider.models.isEmpty
|
||||
return hasDefaultName && !hasConfiguration
|
||||
@@ -426,8 +430,10 @@ private struct ProSettings: View {
|
||||
}
|
||||
|
||||
Section("Termind Pro") {
|
||||
Text("Unlock multiple-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and private iCloud Vault sync with one permanent purchase.")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(
|
||||
"Unlock multiple-pane workspaces, the AI Assistant, terminal drop uploads, MCP, and private iCloud Vault sync with one permanent purchase."
|
||||
)
|
||||
.foregroundStyle(.secondary)
|
||||
if app.proAccess.status.canPurchase {
|
||||
Button(purchaseEntryTitle) { app.proAccess.presentPaywall() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
@@ -500,8 +506,10 @@ private struct SidebarSettings: View {
|
||||
|
||||
private struct SFTPSettings: View {
|
||||
@Environment(AppModel.self) private var app
|
||||
@AppStorage(SettingsKey.sftpGlobalTransferLimit) private var globalLimit = SettingsKey.defaultSFTPGlobalTransferLimit
|
||||
@AppStorage(SettingsKey.sftpPerHostTransferLimit) private var perHostLimit = SettingsKey.defaultSFTPPerHostTransferLimit
|
||||
@AppStorage(SettingsKey.sftpGlobalTransferLimit) private var globalLimit = SettingsKey
|
||||
.defaultSFTPGlobalTransferLimit
|
||||
@AppStorage(SettingsKey.sftpPerHostTransferLimit) private var perHostLimit = SettingsKey
|
||||
.defaultSFTPPerHostTransferLimit
|
||||
|
||||
var body: some View {
|
||||
Section("SFTP") {
|
||||
@@ -519,8 +527,10 @@ private struct SFTPSettings: View {
|
||||
Text(perHostLimit, format: .number).monospacedDigit()
|
||||
}
|
||||
}
|
||||
Text("Changes apply to queued transfers. Active transfers continue unchanged. Unsaved credentials limit a session to one transfer at a time.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Text(
|
||||
"Changes apply to queued transfers. Active transfers continue unchanged. Unsaved credentials limit a session to one transfer at a time."
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.onAppear { applyLimits() }
|
||||
.onChange(of: globalLimit) { applyLimits() }
|
||||
@@ -529,9 +539,15 @@ private struct SFTPSettings: View {
|
||||
|
||||
private func applyLimits() {
|
||||
let normalizedGlobal = min(max(globalLimit, 1), 8)
|
||||
if globalLimit != normalizedGlobal { globalLimit = normalizedGlobal; return }
|
||||
if globalLimit != normalizedGlobal {
|
||||
globalLimit = normalizedGlobal
|
||||
return
|
||||
}
|
||||
let normalizedPerHost = min(max(perHostLimit, 1), normalizedGlobal)
|
||||
if perHostLimit != normalizedPerHost { perHostLimit = normalizedPerHost; return }
|
||||
if perHostLimit != normalizedPerHost {
|
||||
perHostLimit = normalizedPerHost
|
||||
return
|
||||
}
|
||||
app.sftpTransfers.updateLimits(global: globalLimit, perHost: perHostLimit)
|
||||
}
|
||||
}
|
||||
@@ -615,8 +631,12 @@ private struct TerminalSettings: View {
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if TermindTerminalThemeCatalog.darkTheme(named: darkTheme) == nil { darkTheme = SettingsKey.defaultTerminalThemeDark }
|
||||
if TermindTerminalThemeCatalog.lightTheme(named: lightTheme) == nil { lightTheme = SettingsKey.defaultTerminalThemeLight }
|
||||
if TermindTerminalThemeCatalog.darkTheme(named: darkTheme) == nil {
|
||||
darkTheme = SettingsKey.defaultTerminalThemeDark
|
||||
}
|
||||
if TermindTerminalThemeCatalog.lightTheme(named: lightTheme) == nil {
|
||||
lightTheme = SettingsKey.defaultTerminalThemeLight
|
||||
}
|
||||
}
|
||||
.onChange(of: shellIntegration) { _, isEnabled in
|
||||
shellIntegrationChanged(isEnabled)
|
||||
@@ -636,8 +656,10 @@ private struct SyncSettings: View {
|
||||
LabeledContent("Status") { statusLabel }
|
||||
|
||||
if case .accountChangeConfirmationRequired = status {
|
||||
Text("This Mac is signed in to a different iCloud account. Your local Vault will not be uploaded until you confirm the merge.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Text(
|
||||
"This Mac is signed in to a different iCloud account. Your local Vault will not be uploaded until you confirm the merge."
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Button("Merge with This iCloud Account") {
|
||||
app.vaultSync?.confirmAccountSwitchMerge()
|
||||
}
|
||||
@@ -645,8 +667,10 @@ private struct SyncSettings: View {
|
||||
Button("Retry") { app.vaultSync?.retry() }
|
||||
}
|
||||
|
||||
Text("Hosts, identities, public key metadata, Known Hosts, snippets, and port forwards sync through your private iCloud database. Passwords and private keys remain protected separately by Keychain. Credentials marked “This Mac” will be missing on other Macs.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Text(
|
||||
"Hosts, identities, public key metadata, Known Hosts, snippets, and port forwards sync through your private iCloud database. Passwords and private keys remain protected separately by Keychain. Credentials marked “This Mac” will be missing on other Macs."
|
||||
)
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ struct TerminalCustomConfigurationEditorPage: View {
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
Spacer()
|
||||
Button { self.importError = nil } label: {
|
||||
Button {
|
||||
self.importError = nil
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
@@ -555,18 +555,18 @@ private struct SnippetsSidebarView: View {
|
||||
SSHSessionSnippetRow(
|
||||
snippet: snippet, isSelected: selectedSnippetID == snippet.id
|
||||
)
|
||||
.termindSidebarRow(isSelected: selectedSnippetID == snippet.id) {
|
||||
[
|
||||
NativeMenuEntry(title: localized("Copy")) {
|
||||
copyToPasteboard(snippet.body)
|
||||
},
|
||||
NativeMenuEntry(title: localized("Execute")) {
|
||||
requestExecution(snippet)
|
||||
},
|
||||
]
|
||||
}
|
||||
.onTapGesture(count: 2) { requestExecution(snippet) }
|
||||
.onTapGesture { selectedSnippetID = snippet.id }
|
||||
.termindSidebarRow(isSelected: selectedSnippetID == snippet.id) {
|
||||
[
|
||||
NativeMenuEntry(title: localized("Copy")) {
|
||||
copyToPasteboard(snippet.body)
|
||||
},
|
||||
NativeMenuEntry(title: localized("Execute")) {
|
||||
requestExecution(snippet)
|
||||
},
|
||||
]
|
||||
}
|
||||
.onTapGesture(count: 2) { requestExecution(snippet) }
|
||||
.onTapGesture { selectedSnippetID = snippet.id }
|
||||
}
|
||||
|
||||
private var displayedSnippets: [Snippet] {
|
||||
@@ -598,9 +598,10 @@ private struct SnippetsSidebarView: View {
|
||||
|
||||
private func requestExecution(_ snippet: Snippet) {
|
||||
if snippet.targetHostIDs.isEmpty {
|
||||
window.hostChooser = .snippet(SnippetHostChooserRequest(
|
||||
snippetIDs: [snippet.id], strategy: .fillMissingTargets
|
||||
))
|
||||
window.hostChooser = .snippet(
|
||||
SnippetHostChooserRequest(
|
||||
snippetIDs: [snippet.id], strategy: .fillMissingTargets
|
||||
))
|
||||
return
|
||||
}
|
||||
if CommandRiskClassifier.assess(snippet.body).requiresSecondConfirmation {
|
||||
@@ -642,11 +643,13 @@ private struct SidebarCard<Trailing: View>: View {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
if let symbol {
|
||||
Image(systemName: symbol)
|
||||
.foregroundStyle(SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: symbolTint ?? AnyShapeStyle(.secondary)
|
||||
))
|
||||
.foregroundStyle(
|
||||
SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: symbolTint ?? AnyShapeStyle(.secondary)
|
||||
)
|
||||
)
|
||||
.frame(width: Metrics.Sidebar.rowIcon)
|
||||
}
|
||||
|
||||
@@ -657,11 +660,13 @@ private struct SidebarCard<Trailing: View>: View {
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(subtitleIsMonospaced ? .caption.monospaced() : .caption)
|
||||
.foregroundStyle(SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.secondary)
|
||||
))
|
||||
.foregroundStyle(
|
||||
SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.secondary)
|
||||
)
|
||||
)
|
||||
.lineLimit(1)
|
||||
.truncationMode(truncatesSubtitleInMiddle ? .middle : .tail)
|
||||
}
|
||||
@@ -899,7 +904,9 @@ private struct SSHCommandHistorySidebar: View {
|
||||
}
|
||||
|
||||
private func insert(_ entry: CommandHistoryEntry) {
|
||||
guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else { return }
|
||||
guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else {
|
||||
return
|
||||
}
|
||||
workspace.deliver(entry.command, from: sshSession)
|
||||
}
|
||||
|
||||
@@ -912,7 +919,9 @@ private struct SSHCommandHistorySidebar: View {
|
||||
}
|
||||
|
||||
private func executeNow(_ entry: CommandHistoryEntry) {
|
||||
guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else { return }
|
||||
guard let workspace = window.selectedWorkspace, let sshSession = workspace.focusedPane.sshSession else {
|
||||
return
|
||||
}
|
||||
workspace.deliver(entry.command, from: sshSession, submit: true)
|
||||
destructiveEntry = nil
|
||||
}
|
||||
|
||||
@@ -819,7 +819,7 @@ private struct SnippetTargetPickerSheet: View {
|
||||
isChecked: item.isChecked
|
||||
)
|
||||
.onTapGesture { chooser.activate(item.id) }
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Metrics.Space.l)
|
||||
.padding(.bottom, Metrics.Space.s)
|
||||
}
|
||||
|
||||
@@ -229,10 +229,11 @@ struct StatusBarView: View {
|
||||
Text(status.label)
|
||||
}
|
||||
}
|
||||
.help(Text(
|
||||
host.map { app.hostLibrary.loginAddress(for: $0) }
|
||||
?? serialSession?.subtitle ?? status.label
|
||||
))
|
||||
.help(
|
||||
Text(
|
||||
host.map { app.hostLibrary.loginAddress(for: $0) }
|
||||
?? serialSession?.subtitle ?? status.label
|
||||
))
|
||||
}
|
||||
|
||||
private var host: Host? { pane?.hostID.flatMap { app.hostLibrary.host($0) } }
|
||||
@@ -611,9 +612,12 @@ private struct TransferCenterNativeList: NSViewRepresentable {
|
||||
return Metrics.TransferCenter.estimatedRowHeight
|
||||
}
|
||||
measurement.rootView = root
|
||||
return ceil(measurement.sizeThatFits(in: CGSize(
|
||||
width: width, height: .greatestFiniteMagnitude
|
||||
)).height)
|
||||
return ceil(
|
||||
measurement.sizeThatFits(
|
||||
in: CGSize(
|
||||
width: width, height: .greatestFiniteMagnitude
|
||||
)
|
||||
).height)
|
||||
}
|
||||
|
||||
private func configure(_ row: HostingListRow, item: TransferCenterListItem) {
|
||||
|
||||
@@ -243,7 +243,8 @@
|
||||
// search-field → terminal → search-field focus flash.
|
||||
let window = terminalView.window
|
||||
let editor = window?.firstResponder as? NSTextView
|
||||
let textField = editor?.isFieldEditor == true
|
||||
let textField =
|
||||
editor?.isFieldEditor == true
|
||||
? editor?.delegate as? NSTextField
|
||||
: nil
|
||||
let selection = editor?.selectedRange
|
||||
|
||||
@@ -673,7 +673,8 @@ final class SSHSessionModel: Identifiable {
|
||||
{
|
||||
let identity = store.identity(for: jump)
|
||||
let submitted = suppliedAuthentications[jump.id]
|
||||
let retryNeedsPassphrase = submitted?.passphrase == nil
|
||||
let retryNeedsPassphrase =
|
||||
submitted?.passphrase == nil
|
||||
&& (submitted?.keyID ?? identity?.keyID)
|
||||
.flatMap { store.key($0) }?.requiresPassphraseEntry == true
|
||||
connectionAttemptID = nil
|
||||
@@ -768,9 +769,11 @@ final class SSHSessionModel: Identifiable {
|
||||
}
|
||||
let authenticationIdentity = store.identity(for: authenticationHost)
|
||||
let submitted = suppliedAuthentications[authenticationHost.id]
|
||||
let authenticationKeyID = submitted?.keyID
|
||||
let authenticationKeyID =
|
||||
submitted?.keyID
|
||||
?? authenticationIdentity?.keyID ?? selectedKeyID
|
||||
let retryNeedsPassphrase = submitted?.passphrase == nil
|
||||
let retryNeedsPassphrase =
|
||||
submitted?.passphrase == nil
|
||||
&& authenticationKeyID.flatMap { store.key($0) }?
|
||||
.requiresPassphraseEntry == true
|
||||
let returnsToIdentity = submitted?.passphrase != nil
|
||||
|
||||
@@ -104,7 +104,10 @@ final class SerialSessionModel: Identifiable {
|
||||
}
|
||||
)
|
||||
try Task.checkCancellation()
|
||||
guard let self else { await opened.close(); return }
|
||||
guard let self else {
|
||||
await opened.close()
|
||||
return
|
||||
}
|
||||
self.connection = opened
|
||||
self.status = .connected
|
||||
self.connectionJourney.succeed()
|
||||
@@ -146,8 +149,9 @@ final class SerialSessionModel: Identifiable {
|
||||
terminalBackend.receive(echo)
|
||||
}
|
||||
Task {
|
||||
do { try await connection.write(output) }
|
||||
catch { await MainActor.run { self.fail(error.localizedDescription) } }
|
||||
do { try await connection.write(output) } catch {
|
||||
await MainActor.run { self.fail(error.localizedDescription) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,64 +44,64 @@ struct TerminalPlaceholderView: View {
|
||||
terminalSurface
|
||||
.overlay(alignment: .bottom) { uploadDecoration }
|
||||
.overlay { connectionJourney }
|
||||
.animation(Motion.panel, value: uploadNotice)
|
||||
.background(terminalAppearance.background)
|
||||
.onAppear {
|
||||
// The mounted view is the final source of truth after any drag,
|
||||
// merge, detach, or SwiftUI tree rebuild. Reassert the relationship
|
||||
// here so a stale layout callback cannot leave live keystrokes on a
|
||||
// sshSession whose router was cleared while it moved.
|
||||
sshSession.inputRouter = workspace
|
||||
sshSession.terminalState.isSurfaceVisible = isVisible
|
||||
terminalFocused = requestsFocus
|
||||
sshSession.terminalState.applyAppearance(terminalAppearance.preferences)
|
||||
if sshSession.status == .connecting { sshSession.connect(using: app.hostLibrary) }
|
||||
if requestsFocus { sshSession.requestTerminalFocus() }
|
||||
}
|
||||
.onChange(of: isVisible) { _, visible in
|
||||
sshSession.terminalState.isSurfaceVisible = visible
|
||||
}
|
||||
.onChange(of: requestsFocus) { _, focused in
|
||||
terminalFocused = focused
|
||||
if focused { sshSession.requestTerminalFocus() }
|
||||
}
|
||||
.onChange(of: externalFileDrop?.id) {
|
||||
guard let externalFileDrop else { return }
|
||||
_ = receiveDroppedURLs(externalFileDrop.urls)
|
||||
}
|
||||
.onDisappear {
|
||||
sshSession.terminalState.isSurfaceVisible = false
|
||||
terminalFocused = false
|
||||
uploadNoticeTask?.cancel()
|
||||
uploadPreparationTask?.cancel()
|
||||
cancelPendingUpload()
|
||||
}
|
||||
.onChange(of: terminalAppearance.preferences) { _, preferences in
|
||||
sshSession.terminalState.applyAppearance(preferences)
|
||||
}
|
||||
.confirmationDialog(
|
||||
uploadConflictTitle,
|
||||
isPresented: $uploadConflictPresented,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Replace") { enqueuePendingUpload(.replace) }
|
||||
Button("Skip Existing") { enqueuePendingUpload(.skip) }
|
||||
Button("Keep Both") { enqueuePendingUpload(.keepBoth) }
|
||||
Button("Cancel", role: .cancel) { cancelPendingUpload() }
|
||||
} message: {
|
||||
Text("Use this choice for all conflicts in this batch.")
|
||||
}
|
||||
.alert(
|
||||
"Upload Failed",
|
||||
isPresented: Binding(
|
||||
get: { uploadError != nil },
|
||||
set: { if !$0 { uploadError = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK") { uploadError = nil }
|
||||
} message: {
|
||||
Text(uploadError ?? "")
|
||||
}
|
||||
.animation(Motion.panel, value: uploadNotice)
|
||||
.background(terminalAppearance.background)
|
||||
.onAppear {
|
||||
// The mounted view is the final source of truth after any drag,
|
||||
// merge, detach, or SwiftUI tree rebuild. Reassert the relationship
|
||||
// here so a stale layout callback cannot leave live keystrokes on a
|
||||
// sshSession whose router was cleared while it moved.
|
||||
sshSession.inputRouter = workspace
|
||||
sshSession.terminalState.isSurfaceVisible = isVisible
|
||||
terminalFocused = requestsFocus
|
||||
sshSession.terminalState.applyAppearance(terminalAppearance.preferences)
|
||||
if sshSession.status == .connecting { sshSession.connect(using: app.hostLibrary) }
|
||||
if requestsFocus { sshSession.requestTerminalFocus() }
|
||||
}
|
||||
.onChange(of: isVisible) { _, visible in
|
||||
sshSession.terminalState.isSurfaceVisible = visible
|
||||
}
|
||||
.onChange(of: requestsFocus) { _, focused in
|
||||
terminalFocused = focused
|
||||
if focused { sshSession.requestTerminalFocus() }
|
||||
}
|
||||
.onChange(of: externalFileDrop?.id) {
|
||||
guard let externalFileDrop else { return }
|
||||
_ = receiveDroppedURLs(externalFileDrop.urls)
|
||||
}
|
||||
.onDisappear {
|
||||
sshSession.terminalState.isSurfaceVisible = false
|
||||
terminalFocused = false
|
||||
uploadNoticeTask?.cancel()
|
||||
uploadPreparationTask?.cancel()
|
||||
cancelPendingUpload()
|
||||
}
|
||||
.onChange(of: terminalAppearance.preferences) { _, preferences in
|
||||
sshSession.terminalState.applyAppearance(preferences)
|
||||
}
|
||||
.confirmationDialog(
|
||||
uploadConflictTitle,
|
||||
isPresented: $uploadConflictPresented,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Replace") { enqueuePendingUpload(.replace) }
|
||||
Button("Skip Existing") { enqueuePendingUpload(.skip) }
|
||||
Button("Keep Both") { enqueuePendingUpload(.keepBoth) }
|
||||
Button("Cancel", role: .cancel) { cancelPendingUpload() }
|
||||
} message: {
|
||||
Text("Use this choice for all conflicts in this batch.")
|
||||
}
|
||||
.alert(
|
||||
"Upload Failed",
|
||||
isPresented: Binding(
|
||||
get: { uploadError != nil },
|
||||
set: { if !$0 { uploadError = nil } }
|
||||
)
|
||||
) {
|
||||
Button("OK") { uploadError = nil }
|
||||
} message: {
|
||||
Text(uploadError ?? "")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -312,11 +312,12 @@ struct TerminalPlaceholderView: View {
|
||||
// The log rides on the chip rather than in the draft. Pasting it into
|
||||
// the editable text made the pill a label for something the user could
|
||||
// silently delete half of, and left the draft too long to read.
|
||||
conversation.attach(ContextChip(
|
||||
kind: .output,
|
||||
label: String(localized: "Connection error"),
|
||||
content: String(log.suffix(4_000))
|
||||
))
|
||||
conversation.attach(
|
||||
ContextChip(
|
||||
kind: .output,
|
||||
label: String(localized: "Connection error"),
|
||||
content: String(log.suffix(4_000))
|
||||
))
|
||||
conversation.composer.draft = String(
|
||||
localized: "SSH connection failed: \(message). What should I check?"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
|
||||
/// The first pinned destination's main content. Its navigator is mounted by the
|
||||
/// window-level split view and remains alive while top-level destinations change.
|
||||
struct VaultView: View {
|
||||
|
||||
@@ -474,8 +474,9 @@ final class WindowModel {
|
||||
var sessionPanes: [PaneRef] {
|
||||
workspaces.flatMap { workspace in
|
||||
workspace.panes.compactMap { pane in
|
||||
guard pane.kind == .sshSession || pane.kind == .sftpSession
|
||||
|| pane.kind == .serialSession
|
||||
guard
|
||||
pane.kind == .sshSession || pane.kind == .sftpSession
|
||||
|| pane.kind == .serialSession
|
||||
else { return nil }
|
||||
return PaneRef(workspace: workspace, pane: pane)
|
||||
}
|
||||
@@ -600,12 +601,14 @@ final class WindowModel {
|
||||
}
|
||||
|
||||
func toggleAssistantPin(_ id: ConversationModel.ID) {
|
||||
if let assistantCommands { assistantCommands.togglePin(id) }
|
||||
else if let conversation = assistantLibrary.conversation(id: id),
|
||||
if let assistantCommands {
|
||||
assistantCommands.togglePin(id)
|
||||
} else if let conversation = assistantLibrary.conversation(id: id),
|
||||
!conversation.isArchived, !conversation.isStreaming, !conversation.isCompacting
|
||||
{
|
||||
let toPinned = !conversation.isPinned
|
||||
let nextID = (toPinned
|
||||
let nextID =
|
||||
(toPinned
|
||||
? assistantLibrary.pinnedConversations
|
||||
: assistantLibrary.activeConversations).first?.id
|
||||
moveAssistantConversation(id, toPinned: toPinned, before: nextID)
|
||||
@@ -625,8 +628,9 @@ final class WindowModel {
|
||||
}
|
||||
}
|
||||
func renameAssistantConversation(_ id: ConversationModel.ID, to title: String) {
|
||||
if let assistantCommands { assistantCommands.rename(id, title) }
|
||||
else {
|
||||
if let assistantCommands {
|
||||
assistantCommands.rename(id, title)
|
||||
} else {
|
||||
let value = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !value.isEmpty {
|
||||
assistantLibrary.conversation(id: id)?.customTitle = String(value.prefix(80))
|
||||
@@ -634,8 +638,9 @@ final class WindowModel {
|
||||
}
|
||||
}
|
||||
func setAssistantArchived(_ id: ConversationModel.ID, archived: Bool) {
|
||||
if let assistantCommands { assistantCommands.archive(id, archived) }
|
||||
else if let conversation = assistantLibrary.conversation(id: id),
|
||||
if let assistantCommands {
|
||||
assistantCommands.archive(id, archived)
|
||||
} else if let conversation = assistantLibrary.conversation(id: id),
|
||||
!conversation.isStreaming, !conversation.isCompacting
|
||||
{
|
||||
conversation.archivedAt = archived ? .now : nil
|
||||
|
||||
@@ -270,9 +270,10 @@ final class Workspace: Identifiable {
|
||||
commandGenerationSequence &+= 1
|
||||
let generation = commandGenerationSequence
|
||||
let targetIDs = targets.map(\.id)
|
||||
let revisions = Dictionary(uniqueKeysWithValues: targets.map {
|
||||
($0.id, $0.commandGeneratorRevision)
|
||||
})
|
||||
let revisions = Dictionary(
|
||||
uniqueKeysWithValues: targets.map {
|
||||
($0.id, $0.commandGeneratorRevision)
|
||||
})
|
||||
let focusedID = focusedPaneID
|
||||
let wasBroadcasting = broadcastsInput && canBroadcast
|
||||
commandGeneratorState = .readingDraft
|
||||
@@ -284,11 +285,13 @@ final class Workspace: Identifiable {
|
||||
guard !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw WorkspaceCommandGenerationError.emptyDraft
|
||||
}
|
||||
guard self?.commandGenerationSnapshotIsCurrent(
|
||||
generation: generation, focusedID: focusedID,
|
||||
wasBroadcasting: wasBroadcasting, targetIDs: targetIDs,
|
||||
revisions: revisions
|
||||
) == true else { throw CancellationError() }
|
||||
guard
|
||||
self?.commandGenerationSnapshotIsCurrent(
|
||||
generation: generation, focusedID: focusedID,
|
||||
wasBroadcasting: wasBroadcasting, targetIDs: targetIDs,
|
||||
revisions: revisions
|
||||
) == true
|
||||
else { throw CancellationError() }
|
||||
|
||||
self?.commandGeneratorState = .generating
|
||||
let context = CommandGeneratorContext(
|
||||
@@ -300,11 +303,13 @@ final class Workspace: Identifiable {
|
||||
try await controller.generate(context)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard self?.commandGenerationSnapshotIsCurrent(
|
||||
generation: generation, focusedID: focusedID,
|
||||
wasBroadcasting: wasBroadcasting, targetIDs: targetIDs,
|
||||
revisions: revisions
|
||||
) == true else { throw CancellationError() }
|
||||
guard
|
||||
self?.commandGenerationSnapshotIsCurrent(
|
||||
generation: generation, focusedID: focusedID,
|
||||
wasBroadcasting: wasBroadcasting, targetIDs: targetIDs,
|
||||
revisions: revisions
|
||||
) == true
|
||||
else { throw CancellationError() }
|
||||
|
||||
for target in targets {
|
||||
target.replaceCurrentDraft(with: command)
|
||||
|
||||
@@ -21,7 +21,7 @@ struct WorkspaceContentView: View {
|
||||
isActive: isActive,
|
||||
omittedPaneID: omittedPaneID
|
||||
)
|
||||
.id(workspace.id)
|
||||
.id(workspace.id)
|
||||
|
||||
if workspace.commandGeneratorState != .idle {
|
||||
CommandGeneratorOverlay(state: workspace.commandGeneratorState)
|
||||
@@ -554,7 +554,8 @@ private struct PaneView: View {
|
||||
tone: .unavailable
|
||||
)
|
||||
}
|
||||
let hostName = app.hostLibrary.host(sftpSession.hostID)?.name
|
||||
let hostName =
|
||||
app.hostLibrary.host(sftpSession.hostID)?.name
|
||||
?? String(localized: "SFTP")
|
||||
return DropTargetPresentation(
|
||||
title: nativeDropKind == .sftpItems
|
||||
|
||||
@@ -142,7 +142,9 @@ struct WorkspaceNavigatorView: View {
|
||||
navigatorSectionHeader("Workspace", isExpanded: $workspaceExpanded)
|
||||
.onGeometryChange(for: CGRect.self) {
|
||||
$0.frame(in: .named(Self.coordinateSpace))
|
||||
} action: { workspaceHeaderFrame = $0 }
|
||||
} action: {
|
||||
workspaceHeaderFrame = $0
|
||||
}
|
||||
|
||||
if workspaceExpanded {
|
||||
if window.workspaces.isEmpty {
|
||||
@@ -160,7 +162,9 @@ struct WorkspaceNavigatorView: View {
|
||||
)
|
||||
.onGeometryChange(for: CGRect.self) {
|
||||
$0.frame(in: .named(Self.coordinateSpace))
|
||||
} action: { workspaceFrames[workspace.id] = $0 }
|
||||
} action: {
|
||||
workspaceFrames[workspace.id] = $0
|
||||
}
|
||||
.overlay(alignment: .top) {
|
||||
if workspaceDropTarget?.visibleBoundaryIndex == index {
|
||||
workspaceInsertionLine
|
||||
@@ -189,7 +193,9 @@ struct WorkspaceNavigatorView: View {
|
||||
}
|
||||
.onGeometryChange(for: CGRect.self) {
|
||||
$0.frame(in: .named(Self.coordinateSpace))
|
||||
} action: { batchStartupFrame = $0 }
|
||||
} action: {
|
||||
batchStartupFrame = $0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +207,8 @@ struct WorkspaceNavigatorView: View {
|
||||
|
||||
private var workspaceDropBounds: CGRect {
|
||||
guard workspaceExpanded else { return workspaceHeaderFrame }
|
||||
let frames = [workspaceHeaderFrame, batchStartupFrame]
|
||||
let frames =
|
||||
[workspaceHeaderFrame, batchStartupFrame]
|
||||
+ window.workspaces.compactMap { workspaceFrames[$0.id] }
|
||||
return frames.filter { !$0.isEmpty }.reduce(.null) { $0.union($1) }
|
||||
}
|
||||
@@ -240,7 +247,7 @@ struct WorkspaceNavigatorView: View {
|
||||
return true
|
||||
}
|
||||
)
|
||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||
.frame(width: proxy.size.width, height: proxy.size.height)
|
||||
}
|
||||
.allowsHitTesting(true)
|
||||
}
|
||||
@@ -381,11 +388,13 @@ private struct WorkspaceNavigatorRow: View {
|
||||
private var row: some View {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Image(systemName: workspace.symbol)
|
||||
.foregroundStyle(SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.secondary)
|
||||
))
|
||||
.foregroundStyle(
|
||||
SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.secondary)
|
||||
)
|
||||
)
|
||||
.frame(width: Metrics.Sidebar.rowIcon)
|
||||
|
||||
if isRenaming {
|
||||
|
||||
@@ -63,8 +63,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
UserDefaults.standard.set(enabled, forKey: SettingsKey.vaultSyncEnabled)
|
||||
if enabled { start() }
|
||||
else { pause() }
|
||||
if enabled { start() } else { pause() }
|
||||
}
|
||||
|
||||
func pause() {
|
||||
@@ -80,8 +79,12 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
|
||||
func retry() {
|
||||
guard isEnabled else { return }
|
||||
terminalError = nil
|
||||
if engine == nil { isStarted = false; start() }
|
||||
else { run { [weak self] in try await self?.synchronizeNow() } }
|
||||
if engine == nil {
|
||||
isStarted = false
|
||||
start()
|
||||
} else {
|
||||
run { [weak self] in try await self?.synchronizeNow() }
|
||||
}
|
||||
}
|
||||
|
||||
func noteLocalChanges(_ mutations: VaultMutationSet) {
|
||||
@@ -113,9 +116,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
|
||||
guard operation == nil else { return }
|
||||
operation = Task { [weak self] in
|
||||
defer { self?.operation = nil }
|
||||
do { try await body() }
|
||||
catch is CancellationError {}
|
||||
catch { self?.status = Self.status(for: error) }
|
||||
do { try await body() } catch is CancellationError {} catch { self?.status = Self.status(for: error) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,9 +191,10 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
|
||||
|
||||
private func addPendingChanges(_ keys: Set<VaultRecordKey>) {
|
||||
guard let engine else { return }
|
||||
engine.state.add(pendingRecordZoneChanges: keys.map {
|
||||
.saveRecord(CKRecord.ID(recordName: $0.recordName, zoneID: zoneID))
|
||||
})
|
||||
engine.state.add(
|
||||
pendingRecordZoneChanges: keys.map {
|
||||
.saveRecord(CKRecord.ID(recordName: $0.recordName, zoneID: zoneID))
|
||||
})
|
||||
}
|
||||
|
||||
private func updateUpToDateStatus() throws {
|
||||
@@ -248,8 +250,7 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
|
||||
guard let key = VaultRecordKey(recordName: recordID.recordName),
|
||||
let outgoing = try? journal.outgoingEntry(for: key)
|
||||
else { return nil }
|
||||
do { return try Self.makeRecord(outgoing, recordID: recordID) }
|
||||
catch {
|
||||
do { return try Self.makeRecord(outgoing, recordID: recordID) } catch {
|
||||
terminalError = error.localizedDescription
|
||||
status = .failed(error.localizedDescription)
|
||||
return nil
|
||||
@@ -355,9 +356,11 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
|
||||
|
||||
// Physical deletes are not part of the protocol; recreate the local
|
||||
// envelope instead of converting a console accident into data loss.
|
||||
let physicallyDeleted = Set(changes.deletions.compactMap {
|
||||
VaultRecordKey(recordName: $0.recordID.recordName)
|
||||
}).filter { (try? journal.envelope(for: $0)) != nil }
|
||||
let physicallyDeleted = Set(
|
||||
changes.deletions.compactMap {
|
||||
VaultRecordKey(recordName: $0.recordID.recordName)
|
||||
}
|
||||
).filter { (try? journal.envelope(for: $0)) != nil }
|
||||
try journal.markNeedsUpload(physicallyDeleted)
|
||||
addPendingChanges(result.needsUpload.union(physicallyDeleted))
|
||||
try context.save()
|
||||
@@ -424,8 +427,9 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
|
||||
/// scoped to Development or Production. Rebind once per environment while
|
||||
/// retaining every local envelope and its conflict-resolution version.
|
||||
private func prepareForCurrentEnvironment() throws {
|
||||
guard defaults.string(forKey: SettingsKey.vaultSyncEnvironment)
|
||||
!= environmentIdentifier
|
||||
guard
|
||||
defaults.string(forKey: SettingsKey.vaultSyncEnvironment)
|
||||
!= environmentIdentifier
|
||||
else { return }
|
||||
|
||||
try journal.markAllEntriesNeedsUploadAfterEnvironmentChange()
|
||||
@@ -544,6 +548,6 @@ final class VaultSyncCoordinator: NSObject, CKSyncEngineDelegate, @unchecked Sen
|
||||
}
|
||||
}
|
||||
|
||||
private extension SHA256.Digest {
|
||||
nonisolated var hexString: String { map { String(format: "%02x", $0) }.joined() }
|
||||
extension SHA256.Digest {
|
||||
fileprivate nonisolated var hexString: String { map { String(format: "%02x", $0) }.joined() }
|
||||
}
|
||||
@@ -159,8 +159,11 @@ extension VaultSnapshot {
|
||||
private nonisolated func upsert<Value: Identifiable>(
|
||||
_ value: Value, in values: inout [Value]
|
||||
) where Value.ID == UUID {
|
||||
if let index = values.firstIndex(where: { $0.id == value.id }) { values[index] = value }
|
||||
else { values.append(value) }
|
||||
if let index = values.firstIndex(where: { $0.id == value.id }) {
|
||||
values[index] = value
|
||||
} else {
|
||||
values.append(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,9 +268,11 @@ final class VaultSyncJournal {
|
||||
result.accepted.append(envelope)
|
||||
continue
|
||||
}
|
||||
if local.version < envelope.version { result.accepted.append(envelope) }
|
||||
else if envelope.version < local.version { result.needsUpload.insert(local.key) }
|
||||
else if local != envelope {
|
||||
if local.version < envelope.version {
|
||||
result.accepted.append(envelope)
|
||||
} else if envelope.version < local.version {
|
||||
result.needsUpload.insert(local.key)
|
||||
} else if local != envelope {
|
||||
let localData = VaultSyncCodec.encode(local)
|
||||
let remoteData = VaultSyncCodec.encode(envelope)
|
||||
if localData.lexicographicallyPrecedes(remoteData) {
|
||||
@@ -313,9 +318,10 @@ final class VaultSyncJournal {
|
||||
row.reason = reason
|
||||
row.receivedAt = .now
|
||||
} else {
|
||||
context.insert(PersistentVaultSyncQuarantine(
|
||||
recordName: recordName, payloadData: payloadData, reason: reason
|
||||
))
|
||||
context.insert(
|
||||
PersistentVaultSyncQuarantine(
|
||||
recordName: recordName, payloadData: payloadData, reason: reason
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,8 +380,9 @@ final class VaultSyncJournal {
|
||||
func markAllLiveRecordsNeedsUploadForNewAccount() throws {
|
||||
for row in try context.fetch(FetchDescriptor<PersistentVaultSyncEntry>()) {
|
||||
guard let envelope = row.envelope else { continue }
|
||||
if envelope.isDeleted { context.delete(row) }
|
||||
else {
|
||||
if envelope.isDeleted {
|
||||
context.delete(row)
|
||||
} else {
|
||||
row.needsUpload = true
|
||||
row.systemFieldsData = nil
|
||||
}
|
||||
|
||||
@@ -91,20 +91,22 @@ final class PersistentAgentMessage {
|
||||
)
|
||||
authoredText = message.authoredText.map(AgentPersistenceRedactor.visibleText)
|
||||
imagesData = (try? JSONEncoder().encode(message.images)) ?? Data()
|
||||
reasoningData = AgentPersistenceRedactor.encoded(message.reasoning.map { block in
|
||||
AgentReasoningBlock(
|
||||
id: block.id,
|
||||
text: AgentPersistenceRedactor.visibleText(block.text),
|
||||
createdAt: block.createdAt
|
||||
)
|
||||
})
|
||||
toolCallsData = AgentPersistenceRedactor.encoded(message.toolCalls.map { call in
|
||||
AgentToolCall(
|
||||
id: call.id, name: call.name,
|
||||
arguments: AgentPersistenceRedactor.redactedJSON(call.arguments),
|
||||
providerItemID: call.providerItemID
|
||||
)
|
||||
})
|
||||
reasoningData = AgentPersistenceRedactor.encoded(
|
||||
message.reasoning.map { block in
|
||||
AgentReasoningBlock(
|
||||
id: block.id,
|
||||
text: AgentPersistenceRedactor.visibleText(block.text),
|
||||
createdAt: block.createdAt
|
||||
)
|
||||
})
|
||||
toolCallsData = AgentPersistenceRedactor.encoded(
|
||||
message.toolCalls.map { call in
|
||||
AgentToolCall(
|
||||
id: call.id, name: call.name,
|
||||
arguments: AgentPersistenceRedactor.redactedJSON(call.arguments),
|
||||
providerItemID: call.providerItemID
|
||||
)
|
||||
})
|
||||
toolCallID = message.toolCallID
|
||||
toolName = message.toolName
|
||||
isError = message.isError
|
||||
@@ -276,8 +278,11 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
}
|
||||
let runID = snapshot.id
|
||||
let runDescriptor = FetchDescriptor<PersistentAgentRun>(predicate: #Predicate { $0.id == runID })
|
||||
if let row = try context.fetch(runDescriptor).first { row.update(snapshot) }
|
||||
else { context.insert(PersistentAgentRun(snapshot)) }
|
||||
if let row = try context.fetch(runDescriptor).first {
|
||||
row.update(snapshot)
|
||||
} else {
|
||||
context.insert(PersistentAgentRun(snapshot))
|
||||
}
|
||||
|
||||
let conversationID = snapshot.conversationID
|
||||
let conversationDescriptor = FetchDescriptor<PersistentAgentConversation>(
|
||||
@@ -290,9 +295,10 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
if let conversation = try context.fetch(conversationDescriptor).first {
|
||||
conversation.updatedAt = .now
|
||||
} else {
|
||||
context.insert(PersistentAgentConversation(
|
||||
id: conversationID, title: "", createdAt: snapshot.startedAt
|
||||
))
|
||||
context.insert(
|
||||
PersistentAgentConversation(
|
||||
id: conversationID, title: "", createdAt: snapshot.startedAt
|
||||
))
|
||||
}
|
||||
|
||||
// Matched on message id, not on this run's id. A conversation's earlier
|
||||
@@ -301,17 +307,18 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
// constraint and rewriting rows that were already correct.
|
||||
let durableMessages = snapshot.messages.filter { !$0.isCompaction }
|
||||
let ids = Set(durableMessages.map(\.id))
|
||||
let stored = try context.fetch(FetchDescriptor<PersistentAgentMessage>(
|
||||
predicate: #Predicate { ids.contains($0.id) }
|
||||
))
|
||||
let stored = try context.fetch(
|
||||
FetchDescriptor<PersistentAgentMessage>(
|
||||
predicate: #Predicate { ids.contains($0.id) }
|
||||
))
|
||||
let storedIDs = Set(stored.map(\.id))
|
||||
var inserted = false
|
||||
for (sequence, message) in durableMessages.enumerated()
|
||||
where !storedIDs.contains(message.id)
|
||||
{
|
||||
context.insert(PersistentAgentMessage(
|
||||
message, runID: runID, sequence: sequence
|
||||
))
|
||||
where !storedIDs.contains(message.id) {
|
||||
context.insert(
|
||||
PersistentAgentMessage(
|
||||
message, runID: runID, sequence: sequence
|
||||
))
|
||||
insertContextAttachments(for: message)
|
||||
inserted = true
|
||||
}
|
||||
@@ -326,26 +333,30 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
guard !deletedConversationIDs.contains(conversationID) else {
|
||||
throw AgentConversationPersistenceError.conversationNotFound
|
||||
}
|
||||
let conversations = try context.fetch(FetchDescriptor<PersistentAgentConversation>(
|
||||
predicate: #Predicate { $0.id == conversationID }
|
||||
))
|
||||
let conversations = try context.fetch(
|
||||
FetchDescriptor<PersistentAgentConversation>(
|
||||
predicate: #Predicate { $0.id == conversationID }
|
||||
))
|
||||
guard conversations.first != nil else {
|
||||
throw AgentConversationPersistenceError.conversationNotFound
|
||||
}
|
||||
if let boundary = record.compactedThroughMessageID {
|
||||
let runIDs = Set(try context.fetch(FetchDescriptor<PersistentAgentRun>())
|
||||
.filter { $0.conversationID == conversationID }.map(\.id))
|
||||
let rows = try context.fetch(FetchDescriptor<PersistentAgentMessage>(
|
||||
predicate: #Predicate { $0.id == boundary }
|
||||
))
|
||||
let runIDs = Set(
|
||||
try context.fetch(FetchDescriptor<PersistentAgentRun>())
|
||||
.filter { $0.conversationID == conversationID }.map(\.id))
|
||||
let rows = try context.fetch(
|
||||
FetchDescriptor<PersistentAgentMessage>(
|
||||
predicate: #Predicate { $0.id == boundary }
|
||||
))
|
||||
guard rows.contains(where: { runIDs.contains($0.runID) }) else {
|
||||
throw AgentConversationPersistenceError.messageNotFound
|
||||
}
|
||||
}
|
||||
let recordID = record.id
|
||||
let existing = try context.fetch(FetchDescriptor<PersistentAgentCompaction>(
|
||||
predicate: #Predicate { $0.id == recordID }
|
||||
))
|
||||
let existing = try context.fetch(
|
||||
FetchDescriptor<PersistentAgentCompaction>(
|
||||
predicate: #Predicate { $0.id == recordID }
|
||||
))
|
||||
guard existing.isEmpty else { return }
|
||||
context.insert(PersistentAgentCompaction(record, conversationID: conversationID))
|
||||
try context.savePendingChanges()
|
||||
@@ -363,18 +374,21 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
try upsertInvocation(invocation, state: "finished")
|
||||
context.insert(PersistentAgentToolResult(result, invocation: invocation))
|
||||
case .reviewStarted(let request):
|
||||
context.insert(PersistentAgentReviewRecord(
|
||||
runID: runID, invocationID: request.invocation.id, decision: nil
|
||||
))
|
||||
context.insert(
|
||||
PersistentAgentReviewRecord(
|
||||
runID: runID, invocationID: request.invocation.id, decision: nil
|
||||
))
|
||||
case .reviewFinished(let request, let decision):
|
||||
context.insert(PersistentAgentReviewRecord(
|
||||
runID: runID, invocationID: request.invocation.id, decision: decision
|
||||
))
|
||||
context.insert(
|
||||
PersistentAgentReviewRecord(
|
||||
runID: runID, invocationID: request.invocation.id, decision: decision
|
||||
))
|
||||
case .reviewFailed(let request, let reason, let timedOut):
|
||||
context.insert(PersistentAgentReviewRecord(
|
||||
runID: runID, invocationID: request.invocation.id,
|
||||
failure: reason, timedOut: timedOut
|
||||
))
|
||||
context.insert(
|
||||
PersistentAgentReviewRecord(
|
||||
runID: runID, invocationID: request.invocation.id,
|
||||
failure: reason, timedOut: timedOut
|
||||
))
|
||||
default:
|
||||
// Nothing to write, so nothing to save. `AgentRuntime` already
|
||||
// filters on `isDurable`; this is the second half of the same
|
||||
@@ -386,9 +400,10 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
|
||||
@MainActor
|
||||
func loadConversations() async throws -> [StoredAgentConversation] {
|
||||
let conversations = try context.fetch(FetchDescriptor<PersistentAgentConversation>(
|
||||
sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
|
||||
))
|
||||
let conversations = try context.fetch(
|
||||
FetchDescriptor<PersistentAgentConversation>(
|
||||
sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
|
||||
))
|
||||
let orderRows = try context.fetch(FetchDescriptor<PersistentAgentConversationOrder>())
|
||||
let sortIndexByConversation = Dictionary(
|
||||
uniqueKeysWithValues: orderRows.map { ($0.conversationID, $0.sortIndex) }
|
||||
@@ -437,16 +452,19 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
}
|
||||
.compactMap { row -> AgentTranscriptMessage? in
|
||||
guard let role = AgentTranscriptRole(rawValue: row.role) else { return nil }
|
||||
let calls = (try? JSONDecoder().decode(
|
||||
[AgentToolCall].self, from: row.toolCallsData
|
||||
)) ?? []
|
||||
let images = (try? JSONDecoder().decode(
|
||||
[AgentImageAttachment].self, from: row.imagesData
|
||||
)) ?? []
|
||||
let calls =
|
||||
(try? JSONDecoder().decode(
|
||||
[AgentToolCall].self, from: row.toolCallsData
|
||||
)) ?? []
|
||||
let images =
|
||||
(try? JSONDecoder().decode(
|
||||
[AgentImageAttachment].self, from: row.imagesData
|
||||
)) ?? []
|
||||
let contextAttachments = attachmentsByMessage[row.id] ?? []
|
||||
let reasoning = (try? JSONDecoder().decode(
|
||||
[AgentReasoningBlock].self, from: row.reasoningData
|
||||
)) ?? []
|
||||
let reasoning =
|
||||
(try? JSONDecoder().decode(
|
||||
[AgentReasoningBlock].self, from: row.reasoningData
|
||||
)) ?? []
|
||||
return AgentTranscriptMessage(
|
||||
id: row.id, role: role, text: row.text,
|
||||
authoredText: row.authoredText, images: images,
|
||||
@@ -462,7 +480,8 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
uniqueKeysWithValues: messages.filter { runIDs.contains($0.runID) }
|
||||
.map { ($0.id, $0.runID) }
|
||||
)
|
||||
let state = matchingRuns.last.flatMap { AgentRunState(rawValue: $0.state) }
|
||||
let state =
|
||||
matchingRuns.last.flatMap { AgentRunState(rawValue: $0.state) }
|
||||
?? .completed
|
||||
let cards = invocations.filter { runIDs.contains($0.runID) }
|
||||
.sorted { $0.createdAt < $1.createdAt }
|
||||
@@ -545,16 +564,18 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
guard updates.count == ids.count else {
|
||||
throw AgentConversationPersistenceError.conversationNotFound
|
||||
}
|
||||
let rows = try context.fetch(FetchDescriptor<PersistentAgentConversation>(
|
||||
predicate: #Predicate { ids.contains($0.id) }
|
||||
))
|
||||
let rows = try context.fetch(
|
||||
FetchDescriptor<PersistentAgentConversation>(
|
||||
predicate: #Predicate { ids.contains($0.id) }
|
||||
))
|
||||
guard rows.count == ids.count else {
|
||||
throw AgentConversationPersistenceError.conversationNotFound
|
||||
}
|
||||
let updatesByID = Dictionary(uniqueKeysWithValues: updates.map { ($0.id, $0) })
|
||||
let orderRows = try context.fetch(FetchDescriptor<PersistentAgentConversationOrder>(
|
||||
predicate: #Predicate { ids.contains($0.conversationID) }
|
||||
))
|
||||
let orderRows = try context.fetch(
|
||||
FetchDescriptor<PersistentAgentConversationOrder>(
|
||||
predicate: #Predicate { ids.contains($0.conversationID) }
|
||||
))
|
||||
var orderByID = Dictionary(
|
||||
uniqueKeysWithValues: orderRows.map { ($0.conversationID, $0) }
|
||||
)
|
||||
@@ -583,9 +604,10 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
let runs = allRuns.filter { $0.conversationID == id }
|
||||
.sorted { $0.startedAt < $1.startedAt }
|
||||
let allMessages = try context.fetch(FetchDescriptor<PersistentAgentMessage>())
|
||||
guard let target = allMessages.first(where: { message in
|
||||
message.id == messageID && runs.contains { $0.id == message.runID }
|
||||
}),
|
||||
guard
|
||||
let target = allMessages.first(where: { message in
|
||||
message.id == messageID && runs.contains { $0.id == message.runID }
|
||||
}),
|
||||
let targetIndex = runs.firstIndex(where: { $0.id == target.runID })
|
||||
else { throw AgentConversationPersistenceError.messageNotFound }
|
||||
|
||||
@@ -601,8 +623,8 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
do {
|
||||
try deleteGraph(runIDs: removedRunIDs, runs: removedRuns)
|
||||
for row in try context.fetch(FetchDescriptor<PersistentAgentCompaction>())
|
||||
where row.conversationID == id
|
||||
&& row.compactedThroughMessageID.map(removedMessageIDs.contains) == true
|
||||
where row.conversationID == id
|
||||
&& row.compactedThroughMessageID.map(removedMessageIDs.contains) == true
|
||||
{
|
||||
context.delete(row)
|
||||
}
|
||||
@@ -613,9 +635,10 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
startedAt: replacement.createdAt, finishedAt: replacement.createdAt
|
||||
)
|
||||
context.insert(PersistentAgentRun(replacementRun))
|
||||
context.insert(PersistentAgentMessage(
|
||||
replacement, runID: replacementRun.id, sequence: 0
|
||||
))
|
||||
context.insert(
|
||||
PersistentAgentMessage(
|
||||
replacement, runID: replacementRun.id, sequence: 0
|
||||
))
|
||||
insertContextAttachments(for: replacement)
|
||||
|
||||
let descriptor = FetchDescriptor<PersistentAgentConversation>(
|
||||
@@ -657,35 +680,42 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
try context.commit {
|
||||
context.insert(conversation)
|
||||
if let sortIndex = metadata.sortIndex {
|
||||
context.insert(PersistentAgentConversationOrder(
|
||||
conversationID: value.id, sortIndex: sortIndex
|
||||
))
|
||||
context.insert(
|
||||
PersistentAgentConversationOrder(
|
||||
conversationID: value.id, sortIndex: sortIndex
|
||||
))
|
||||
}
|
||||
|
||||
let summaries = value.runs.isEmpty
|
||||
? [AgentRunSummary(
|
||||
id: UUID(), state: value.lastRunState,
|
||||
startedAt: metadata.createdAt, finishedAt: metadata.updatedAt
|
||||
)]
|
||||
let summaries =
|
||||
value.runs.isEmpty
|
||||
? [
|
||||
AgentRunSummary(
|
||||
id: UUID(), state: value.lastRunState,
|
||||
startedAt: metadata.createdAt, finishedAt: metadata.updatedAt
|
||||
)
|
||||
]
|
||||
: value.runs.sorted { $0.startedAt < $1.startedAt }
|
||||
for summary in summaries {
|
||||
context.insert(PersistentAgentRun(AgentRunSnapshot(
|
||||
id: summary.id, conversationID: value.id, state: summary.state,
|
||||
permissionMode: .askForApproval, messages: [],
|
||||
startedAt: summary.startedAt, finishedAt: summary.finishedAt
|
||||
)))
|
||||
context.insert(
|
||||
PersistentAgentRun(
|
||||
AgentRunSnapshot(
|
||||
id: summary.id, conversationID: value.id, state: summary.state,
|
||||
permissionMode: .askForApproval, messages: [],
|
||||
startedAt: summary.startedAt, finishedAt: summary.finishedAt
|
||||
)))
|
||||
}
|
||||
let validRunIDs = Set(summaries.map(\.id))
|
||||
let fallbackRunID = summaries[0].id
|
||||
for (sequence, message) in value.messages.enumerated() {
|
||||
guard !message.isCompaction else { continue }
|
||||
context.insert(PersistentAgentMessage(
|
||||
message,
|
||||
runID: value.messageRunIDs[message.id].flatMap {
|
||||
validRunIDs.contains($0) ? $0 : nil
|
||||
} ?? fallbackRunID,
|
||||
sequence: sequence
|
||||
))
|
||||
context.insert(
|
||||
PersistentAgentMessage(
|
||||
message,
|
||||
runID: value.messageRunIDs[message.id].flatMap {
|
||||
validRunIDs.contains($0) ? $0 : nil
|
||||
} ?? fallbackRunID,
|
||||
sequence: sequence
|
||||
))
|
||||
insertContextAttachments(for: message)
|
||||
}
|
||||
for record in value.compactions {
|
||||
@@ -701,9 +731,10 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
context.insert(PersistentAgentToolResult(result, invocation: invocation))
|
||||
}
|
||||
if let review = card.review {
|
||||
context.insert(PersistentAgentReviewRecord(
|
||||
runID: invocation.runID, invocationID: invocation.id, decision: review
|
||||
))
|
||||
context.insert(
|
||||
PersistentAgentReviewRecord(
|
||||
runID: invocation.runID, invocationID: invocation.id, decision: review
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -724,31 +755,32 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
let orderRows = try context.fetch(FetchDescriptor<PersistentAgentConversationOrder>())
|
||||
for row in orderRows where row.conversationID == id { context.delete(row) }
|
||||
for row in try context.fetch(FetchDescriptor<PersistentAgentCompaction>())
|
||||
where row.conversationID == id { context.delete(row) }
|
||||
where row.conversationID == id { context.delete(row) }
|
||||
try context.save()
|
||||
}
|
||||
|
||||
private func deleteGraph(runIDs: Set<UUID>, runs: [PersistentAgentRun]) throws {
|
||||
for row in try context.fetch(FetchDescriptor<PersistentAgentToolResult>())
|
||||
where runIDs.contains(row.runID) { context.delete(row) }
|
||||
where runIDs.contains(row.runID) { context.delete(row) }
|
||||
for row in try context.fetch(FetchDescriptor<PersistentAgentReviewRecord>())
|
||||
where runIDs.contains(row.runID) { context.delete(row) }
|
||||
where runIDs.contains(row.runID) { context.delete(row) }
|
||||
for row in try context.fetch(FetchDescriptor<PersistentAgentToolInvocation>())
|
||||
where runIDs.contains(row.runID) { context.delete(row) }
|
||||
where runIDs.contains(row.runID) { context.delete(row) }
|
||||
let messages = try context.fetch(FetchDescriptor<PersistentAgentMessage>())
|
||||
.filter { runIDs.contains($0.runID) }
|
||||
let messageIDs = Set(messages.map(\.id))
|
||||
for row in try context.fetch(FetchDescriptor<PersistentAgentContextAttachments>())
|
||||
where messageIDs.contains(row.messageID) { context.delete(row) }
|
||||
where messageIDs.contains(row.messageID) { context.delete(row) }
|
||||
for row in messages { context.delete(row) }
|
||||
for row in runs { context.delete(row) }
|
||||
}
|
||||
|
||||
private func insertContextAttachments(for message: AgentTranscriptMessage) {
|
||||
guard !message.contextAttachments.isEmpty else { return }
|
||||
context.insert(PersistentAgentContextAttachments(
|
||||
messageID: message.id, attachments: message.contextAttachments
|
||||
))
|
||||
context.insert(
|
||||
PersistentAgentContextAttachments(
|
||||
messageID: message.id, attachments: message.contextAttachments
|
||||
))
|
||||
}
|
||||
|
||||
/// Puts back tool results that were recorded but never made it into the
|
||||
@@ -795,10 +827,11 @@ final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable {
|
||||
// The last recorded one: a call id is unique to its invocation,
|
||||
// so more than one row can only mean a rewritten result.
|
||||
guard let stored = results.last(where: { $0.callID == call.id }) else { continue }
|
||||
recovered.append(AgentTranscriptMessage(
|
||||
role: .tool, text: stored.content, toolCallID: call.id,
|
||||
toolName: names[call.id] ?? call.name, isError: stored.isError
|
||||
))
|
||||
recovered.append(
|
||||
AgentTranscriptMessage(
|
||||
role: .tool, text: stored.content, toolCallID: call.id,
|
||||
toolName: names[call.id] ?? call.name, isError: stored.isError
|
||||
))
|
||||
}
|
||||
}
|
||||
return recovered
|
||||
|
||||
@@ -2,143 +2,143 @@ import Foundation
|
||||
import SwiftData
|
||||
|
||||
enum TermindSchemaV7HostModels {
|
||||
@Model
|
||||
final class PersistentHost {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
var name = ""
|
||||
var address = ""
|
||||
var port = 22
|
||||
var identityID: UUID?
|
||||
var jumpHostID: UUID?
|
||||
var proxyProtocol: String?
|
||||
var proxyHostname = ""
|
||||
var proxyPort = 0
|
||||
var proxyUsername = ""
|
||||
var proxyIdentityID: UUID?
|
||||
var sftpSessionInitialPath = ""
|
||||
var groupID: UUID?
|
||||
var tags: [String] = []
|
||||
var colorTag = TagColor.none.rawValue
|
||||
/// Legacy V1–V5 storage retained only so existing stores can migrate.
|
||||
/// Recents and last-connected presentation are derived from connection
|
||||
/// history; this column is never read or written by current code.
|
||||
var lastUsedAt: Date?
|
||||
var sortIndex = 0.0
|
||||
@Model
|
||||
final class PersistentHost {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
var name = ""
|
||||
var address = ""
|
||||
var port = 22
|
||||
var identityID: UUID?
|
||||
var jumpHostID: UUID?
|
||||
var proxyProtocol: String?
|
||||
var proxyHostname = ""
|
||||
var proxyPort = 0
|
||||
var proxyUsername = ""
|
||||
var proxyIdentityID: UUID?
|
||||
var sftpSessionInitialPath = ""
|
||||
var groupID: UUID?
|
||||
var tags: [String] = []
|
||||
var colorTag = TagColor.none.rawValue
|
||||
/// Legacy V1–V5 storage retained only so existing stores can migrate.
|
||||
/// Recents and last-connected presentation are derived from connection
|
||||
/// history; this column is never read or written by current code.
|
||||
var lastUsedAt: Date?
|
||||
var sortIndex = 0.0
|
||||
|
||||
init(_ value: Host) {
|
||||
id = value.id
|
||||
apply(value)
|
||||
}
|
||||
|
||||
func apply(_ value: Host) {
|
||||
name = value.name
|
||||
address = value.host
|
||||
port = value.port
|
||||
identityID = value.identityID
|
||||
jumpHostID = value.jumpHostID
|
||||
proxyProtocol = value.proxy?.kind.rawValue
|
||||
proxyHostname = value.proxy?.hostname ?? ""
|
||||
proxyPort = value.proxy?.port ?? 0
|
||||
proxyUsername = value.proxy?.username ?? ""
|
||||
proxyIdentityID = value.proxy?.identityID
|
||||
sftpSessionInitialPath = value.sftpSessionInitialPath
|
||||
groupID = value.groupID
|
||||
tags = value.tags
|
||||
colorTag = value.colorTag.rawValue
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
|
||||
var value: Host {
|
||||
var host = Host(
|
||||
id: id, name: name, host: address, port: port,
|
||||
identityID: identityID, groupID: groupID, tags: tags,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none,
|
||||
sortIndex: sortIndex
|
||||
)
|
||||
host.jumpHostID = jumpHostID
|
||||
host.sftpSessionInitialPath = sftpSessionInitialPath
|
||||
if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) {
|
||||
host.proxy = HostProxy(
|
||||
kind: kind, hostname: proxyHostname, port: proxyPort,
|
||||
username: proxyUsername, identityID: proxyIdentityID
|
||||
)
|
||||
init(_ value: Host) {
|
||||
id = value.id
|
||||
apply(value)
|
||||
}
|
||||
|
||||
func apply(_ value: Host) {
|
||||
name = value.name
|
||||
address = value.host
|
||||
port = value.port
|
||||
identityID = value.identityID
|
||||
jumpHostID = value.jumpHostID
|
||||
proxyProtocol = value.proxy?.kind.rawValue
|
||||
proxyHostname = value.proxy?.hostname ?? ""
|
||||
proxyPort = value.proxy?.port ?? 0
|
||||
proxyUsername = value.proxy?.username ?? ""
|
||||
proxyIdentityID = value.proxy?.identityID
|
||||
sftpSessionInitialPath = value.sftpSessionInitialPath
|
||||
groupID = value.groupID
|
||||
tags = value.tags
|
||||
colorTag = value.colorTag.rawValue
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
|
||||
var value: Host {
|
||||
var host = Host(
|
||||
id: id, name: name, host: address, port: port,
|
||||
identityID: identityID, groupID: groupID, tags: tags,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none,
|
||||
sortIndex: sortIndex
|
||||
)
|
||||
host.jumpHostID = jumpHostID
|
||||
host.sftpSessionInitialPath = sftpSessionInitialPath
|
||||
if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) {
|
||||
host.proxy = HostProxy(
|
||||
kind: kind, hostname: proxyHostname, port: proxyPort,
|
||||
username: proxyUsername, identityID: proxyIdentityID
|
||||
)
|
||||
}
|
||||
return host
|
||||
}
|
||||
return host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TermindSchemaV8Models {
|
||||
@Model
|
||||
final class PersistentHost {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
var name = ""
|
||||
var address = ""
|
||||
var port = 22
|
||||
var identityID: UUID?
|
||||
var jumpHostID: UUID?
|
||||
var proxyProtocol: String?
|
||||
var proxyHostname = ""
|
||||
var proxyPort = 0
|
||||
var proxyUsername = ""
|
||||
var proxyIdentityID: UUID?
|
||||
var sftpSessionInitialPath = ""
|
||||
var groupID: UUID?
|
||||
var tags: [String] = []
|
||||
var colorTag = TagColor.none.rawValue
|
||||
var detectedDistributionID: String?
|
||||
var detectedDistributionLikeIDs: [String] = []
|
||||
/// Legacy storage retained for the existing V5–V6 migration contract.
|
||||
var lastUsedAt: Date?
|
||||
var sortIndex = 0.0
|
||||
@Model
|
||||
final class PersistentHost {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
var name = ""
|
||||
var address = ""
|
||||
var port = 22
|
||||
var identityID: UUID?
|
||||
var jumpHostID: UUID?
|
||||
var proxyProtocol: String?
|
||||
var proxyHostname = ""
|
||||
var proxyPort = 0
|
||||
var proxyUsername = ""
|
||||
var proxyIdentityID: UUID?
|
||||
var sftpSessionInitialPath = ""
|
||||
var groupID: UUID?
|
||||
var tags: [String] = []
|
||||
var colorTag = TagColor.none.rawValue
|
||||
var detectedDistributionID: String?
|
||||
var detectedDistributionLikeIDs: [String] = []
|
||||
/// Legacy storage retained for the existing V5–V6 migration contract.
|
||||
var lastUsedAt: Date?
|
||||
var sortIndex = 0.0
|
||||
|
||||
init(_ value: Host) {
|
||||
id = value.id
|
||||
apply(value)
|
||||
}
|
||||
|
||||
func apply(_ value: Host) {
|
||||
name = value.name
|
||||
address = value.host
|
||||
port = value.port
|
||||
identityID = value.identityID
|
||||
jumpHostID = value.jumpHostID
|
||||
proxyProtocol = value.proxy?.kind.rawValue
|
||||
proxyHostname = value.proxy?.hostname ?? ""
|
||||
proxyPort = value.proxy?.port ?? 0
|
||||
proxyUsername = value.proxy?.username ?? ""
|
||||
proxyIdentityID = value.proxy?.identityID
|
||||
sftpSessionInitialPath = value.sftpSessionInitialPath
|
||||
groupID = value.groupID
|
||||
tags = value.tags
|
||||
colorTag = value.colorTag.rawValue
|
||||
detectedDistributionID = value.detectedDistribution?.id
|
||||
detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? []
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
|
||||
var value: Host {
|
||||
var host = Host(
|
||||
id: id, name: name, host: address, port: port,
|
||||
identityID: identityID, groupID: groupID, tags: tags,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none,
|
||||
detectedDistribution: detectedDistributionID.flatMap {
|
||||
DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs)
|
||||
},
|
||||
sortIndex: sortIndex
|
||||
)
|
||||
host.jumpHostID = jumpHostID
|
||||
host.sftpSessionInitialPath = sftpSessionInitialPath
|
||||
if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) {
|
||||
host.proxy = HostProxy(
|
||||
kind: kind, hostname: proxyHostname, port: proxyPort,
|
||||
username: proxyUsername, identityID: proxyIdentityID
|
||||
)
|
||||
init(_ value: Host) {
|
||||
id = value.id
|
||||
apply(value)
|
||||
}
|
||||
|
||||
func apply(_ value: Host) {
|
||||
name = value.name
|
||||
address = value.host
|
||||
port = value.port
|
||||
identityID = value.identityID
|
||||
jumpHostID = value.jumpHostID
|
||||
proxyProtocol = value.proxy?.kind.rawValue
|
||||
proxyHostname = value.proxy?.hostname ?? ""
|
||||
proxyPort = value.proxy?.port ?? 0
|
||||
proxyUsername = value.proxy?.username ?? ""
|
||||
proxyIdentityID = value.proxy?.identityID
|
||||
sftpSessionInitialPath = value.sftpSessionInitialPath
|
||||
groupID = value.groupID
|
||||
tags = value.tags
|
||||
colorTag = value.colorTag.rawValue
|
||||
detectedDistributionID = value.detectedDistribution?.id
|
||||
detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? []
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
|
||||
var value: Host {
|
||||
var host = Host(
|
||||
id: id, name: name, host: address, port: port,
|
||||
identityID: identityID, groupID: groupID, tags: tags,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none,
|
||||
detectedDistribution: detectedDistributionID.flatMap {
|
||||
DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs)
|
||||
},
|
||||
sortIndex: sortIndex
|
||||
)
|
||||
host.jumpHostID = jumpHostID
|
||||
host.sftpSessionInitialPath = sftpSessionInitialPath
|
||||
if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) {
|
||||
host.proxy = HostProxy(
|
||||
kind: kind, hostname: proxyHostname, port: proxyPort,
|
||||
username: proxyUsername, identityID: proxyIdentityID
|
||||
)
|
||||
}
|
||||
return host
|
||||
}
|
||||
return host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias PersistentHost = TermindSchemaV8Models.PersistentHost
|
||||
|
||||
@@ -289,97 +289,97 @@ enum TermindSchemaV6Models {
|
||||
}
|
||||
|
||||
enum TermindSchemaV7Models {
|
||||
@Model
|
||||
final class PersistentIdentity {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
var name = ""
|
||||
var username = ""
|
||||
var credentialRef = ""
|
||||
var passwordStorage = PasswordStorage.iCloud.rawValue
|
||||
var keyID: UUID?
|
||||
var notes = ""
|
||||
var lastUsedAt: Date?
|
||||
var colorTag = TagColor.none.rawValue
|
||||
var sortIndex = 0.0
|
||||
@Model
|
||||
final class PersistentIdentity {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
var name = ""
|
||||
var username = ""
|
||||
var credentialRef = ""
|
||||
var passwordStorage = PasswordStorage.iCloud.rawValue
|
||||
var keyID: UUID?
|
||||
var notes = ""
|
||||
var lastUsedAt: Date?
|
||||
var colorTag = TagColor.none.rawValue
|
||||
var sortIndex = 0.0
|
||||
|
||||
init(_ value: Identity) {
|
||||
id = value.id
|
||||
apply(value)
|
||||
init(_ value: Identity) {
|
||||
id = value.id
|
||||
apply(value)
|
||||
}
|
||||
|
||||
func apply(_ value: Identity) {
|
||||
name = value.name
|
||||
username = value.username
|
||||
credentialRef = value.credentialRef
|
||||
passwordStorage = value.passwordStorage.rawValue
|
||||
keyID = value.keyID
|
||||
notes = value.notes
|
||||
lastUsedAt = value.lastUsedAt
|
||||
colorTag = value.colorTag.rawValue
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
|
||||
var value: Identity {
|
||||
Identity(
|
||||
id: id, name: name, username: username, credentialRef: credentialRef,
|
||||
passwordStorage: PasswordStorage(rawValue: passwordStorage) ?? .iCloud,
|
||||
keyID: keyID, notes: notes, lastUsedAt: lastUsedAt,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none, sortIndex: sortIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func apply(_ value: Identity) {
|
||||
name = value.name
|
||||
username = value.username
|
||||
credentialRef = value.credentialRef
|
||||
passwordStorage = value.passwordStorage.rawValue
|
||||
keyID = value.keyID
|
||||
notes = value.notes
|
||||
lastUsedAt = value.lastUsedAt
|
||||
colorTag = value.colorTag.rawValue
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
@Model
|
||||
final class PersistentSSHKey {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
var label = ""
|
||||
var algorithm = SSHKeyAlgorithm.ed25519.rawValue
|
||||
var publicKey = ""
|
||||
var certificate = ""
|
||||
var fingerprint = ""
|
||||
var credentialRef = ""
|
||||
var storage = KeyStorage.iCloud.rawValue
|
||||
var keySize: Int?
|
||||
var hasPassphrase = false
|
||||
var requiresUserPresence = false
|
||||
var createdAt = Date(timeIntervalSince1970: 0)
|
||||
var colorTag = TagColor.none.rawValue
|
||||
var sortIndex = 0.0
|
||||
|
||||
var value: Identity {
|
||||
Identity(
|
||||
id: id, name: name, username: username, credentialRef: credentialRef,
|
||||
passwordStorage: PasswordStorage(rawValue: passwordStorage) ?? .iCloud,
|
||||
keyID: keyID, notes: notes, lastUsedAt: lastUsedAt,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none, sortIndex: sortIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
init(_ value: SSHKeyRef) {
|
||||
id = value.id
|
||||
apply(value)
|
||||
}
|
||||
|
||||
@Model
|
||||
final class PersistentSSHKey {
|
||||
@Attribute(.unique) var id: UUID = UUID()
|
||||
var label = ""
|
||||
var algorithm = SSHKeyAlgorithm.ed25519.rawValue
|
||||
var publicKey = ""
|
||||
var certificate = ""
|
||||
var fingerprint = ""
|
||||
var credentialRef = ""
|
||||
var storage = KeyStorage.iCloud.rawValue
|
||||
var keySize: Int?
|
||||
var hasPassphrase = false
|
||||
var requiresUserPresence = false
|
||||
var createdAt = Date(timeIntervalSince1970: 0)
|
||||
var colorTag = TagColor.none.rawValue
|
||||
var sortIndex = 0.0
|
||||
func apply(_ value: SSHKeyRef) {
|
||||
label = value.label
|
||||
algorithm = value.algorithm.rawValue
|
||||
publicKey = value.publicKey
|
||||
certificate = value.certificate
|
||||
fingerprint = value.fingerprint
|
||||
credentialRef = value.credentialRef
|
||||
storage = value.storage.rawValue
|
||||
keySize = value.keySize
|
||||
hasPassphrase = value.hasPassphrase
|
||||
requiresUserPresence = value.requiresUserPresence
|
||||
createdAt = value.createdAt
|
||||
colorTag = value.colorTag.rawValue
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
|
||||
init(_ value: SSHKeyRef) {
|
||||
id = value.id
|
||||
apply(value)
|
||||
var value: SSHKeyRef {
|
||||
SSHKeyRef(
|
||||
id: id, label: label,
|
||||
algorithm: SSHKeyAlgorithm(rawValue: algorithm) ?? .ed25519,
|
||||
publicKey: publicKey, certificate: certificate, fingerprint: fingerprint,
|
||||
credentialRef: credentialRef,
|
||||
storage: KeyStorage(rawValue: storage) ?? .iCloud,
|
||||
keySize: keySize, hasPassphrase: hasPassphrase,
|
||||
requiresUserPresence: requiresUserPresence, createdAt: createdAt,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none, sortIndex: sortIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func apply(_ value: SSHKeyRef) {
|
||||
label = value.label
|
||||
algorithm = value.algorithm.rawValue
|
||||
publicKey = value.publicKey
|
||||
certificate = value.certificate
|
||||
fingerprint = value.fingerprint
|
||||
credentialRef = value.credentialRef
|
||||
storage = value.storage.rawValue
|
||||
keySize = value.keySize
|
||||
hasPassphrase = value.hasPassphrase
|
||||
requiresUserPresence = value.requiresUserPresence
|
||||
createdAt = value.createdAt
|
||||
colorTag = value.colorTag.rawValue
|
||||
sortIndex = value.sortIndex
|
||||
}
|
||||
|
||||
var value: SSHKeyRef {
|
||||
SSHKeyRef(
|
||||
id: id, label: label,
|
||||
algorithm: SSHKeyAlgorithm(rawValue: algorithm) ?? .ed25519,
|
||||
publicKey: publicKey, certificate: certificate, fingerprint: fingerprint,
|
||||
credentialRef: credentialRef,
|
||||
storage: KeyStorage(rawValue: storage) ?? .iCloud,
|
||||
keySize: keySize, hasPassphrase: hasPassphrase,
|
||||
requiresUserPresence: requiresUserPresence, createdAt: createdAt,
|
||||
colorTag: TagColor(rawValue: colorTag) ?? .none, sortIndex: sortIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias PersistentIdentity = TermindSchemaV7Models.PersistentIdentity
|
||||
@@ -758,14 +758,12 @@ final class HostPersistence {
|
||||
var byID: [UUID: Model] = [:]
|
||||
for row in rows {
|
||||
let id = rowID(row)
|
||||
if byID[id] == nil { byID[id] = row }
|
||||
else { context.delete(row) }
|
||||
if byID[id] == nil { byID[id] = row } else { context.delete(row) }
|
||||
}
|
||||
let wanted = Set(values.map(valueID))
|
||||
for row in byID.values where !wanted.contains(rowID(row)) { context.delete(row) }
|
||||
for value in values {
|
||||
if let row = byID[valueID(value)] { apply(row, value) }
|
||||
else { context.insert(make(value)) }
|
||||
if let row = byID[valueID(value)] { apply(row, value) } else { context.insert(make(value)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,9 +30,11 @@ final class SwiftDataConnectionHistoryRepository: ConnectionHistoryRepository {
|
||||
init(context: ModelContext) { self.context = context }
|
||||
|
||||
func entries() throws -> [ConnectionHistoryEntry] {
|
||||
try context.fetch(FetchDescriptor<PersistentConnectionHistoryEntry>(
|
||||
sortBy: [SortDescriptor(\.connectedAt, order: .reverse)]
|
||||
)).map(\.value)
|
||||
try context.fetch(
|
||||
FetchDescriptor<PersistentConnectionHistoryEntry>(
|
||||
sortBy: [SortDescriptor(\.connectedAt, order: .reverse)]
|
||||
)
|
||||
).map(\.value)
|
||||
}
|
||||
|
||||
func insert(_ entry: ConnectionHistoryEntry, limit: Int) throws {
|
||||
|
||||
@@ -4,8 +4,7 @@ import SwiftData
|
||||
@MainActor
|
||||
final class SwiftDataHostRepository: HostRepository {
|
||||
private let context: ModelContext
|
||||
private var continuations: [UUID: AsyncStream<RepositoryChange<HostLibrarySnapshot>>.Continuation]
|
||||
= [:]
|
||||
private var continuations: [UUID: AsyncStream<RepositoryChange<HostLibrarySnapshot>>.Continuation] = [:]
|
||||
var didRecordVaultChanges: ((VaultMutationSet) -> Void)?
|
||||
|
||||
init(context: ModelContext) {
|
||||
|
||||
@@ -32,19 +32,22 @@ final class SwiftDataSFTPHistoryRepository: SFTPHistoryRepository {
|
||||
init(context: ModelContext) { self.context = context }
|
||||
|
||||
func entries(for hostID: Host.ID) throws -> [SFTPHistoryEntry] {
|
||||
try context.fetch(FetchDescriptor<PersistentSFTPHistoryEntry>(
|
||||
predicate: #Predicate { $0.hostID == hostID },
|
||||
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
|
||||
)).map(\.value)
|
||||
try context.fetch(
|
||||
FetchDescriptor<PersistentSFTPHistoryEntry>(
|
||||
predicate: #Predicate { $0.hostID == hostID },
|
||||
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
|
||||
)
|
||||
).map(\.value)
|
||||
}
|
||||
|
||||
func upsert(_ entry: SFTPHistoryEntry, limit: Int) throws {
|
||||
let hostID = entry.hostID
|
||||
try context.commit {
|
||||
let rows = try context.fetch(FetchDescriptor<PersistentSFTPHistoryEntry>(
|
||||
predicate: #Predicate { $0.hostID == hostID },
|
||||
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
|
||||
))
|
||||
let rows = try context.fetch(
|
||||
FetchDescriptor<PersistentSFTPHistoryEntry>(
|
||||
predicate: #Predicate { $0.hostID == hostID },
|
||||
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
|
||||
))
|
||||
if let row = rows.first(where: {
|
||||
$0.kind == entry.kind.rawValue && $0.path == entry.path
|
||||
}) {
|
||||
|
||||
@@ -55,9 +55,11 @@ final class SwiftDataToolAuditRepository: ToolAuditRepository {
|
||||
init(context: ModelContext) { self.context = context }
|
||||
|
||||
func entries() throws -> [ExternalToolCall] {
|
||||
try context.fetch(FetchDescriptor<PersistentExternalToolCall>(
|
||||
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
|
||||
)).map(\.value)
|
||||
try context.fetch(
|
||||
FetchDescriptor<PersistentExternalToolCall>(
|
||||
sortBy: [SortDescriptor(\.startedAt, order: .reverse)]
|
||||
)
|
||||
).map(\.value)
|
||||
}
|
||||
|
||||
func clear() throws {
|
||||
@@ -97,9 +99,10 @@ final class SwiftDataToolAuditRepository: ToolAuditRepository {
|
||||
func recoverInterruptedCalls() throws {
|
||||
let running = ExternalToolCall.Status.running.rawValue
|
||||
try context.commit {
|
||||
let rows = try context.fetch(FetchDescriptor<PersistentExternalToolCall>(
|
||||
predicate: #Predicate { $0.status == running }
|
||||
))
|
||||
let rows = try context.fetch(
|
||||
FetchDescriptor<PersistentExternalToolCall>(
|
||||
predicate: #Predicate { $0.status == running }
|
||||
))
|
||||
for row in rows {
|
||||
row.status = ExternalToolCall.Status.error.rawValue
|
||||
row.finishedAt = .now
|
||||
|
||||
@@ -38,7 +38,8 @@ actor MCPServerProtocol {
|
||||
case ("POST", "/mcp"):
|
||||
return await streamablePOST(request)
|
||||
case ("GET", "/mcp"):
|
||||
return HTTPResponse.text(405, "This server does not expose a standalone MCP SSE stream.", headers: ["Allow": "POST, DELETE"])
|
||||
return HTTPResponse.text(
|
||||
405, "This server does not expose a standalone MCP SSE stream.", headers: ["Allow": "POST, DELETE"])
|
||||
case ("DELETE", "/mcp"):
|
||||
return await deleteSession(request)
|
||||
case ("GET", "/sse"):
|
||||
@@ -68,8 +69,7 @@ actor MCPServerProtocol {
|
||||
return .text(415, "MCP requests must use application/json.")
|
||||
}
|
||||
let message: RPCMessage
|
||||
do { message = try RPCMessage(request.body) }
|
||||
catch {
|
||||
do { message = try RPCMessage(request.body) } catch {
|
||||
return Self.jsonResponse(.error(id: .null, code: -32700, message: "Parse error"), status: 400)
|
||||
}
|
||||
|
||||
@@ -82,7 +82,8 @@ actor MCPServerProtocol {
|
||||
sessionCountDidChange()
|
||||
let response = await dispatch(message, sessionID: session.id)
|
||||
return Self.jsonResponse(
|
||||
response ?? .error(id: message.id ?? .null, code: -32603, message: "Initialize did not produce a response"),
|
||||
response
|
||||
?? .error(id: message.id ?? .null, code: -32603, message: "Initialize did not produce a response"),
|
||||
headers: ["Mcp-Session-Id": session.id]
|
||||
)
|
||||
}
|
||||
@@ -152,12 +153,12 @@ actor MCPServerProtocol {
|
||||
let continuation = session.legacyContinuation
|
||||
else { return .text(404, "The legacy MCP session is unknown or expired.") }
|
||||
let message: RPCMessage
|
||||
do { message = try RPCMessage(request.body) }
|
||||
catch {
|
||||
continuation.yield(Self.sse(
|
||||
event: "message",
|
||||
data: RPCResponse.error(id: .null, code: -32700, message: "Parse error").data
|
||||
))
|
||||
do { message = try RPCMessage(request.body) } catch {
|
||||
continuation.yield(
|
||||
Self.sse(
|
||||
event: "message",
|
||||
data: RPCResponse.error(id: .null, code: -32700, message: "Parse error").data
|
||||
))
|
||||
return HTTPResponse(status: 202)
|
||||
}
|
||||
if message.method == "initialize" {
|
||||
@@ -180,29 +181,38 @@ actor MCPServerProtocol {
|
||||
switch message.method {
|
||||
case "initialize":
|
||||
guard !session.initialized else {
|
||||
response = isNotification ? nil : .error(
|
||||
id: message.id ?? .null, code: -32600,
|
||||
message: "initialize may only be sent once"
|
||||
)
|
||||
response =
|
||||
isNotification
|
||||
? nil
|
||||
: .error(
|
||||
id: message.id ?? .null, code: -32600,
|
||||
message: "initialize may only be sent once"
|
||||
)
|
||||
break
|
||||
}
|
||||
if let clientName = message.clientName { session.clientName = clientName }
|
||||
let requested = message.params?["protocolVersion"]?.stringValue
|
||||
session.protocolVersion = session.isLegacy
|
||||
session.protocolVersion =
|
||||
session.isLegacy
|
||||
? MCPClient.legacyVersion
|
||||
: Self.negotiatedStreamableVersion(requested)
|
||||
response = isNotification ? nil : .success(id: message.id ?? .null, result: .object([
|
||||
"protocolVersion": .string(session.protocolVersion),
|
||||
"capabilities": .object(["tools": .object([:])]),
|
||||
"serverInfo": .object([
|
||||
"name": .string("termind"),
|
||||
"title": .string("Termind MCP Server"),
|
||||
"version": .string(Bundle.termindDisplayVersion),
|
||||
]),
|
||||
"instructions": .string(
|
||||
"Use list_hosts to discover host_id values. SSH and SFTP calls use Termind's saved Vault credentials and may show an authentication window on this Mac. For secret command input, call request_user_secret, then pass its secret_handle to ssh_exec with stdin_purpose exactly matching the original purpose. The handle is bound to this session, host, tool, and purpose, and can be used only once."
|
||||
),
|
||||
]))
|
||||
response =
|
||||
isNotification
|
||||
? nil
|
||||
: .success(
|
||||
id: message.id ?? .null,
|
||||
result: .object([
|
||||
"protocolVersion": .string(session.protocolVersion),
|
||||
"capabilities": .object(["tools": .object([:])]),
|
||||
"serverInfo": .object([
|
||||
"name": .string("termind"),
|
||||
"title": .string("Termind MCP Server"),
|
||||
"version": .string(Bundle.termindDisplayVersion),
|
||||
]),
|
||||
"instructions": .string(
|
||||
"Use list_hosts to discover host_id values. SSH and SFTP calls use Termind's saved Vault credentials and may show an authentication window on this Mac. For secret command input, call request_user_secret, then pass its secret_handle to ssh_exec with stdin_purpose exactly matching the original purpose. The handle is bound to this session, host, tool, and purpose, and can be used only once."
|
||||
),
|
||||
]))
|
||||
case "notifications/initialized":
|
||||
session.initialized = true
|
||||
response = nil
|
||||
@@ -210,30 +220,42 @@ actor MCPServerProtocol {
|
||||
response = isNotification ? nil : .success(id: message.id ?? .null, result: .object([:]))
|
||||
case "tools/list":
|
||||
guard session.initialized else {
|
||||
response = isNotification ? nil : .error(
|
||||
id: message.id ?? .null, code: -32002, message: "Server not initialized"
|
||||
)
|
||||
response =
|
||||
isNotification
|
||||
? nil
|
||||
: .error(
|
||||
id: message.id ?? .null, code: -32002, message: "Server not initialized"
|
||||
)
|
||||
break
|
||||
}
|
||||
let descriptors = await tools.descriptors
|
||||
response = isNotification ? nil : .success(
|
||||
id: message.id ?? .null,
|
||||
result: .object(["tools": .array(descriptors.map(Self.toolJSON))])
|
||||
)
|
||||
response =
|
||||
isNotification
|
||||
? nil
|
||||
: .success(
|
||||
id: message.id ?? .null,
|
||||
result: .object(["tools": .array(descriptors.map(Self.toolJSON))])
|
||||
)
|
||||
case "tools/call":
|
||||
guard session.initialized else {
|
||||
response = isNotification ? nil : .error(
|
||||
id: message.id ?? .null, code: -32002, message: "Server not initialized"
|
||||
)
|
||||
response =
|
||||
isNotification
|
||||
? nil
|
||||
: .error(
|
||||
id: message.id ?? .null, code: -32002, message: "Server not initialized"
|
||||
)
|
||||
break
|
||||
}
|
||||
guard let params = message.params,
|
||||
let name = params["name"]?.stringValue
|
||||
else {
|
||||
response = isNotification ? nil : .error(
|
||||
id: message.id ?? .null, code: -32602,
|
||||
message: "tools/call requires an enabled tool name and object arguments"
|
||||
)
|
||||
response =
|
||||
isNotification
|
||||
? nil
|
||||
: .error(
|
||||
id: message.id ?? .null, code: -32602,
|
||||
message: "tools/call requires an enabled tool name and object arguments"
|
||||
)
|
||||
break
|
||||
}
|
||||
let arguments = params["arguments"] ?? .object([:])
|
||||
@@ -241,10 +263,13 @@ actor MCPServerProtocol {
|
||||
arguments.objectValue != nil,
|
||||
await tools.contains(name)
|
||||
else {
|
||||
response = isNotification ? nil : .error(
|
||||
id: message.id ?? .null, code: -32602,
|
||||
message: "tools/call requires an enabled tool name and object arguments"
|
||||
)
|
||||
response =
|
||||
isNotification
|
||||
? nil
|
||||
: .error(
|
||||
id: message.id ?? .null, code: -32602,
|
||||
message: "tools/call requires an enabled tool name and object arguments"
|
||||
)
|
||||
break
|
||||
}
|
||||
do {
|
||||
@@ -254,19 +279,28 @@ actor MCPServerProtocol {
|
||||
callID: message.id?.encodedString ?? UUID().uuidString,
|
||||
secretBroker: session.secretBroker
|
||||
)
|
||||
response = isNotification ? nil : .success(
|
||||
id: message.id ?? .null, result: Self.toolResultJSON(result)
|
||||
)
|
||||
response =
|
||||
isNotification
|
||||
? nil
|
||||
: .success(
|
||||
id: message.id ?? .null, result: Self.toolResultJSON(result)
|
||||
)
|
||||
} catch {
|
||||
response = isNotification ? nil : .error(
|
||||
id: message.id ?? .null, code: -32602, message: error.localizedDescription
|
||||
)
|
||||
response =
|
||||
isNotification
|
||||
? nil
|
||||
: .error(
|
||||
id: message.id ?? .null, code: -32602, message: error.localizedDescription
|
||||
)
|
||||
}
|
||||
default:
|
||||
response = isNotification ? nil : .error(
|
||||
id: message.id ?? .null, code: -32601,
|
||||
message: "Method not found: \(message.method)"
|
||||
)
|
||||
response =
|
||||
isNotification
|
||||
? nil
|
||||
: .error(
|
||||
id: message.id ?? .null, code: -32601,
|
||||
message: "Method not found: \(message.method)"
|
||||
)
|
||||
}
|
||||
if sessions[sessionID] != nil {
|
||||
session.lastUsedAt = .now
|
||||
@@ -281,7 +315,8 @@ actor MCPServerProtocol {
|
||||
private func makeSession(message: RPCMessage, legacy: Bool) -> MCPServerSession {
|
||||
let id = UUID().uuidString
|
||||
return MCPServerSession(
|
||||
id: id, runID: UUID(), secretBroker: SecretBroker(), protocolVersion: legacy
|
||||
id: id, runID: UUID(), secretBroker: SecretBroker(),
|
||||
protocolVersion: legacy
|
||||
? MCPClient.legacyVersion : MCPClient.preferredVersion,
|
||||
clientName: message.clientName ?? "MCP client", isLegacy: legacy
|
||||
)
|
||||
@@ -351,13 +386,16 @@ actor MCPServerProtocol {
|
||||
private static func toolResultJSON(_ result: AgentToolResult) -> AgentJSONValue {
|
||||
let text = result.untrustedPayload ?? result.content
|
||||
var value: [String: AgentJSONValue] = [
|
||||
"content": .array([.object([
|
||||
"type": .string("text"), "text": .string(text),
|
||||
])]),
|
||||
"content": .array([
|
||||
.object([
|
||||
"type": .string("text"), "text": .string(text),
|
||||
])
|
||||
]),
|
||||
"isError": .bool(result.isError),
|
||||
]
|
||||
if let parsed = try? AgentJSONValue.decode(Data(text.utf8)) {
|
||||
value["structuredContent"] = parsed.objectValue == nil
|
||||
value["structuredContent"] =
|
||||
parsed.objectValue == nil
|
||||
? .object(["value": parsed]) : parsed
|
||||
}
|
||||
if result.isTruncated {
|
||||
@@ -415,11 +453,12 @@ private nonisolated struct RPCResponse: Sendable {
|
||||
}
|
||||
|
||||
static func error(id: AgentJSONValue, code: Int, message: String) -> Self {
|
||||
Self(value: .object([
|
||||
"jsonrpc": .string("2.0"), "id": id,
|
||||
"error": .object([
|
||||
"code": .number(Double(code)), "message": .string(message),
|
||||
]),
|
||||
]))
|
||||
Self(
|
||||
value: .object([
|
||||
"jsonrpc": .string("2.0"), "id": id,
|
||||
"error": .object([
|
||||
"code": .number(Double(code)), "message": .string(message),
|
||||
]),
|
||||
]))
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,8 @@ actor LoopbackHTTPServer {
|
||||
do { try await Self.send(response, on: connection) } catch { return }
|
||||
if case .stream = response.body { return }
|
||||
let connectionHeader = request.header("connection")?.lowercased()
|
||||
let keepAlive = request.version == "HTTP/1.1"
|
||||
let keepAlive =
|
||||
request.version == "HTTP/1.1"
|
||||
? connectionHeader != "close" : connectionHeader == "keep-alive"
|
||||
if !keepAlive { return }
|
||||
}
|
||||
@@ -215,7 +216,8 @@ actor LoopbackHTTPServer {
|
||||
let count = max(a.count, b.count)
|
||||
var difference = a.count ^ b.count
|
||||
for index in 0..<count {
|
||||
difference |= Int(index < a.count ? a[index] : 0)
|
||||
difference |=
|
||||
Int(index < a.count ? a[index] : 0)
|
||||
^ Int(index < b.count ? b[index] : 0)
|
||||
}
|
||||
return difference == 0
|
||||
@@ -229,7 +231,8 @@ actor LoopbackHTTPServer {
|
||||
components.path.isEmpty, components.query == nil, components.fragment == nil,
|
||||
let rawHost = components.host?.lowercased()
|
||||
else { return false }
|
||||
let host = rawHost.hasPrefix("[") && rawHost.hasSuffix("]")
|
||||
let host =
|
||||
rawHost.hasPrefix("[") && rawHost.hasSuffix("]")
|
||||
? String(rawHost.dropFirst().dropLast()) : rawHost
|
||||
if host == "localhost" || host == "::1" { return true }
|
||||
let octets = host.split(separator: ".").compactMap { UInt8($0) }
|
||||
@@ -285,20 +288,27 @@ actor LoopbackHTTPServer {
|
||||
while remainder.count < length { remainder.append(try await receive(connection)) }
|
||||
let body = Data(remainder.prefix(length))
|
||||
remainder.removeFirst(length)
|
||||
return (HTTPRequest(
|
||||
method: String(requestLine[0]), target: String(requestLine[1]),
|
||||
version: String(requestLine[2]), headers: headers, body: body
|
||||
), remainder)
|
||||
return (
|
||||
HTTPRequest(
|
||||
method: String(requestLine[0]), target: String(requestLine[1]),
|
||||
version: String(requestLine[2]), headers: headers, body: body
|
||||
), remainder
|
||||
)
|
||||
}
|
||||
|
||||
private static func receive(_ connection: NWConnection) async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Data, any Error>) in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1_024) {
|
||||
data, _, complete, error in
|
||||
if let error { continuation.resume(throwing: error) }
|
||||
else if let data, !data.isEmpty { continuation.resume(returning: data) }
|
||||
else if complete { continuation.resume(throwing: LoopbackHTTPServerError.endOfStream) }
|
||||
else { continuation.resume(throwing: LoopbackHTTPServerError.malformed) }
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else if let data, !data.isEmpty {
|
||||
continuation.resume(returning: data)
|
||||
} else if complete {
|
||||
continuation.resume(throwing: LoopbackHTTPServerError.endOfStream)
|
||||
} else {
|
||||
continuation.resume(throwing: LoopbackHTTPServerError.malformed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -333,10 +343,11 @@ actor LoopbackHTTPServer {
|
||||
|
||||
private static func send(_ data: Data, on connection: NWConnection) async throws {
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
||||
connection.send(content: data, completion: .contentProcessed { error in
|
||||
if let error { continuation.resume(throwing: error) }
|
||||
else { continuation.resume() }
|
||||
})
|
||||
connection.send(
|
||||
content: data,
|
||||
completion: .contentProcessed { error in
|
||||
if let error { continuation.resume(throwing: error) } else { continuation.resume() }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,4 +40,3 @@ extension SkillLibraryModel {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,17 @@ extension DispatchQueue {
|
||||
) async throws -> T {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
async {
|
||||
do { continuation.resume(returning: try operation()) }
|
||||
catch { continuation.resume(throwing: error) }
|
||||
do { continuation.resume(returning: try operation()) } catch { continuation.resume(throwing: error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func perform(_ operation: @escaping @Sendable () -> Void) async {
|
||||
await withCheckedContinuation { continuation in
|
||||
async { operation(); continuation.resume() }
|
||||
async {
|
||||
operation()
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ nonisolated enum PortForwardingRuntimeState: Hashable, Sendable {
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .preparing, .awaitingTrust, .awaitingAuthentication, .connecting,
|
||||
.starting, .running, .reconnecting: true
|
||||
.starting, .running, .reconnecting:
|
||||
true
|
||||
case .stopped, .failed: false
|
||||
}
|
||||
}
|
||||
@@ -87,9 +88,9 @@ final class PortForwardingController {
|
||||
for id in all { states[id] = .stopped }
|
||||
let active = sessions
|
||||
sessions.removeAll()
|
||||
connectionTasks.values.forEach { $0.cancel() }
|
||||
for task in connectionTasks.values { task.cancel() }
|
||||
connectionTasks.removeAll()
|
||||
reconnectTasks.values.forEach { $0.cancel() }
|
||||
for task in reconnectTasks.values { task.cancel() }
|
||||
reconnectTasks.removeAll()
|
||||
reconnectAttempts.removeAll()
|
||||
connectedAt.removeAll()
|
||||
@@ -117,8 +118,7 @@ final class PortForwardingController {
|
||||
try await created.connect(configuration)
|
||||
}
|
||||
connectionTasks[host.id] = task
|
||||
do { try await task.value }
|
||||
catch {
|
||||
do { try await task.value } catch {
|
||||
sessions[host.id] = nil
|
||||
connectionTasks[host.id] = nil
|
||||
throw error
|
||||
@@ -130,7 +130,10 @@ final class PortForwardingController {
|
||||
guard desired.contains(rule.id) else { return }
|
||||
states[rule.id] = .starting
|
||||
try await session.start(rule)
|
||||
guard desired.contains(rule.id) else { await session.stop(rule.id); return }
|
||||
guard desired.contains(rule.id) else {
|
||||
await session.stop(rule.id)
|
||||
return
|
||||
}
|
||||
states[rule.id] = .running(activeConnections: 0)
|
||||
} catch {
|
||||
if desired.contains(rule.id),
|
||||
@@ -201,8 +204,7 @@ final class PortForwardingController {
|
||||
states[id] = .reconnecting(attempt: attempt, nextRetry: nextRetry, message: message)
|
||||
}
|
||||
reconnectTasks[hostID] = Task { [weak self] in
|
||||
do { try await Task.sleep(for: .seconds(delay)) }
|
||||
catch { return }
|
||||
do { try await Task.sleep(for: .seconds(delay)) } catch { return }
|
||||
guard let self else { return }
|
||||
await self.reconnect(hostID, attempt: attempt)
|
||||
}
|
||||
|
||||
@@ -48,13 +48,14 @@ nonisolated struct RemoteFile: Identifiable, Hashable, Sendable {
|
||||
}
|
||||
|
||||
var permissionDescription: String {
|
||||
let type: Character = switch kind {
|
||||
case .file: "-"
|
||||
case .directory: "d"
|
||||
case .symbolicLink: "l"
|
||||
case .special: "s"
|
||||
case .unknown: "?"
|
||||
}
|
||||
let type: Character =
|
||||
switch kind {
|
||||
case .file: "-"
|
||||
case .directory: "d"
|
||||
case .symbolicLink: "l"
|
||||
case .special: "s"
|
||||
case .unknown: "?"
|
||||
}
|
||||
let masks: [UInt32] = [0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001]
|
||||
let letters: [Character] = ["r", "w", "x", "r", "w", "x", "r", "w", "x"]
|
||||
return String([type] + zip(masks, letters).map { permissions & $0.0 == 0 ? "-" : $0.1 })
|
||||
@@ -99,18 +100,20 @@ nonisolated private struct SFTPProgressMeter {
|
||||
0.001,
|
||||
Double(components.seconds) + Double(components.attoseconds) / 1e18
|
||||
)
|
||||
report(SFTPProgress(
|
||||
completedBytes: completed, totalBytes: total,
|
||||
bytesPerSecond: Int64(Double(sampledBytes) / seconds)
|
||||
))
|
||||
report(
|
||||
SFTPProgress(
|
||||
completedBytes: completed, totalBytes: total,
|
||||
bytesPerSecond: Int64(Double(sampledBytes) / seconds)
|
||||
))
|
||||
sampledBytes = 0
|
||||
sampledAt = .now
|
||||
}
|
||||
|
||||
nonisolated func finish() {
|
||||
report(SFTPProgress(
|
||||
completedBytes: completed, totalBytes: total, bytesPerSecond: 0
|
||||
))
|
||||
report(
|
||||
SFTPProgress(
|
||||
completedBytes: completed, totalBytes: total, bytesPerSecond: 0
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,12 +453,14 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
func listDirectory(at path: String) async throws -> [RemoteFile] {
|
||||
try await queue.perform {
|
||||
let sftp = try self.requireSFTP()
|
||||
guard let handle = path.withCString({
|
||||
libssh2_sftp_open_ex(
|
||||
sftp, $0, UInt32(path.utf8.count), 0, 0,
|
||||
Int32(LIBSSH2_SFTP_OPENDIR)
|
||||
)
|
||||
}) else {
|
||||
guard
|
||||
let handle = path.withCString({
|
||||
libssh2_sftp_open_ex(
|
||||
sftp, $0, UInt32(path.utf8.count), 0, 0,
|
||||
Int32(LIBSSH2_SFTP_OPENDIR)
|
||||
)
|
||||
})
|
||||
else {
|
||||
throw self.error("Unable to open \(path)")
|
||||
}
|
||||
defer { _ = libssh2_sftp_close_handle(handle) }
|
||||
@@ -470,7 +475,8 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
let bytes = name.prefix(Int(count)).map { UInt8(bitPattern: $0) }
|
||||
let filename = String(decoding: bytes, as: UTF8.self)
|
||||
guard filename != ".", filename != ".." else { continue }
|
||||
result.append(Self.remoteFile(path: Self.join(path, filename), name: filename, attributes: attributes))
|
||||
result.append(
|
||||
Self.remoteFile(path: Self.join(path, filename), name: filename, attributes: attributes))
|
||||
} else if count == 0 {
|
||||
break
|
||||
} else {
|
||||
@@ -511,9 +517,13 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
while data.count <= maximumBytes {
|
||||
let wanted = min(buffer.count, maximumBytes + 1 - data.count)
|
||||
let count = libssh2_sftp_read(handle, &buffer, wanted)
|
||||
if count > 0 { data.append(Data(bytes: buffer, count: count)) }
|
||||
else if count == 0 { break }
|
||||
else { throw self.error("Unable to read \(path)") }
|
||||
if count > 0 {
|
||||
data.append(Data(bytes: buffer, count: count))
|
||||
} else if count == 0 {
|
||||
break
|
||||
} else {
|
||||
throw self.error("Unable to read \(path)")
|
||||
}
|
||||
}
|
||||
guard data.count <= maximumBytes else {
|
||||
throw SFTPError.operation("\(path) exceeds the inline read limit of \(maximumBytes) bytes.")
|
||||
@@ -604,9 +614,11 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
func removeFile(at path: String) async throws {
|
||||
try await queue.perform {
|
||||
let sftp = try self.requireSFTP()
|
||||
guard path.withCString({
|
||||
libssh2_sftp_unlink_ex(sftp, $0, UInt32(path.utf8.count))
|
||||
}) == 0 else {
|
||||
guard
|
||||
path.withCString({
|
||||
libssh2_sftp_unlink_ex(sftp, $0, UInt32(path.utf8.count))
|
||||
}) == 0
|
||||
else {
|
||||
throw self.error("Unable to delete \(path)")
|
||||
}
|
||||
}
|
||||
@@ -615,9 +627,11 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
func removeDirectory(at path: String) async throws {
|
||||
try await queue.perform {
|
||||
let sftp = try self.requireSFTP()
|
||||
guard path.withCString({
|
||||
libssh2_sftp_rmdir_ex(sftp, $0, UInt32(path.utf8.count))
|
||||
}) == 0 else {
|
||||
guard
|
||||
path.withCString({
|
||||
libssh2_sftp_rmdir_ex(sftp, $0, UInt32(path.utf8.count))
|
||||
}) == 0
|
||||
else {
|
||||
throw self.error("Unable to delete \(path)")
|
||||
}
|
||||
}
|
||||
@@ -631,13 +645,15 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
try await queue.perform {
|
||||
let sftp = try self.requireSFTP()
|
||||
let file: FileHandle
|
||||
do { file = try FileHandle(forReadingFrom: localURL) }
|
||||
catch { throw SFTPError.localFile(error.localizedDescription) }
|
||||
do { file = try FileHandle(forReadingFrom: localURL) } catch {
|
||||
throw SFTPError.localFile(error.localizedDescription)
|
||||
}
|
||||
defer { try? file.close() }
|
||||
try self.withAtomicRemoteWrite(
|
||||
sftp: sftp, destination: remotePath, overwrite: overwrite
|
||||
) { handle in
|
||||
let total = (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize)
|
||||
let total =
|
||||
(try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize)
|
||||
.map(Int64.init) ?? 0
|
||||
var meter = SFTPProgressMeter(total: total, report: progress)
|
||||
while true {
|
||||
@@ -674,7 +690,8 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
try await queue.perform {
|
||||
try self.withRemoteReadHandle(at: remotePath) { handle in
|
||||
let fm = FileManager.default
|
||||
let temporary = localURL.deletingLastPathComponent().appendingPathComponent(".termind-part-\(UUID().uuidString)")
|
||||
let temporary = localURL.deletingLastPathComponent().appendingPathComponent(
|
||||
".termind-part-\(UUID().uuidString)")
|
||||
guard fm.createFile(atPath: temporary.path, contents: nil) else {
|
||||
throw SFTPError.localFile("Unable to create \(temporary.path)")
|
||||
}
|
||||
@@ -693,8 +710,11 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
if count > 0 {
|
||||
try file.write(contentsOf: Data(bytes: buffer, count: count))
|
||||
meter.add(count)
|
||||
} else if count == 0 { break }
|
||||
else { throw self.error("Unable to download \(remotePath)") }
|
||||
} else if count == 0 {
|
||||
break
|
||||
} else {
|
||||
throw self.error("Unable to download \(remotePath)")
|
||||
}
|
||||
}
|
||||
try file.synchronize()
|
||||
meter.finish()
|
||||
@@ -824,12 +844,14 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
) throws {
|
||||
let temporary = destination + temporaryTag + UUID().uuidString
|
||||
let flags = UInt(LIBSSH2_FXF_WRITE | LIBSSH2_FXF_CREAT | LIBSSH2_FXF_TRUNC)
|
||||
guard let handle = temporary.withCString({
|
||||
libssh2_sftp_open_ex(
|
||||
sftp, $0, UInt32(temporary.utf8.count), flags, 0o600,
|
||||
Int32(LIBSSH2_SFTP_OPENFILE)
|
||||
)
|
||||
}) else {
|
||||
guard
|
||||
let handle = temporary.withCString({
|
||||
libssh2_sftp_open_ex(
|
||||
sftp, $0, UInt32(temporary.utf8.count), flags, 0o600,
|
||||
Int32(LIBSSH2_SFTP_OPENFILE)
|
||||
)
|
||||
})
|
||||
else {
|
||||
throw error("Unable to create \(destination)")
|
||||
}
|
||||
var succeeded = false
|
||||
@@ -848,7 +870,8 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
succeeded = true
|
||||
}
|
||||
|
||||
private func renameOnQueue(sftp: OpaquePointer, from source: String, to destination: String, overwrite: Bool) throws {
|
||||
private func renameOnQueue(sftp: OpaquePointer, from source: String, to destination: String, overwrite: Bool) throws
|
||||
{
|
||||
let flags = overwrite ? Int(LIBSSH2_SFTP_RENAME_OVERWRITE) : 0
|
||||
let result = source.withCString { sourceBuffer in
|
||||
destination.withCString { destinationBuffer in
|
||||
@@ -888,7 +911,10 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
private func cleanup() {
|
||||
keepaliveTimer?.cancel()
|
||||
keepaliveTimer = nil
|
||||
if let sftp { _ = libssh2_sftp_shutdown(sftp); self.sftp = nil }
|
||||
if let sftp {
|
||||
_ = libssh2_sftp_shutdown(sftp)
|
||||
self.sftp = nil
|
||||
}
|
||||
if let session {
|
||||
_ = libssh2_session_disconnect_ex(session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed SFTP", "")
|
||||
_ = libssh2_session_free(session)
|
||||
@@ -934,11 +960,17 @@ private nonisolated final class SFTPWorker: @unchecked Sendable {
|
||||
) -> RemoteFile {
|
||||
let mode = UInt32(attributes.permissions)
|
||||
let kind: RemoteFile.Kind
|
||||
if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFDIR) { kind = .directory }
|
||||
else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFREG) { kind = .file }
|
||||
else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFLNK) { kind = .symbolicLink }
|
||||
else if mode == 0 { kind = .unknown }
|
||||
else { kind = .special }
|
||||
if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFDIR) {
|
||||
kind = .directory
|
||||
} else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFREG) {
|
||||
kind = .file
|
||||
} else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFLNK) {
|
||||
kind = .symbolicLink
|
||||
} else if mode == 0 {
|
||||
kind = .unknown
|
||||
} else {
|
||||
kind = .special
|
||||
}
|
||||
return RemoteFile(
|
||||
path: path, name: name, kind: kind, size: Int64(attributes.filesize),
|
||||
modifiedAt: attributes.mtime == 0 ? nil : Date(timeIntervalSince1970: TimeInterval(attributes.mtime)),
|
||||
|
||||
@@ -147,9 +147,11 @@ private nonisolated final class SSHExecWorker: @unchecked Sendable {
|
||||
}
|
||||
// Blocking for the two setup calls, non-blocking for the pump below.
|
||||
libssh2_session_set_blocking(session, 1)
|
||||
guard let channel = libssh2_channel_open_ex(
|
||||
session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0
|
||||
) else {
|
||||
guard
|
||||
let channel = libssh2_channel_open_ex(
|
||||
session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0
|
||||
)
|
||||
else {
|
||||
throw notStarted(session, prefix: "Unable to open SSH exec channel")
|
||||
}
|
||||
// Only a command that ran to completion leaves a channel worth closing
|
||||
@@ -212,14 +214,18 @@ private nonisolated final class SSHExecWorker: @unchecked Sendable {
|
||||
bytes.count - inputOffset
|
||||
)
|
||||
}
|
||||
if written > 0 { inputOffset += written; progressed = true }
|
||||
else if written != LIBSSH2_ERROR_EAGAIN {
|
||||
if written > 0 {
|
||||
inputOffset += written
|
||||
progressed = true
|
||||
} else if written != LIBSSH2_ERROR_EAGAIN {
|
||||
throw failed(session, prefix: "Unable to write command input")
|
||||
}
|
||||
} else if !sentEOF {
|
||||
let result = libssh2_channel_send_eof(channel)
|
||||
if result == 0 { sentEOF = true; progressed = true }
|
||||
else if result != LIBSSH2_ERROR_EAGAIN {
|
||||
if result == 0 {
|
||||
sentEOF = true
|
||||
progressed = true
|
||||
} else if result != LIBSSH2_ERROR_EAGAIN {
|
||||
throw failed(session, prefix: "Unable to finish command input")
|
||||
}
|
||||
}
|
||||
@@ -241,8 +247,11 @@ private nonisolated final class SSHExecWorker: @unchecked Sendable {
|
||||
truncated: &errorTruncated
|
||||
)
|
||||
}
|
||||
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { break }
|
||||
else { throw failed(session, prefix: "Unable to read command output") }
|
||||
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 {
|
||||
break
|
||||
} else {
|
||||
throw failed(session, prefix: "Unable to read command output")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !progressed {
|
||||
@@ -297,9 +306,10 @@ private nonisolated final class SSHExecWorker: @unchecked Sendable {
|
||||
)
|
||||
let signal: String?
|
||||
if signalResult == 0, let exitSignal {
|
||||
signal = String(decoding: UnsafeBufferPointer(
|
||||
start: exitSignal, count: exitSignalLength
|
||||
).map(UInt8.init(bitPattern:)), as: UTF8.self)
|
||||
signal = String(
|
||||
decoding: UnsafeBufferPointer(
|
||||
start: exitSignal, count: exitSignalLength
|
||||
).map(UInt8.init(bitPattern:)), as: UTF8.self)
|
||||
libssh2_free(session, exitSignal)
|
||||
} else {
|
||||
signal = nil
|
||||
|
||||
@@ -133,7 +133,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
queue.async { [self] in
|
||||
do {
|
||||
guard let session else { throw TermindSSHError.disconnected }
|
||||
if rules[rule.id] != nil { continuation.resume(); return }
|
||||
if rules[rule.id] != nil {
|
||||
continuation.resume()
|
||||
return
|
||||
}
|
||||
let context = RuleContext(rule: rule)
|
||||
switch rule.kind {
|
||||
case .local, .dynamic:
|
||||
@@ -181,7 +184,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
|
||||
func disconnect() async {
|
||||
await withCheckedContinuation { continuation in
|
||||
queue.async { [self] in cleanup(); continuation.resume() }
|
||||
queue.async { [self] in
|
||||
cleanup()
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,12 +229,16 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
let flow = Flow(socket: accepted, dynamic: context.rule.kind == .dynamic)
|
||||
if context.rule.kind == .local {
|
||||
guard let targetHost = context.rule.targetHost, let targetPort = context.rule.targetPort else {
|
||||
Darwin.close(accepted); continue
|
||||
Darwin.close(accepted)
|
||||
continue
|
||||
}
|
||||
flow.channel = targetHost.withCString { target in
|
||||
libssh2_channel_direct_tcpip_ex(session, target, Int32(targetPort), "127.0.0.1", 0)
|
||||
}
|
||||
guard flow.channel != nil else { Darwin.close(accepted); continue }
|
||||
guard flow.channel != nil else {
|
||||
Darwin.close(accepted)
|
||||
continue
|
||||
}
|
||||
}
|
||||
context.flows[flow.id] = flow
|
||||
}
|
||||
@@ -261,18 +271,21 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
let count = Darwin.recv(flow.socket, &buffer, buffer.count, 0)
|
||||
if count > 0 {
|
||||
let data = Data(buffer.prefix(count))
|
||||
if flow.phase == .forwarding { flow.toChannel.append(data) }
|
||||
else {
|
||||
if flow.phase == .forwarding {
|
||||
flow.toChannel.append(data)
|
||||
} else {
|
||||
flow.handshake.append(data)
|
||||
if flow.handshake.count > 64 * 1024 {
|
||||
close(flow, in: context); return
|
||||
close(flow, in: context)
|
||||
return
|
||||
}
|
||||
handleSOCKSHandshake(flow, session: session)
|
||||
}
|
||||
} else if count == 0 {
|
||||
flow.socketEOF = true
|
||||
} else if errno != EAGAIN && errno != EWOULDBLOCK {
|
||||
close(flow, in: context); return
|
||||
close(flow, in: context)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,24 +295,36 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 }
|
||||
return libssh2_channel_write_ex(channel, 0, base, bytes.count)
|
||||
}
|
||||
if written > 0 { flow.toChannel.removeFirst(written) }
|
||||
else if written == LIBSSH2_ERROR_EAGAIN { break }
|
||||
else { close(flow, in: context); return }
|
||||
if written > 0 {
|
||||
flow.toChannel.removeFirst(written)
|
||||
} else if written == LIBSSH2_ERROR_EAGAIN {
|
||||
break
|
||||
} else {
|
||||
close(flow, in: context)
|
||||
return
|
||||
}
|
||||
}
|
||||
if flow.socketEOF, flow.toChannel.isEmpty, !flow.channelWriteClosed {
|
||||
let result = libssh2_channel_send_eof(channel)
|
||||
if result == 0 { flow.channelWriteClosed = true }
|
||||
else if result != LIBSSH2_ERROR_EAGAIN {
|
||||
close(flow, in: context); return
|
||||
if result == 0 {
|
||||
flow.channelWriteClosed = true
|
||||
} else if result != LIBSSH2_ERROR_EAGAIN {
|
||||
close(flow, in: context)
|
||||
return
|
||||
}
|
||||
}
|
||||
if flow.toSocket.count < highWaterMark {
|
||||
var buffer = [CChar](repeating: 0, count: 32 * 1024)
|
||||
while flow.toSocket.count < highWaterMark {
|
||||
let count = libssh2_channel_read_ex(channel, 0, &buffer, buffer.count)
|
||||
if count > 0 { flow.toSocket.append(Data(bytes: buffer, count: count)) }
|
||||
else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { break }
|
||||
else { close(flow, in: context); return }
|
||||
if count > 0 {
|
||||
flow.toSocket.append(Data(bytes: buffer, count: count))
|
||||
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 {
|
||||
break
|
||||
} else {
|
||||
close(flow, in: context)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if libssh2_channel_eof(channel) == 1 { flow.channelEOF = true }
|
||||
@@ -310,16 +335,21 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
guard let base = bytes.baseAddress else { return 0 }
|
||||
return Darwin.send(flow.socket, base, bytes.count, 0)
|
||||
}
|
||||
if sent > 0 { flow.toSocket.removeFirst(sent) }
|
||||
else if errno == EAGAIN || errno == EWOULDBLOCK { break }
|
||||
else { close(flow, in: context); return }
|
||||
if sent > 0 {
|
||||
flow.toSocket.removeFirst(sent)
|
||||
} else if errno == EAGAIN || errno == EWOULDBLOCK {
|
||||
break
|
||||
} else {
|
||||
close(flow, in: context)
|
||||
return
|
||||
}
|
||||
}
|
||||
if flow.channelEOF, flow.toSocket.isEmpty, !flow.socketWriteClosed {
|
||||
_ = Darwin.shutdown(flow.socket, SHUT_WR)
|
||||
flow.socketWriteClosed = true
|
||||
}
|
||||
if flow.socketEOF,
|
||||
(flow.channel == nil || flow.channelEOF),
|
||||
flow.channel == nil || flow.channelEOF,
|
||||
flow.toChannel.isEmpty, flow.toSocket.isEmpty
|
||||
{
|
||||
close(flow, in: context)
|
||||
@@ -335,7 +365,9 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
guard flow.handshake[0] == 0x05,
|
||||
flow.handshake[2..<(2 + methods)].contains(0x00)
|
||||
else {
|
||||
flow.toSocket.append(contentsOf: [0x05, 0xff]); flow.socketEOF = true; return
|
||||
flow.toSocket.append(contentsOf: [0x05, 0xff])
|
||||
flow.socketEOF = true
|
||||
return
|
||||
}
|
||||
flow.handshake.removeFirst(2 + methods)
|
||||
flow.toSocket.append(contentsOf: [0x05, 0x00])
|
||||
@@ -344,7 +376,9 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
case .request:
|
||||
guard flow.handshake.count >= 4 else { return }
|
||||
guard flow.handshake[0] == 0x05, flow.handshake[1] == 0x01 else {
|
||||
flow.toSocket.append(socksReply(0x07)); flow.socketEOF = true; return
|
||||
flow.toSocket.append(socksReply(0x07))
|
||||
flow.socketEOF = true
|
||||
return
|
||||
}
|
||||
let parsed: (String, Int, Int)?
|
||||
switch flow.handshake[3] {
|
||||
@@ -377,7 +411,9 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
Int(flow.handshake[20]) << 8 | Int(flow.handshake[21]), 22
|
||||
)
|
||||
default:
|
||||
flow.toSocket.append(socksReply(0x08)); flow.socketEOF = true; return
|
||||
flow.toSocket.append(socksReply(0x08))
|
||||
flow.socketEOF = true
|
||||
return
|
||||
}
|
||||
guard let (host, port, consumed) = parsed else { return }
|
||||
flow.handshake.removeFirst(consumed)
|
||||
@@ -385,12 +421,15 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
libssh2_channel_direct_tcpip_ex(session, target, Int32(port), "127.0.0.1", 0)
|
||||
}
|
||||
guard flow.channel != nil else {
|
||||
flow.toSocket.append(socksReply(0x05)); flow.socketEOF = true; return
|
||||
flow.toSocket.append(socksReply(0x05))
|
||||
flow.socketEOF = true
|
||||
return
|
||||
}
|
||||
flow.toSocket.append(socksReply(0x00))
|
||||
flow.phase = .forwarding
|
||||
if !flow.handshake.isEmpty {
|
||||
flow.toChannel.append(flow.handshake); flow.handshake.removeAll()
|
||||
flow.toChannel.append(flow.handshake)
|
||||
flow.handshake.removeAll()
|
||||
}
|
||||
case .forwarding: break
|
||||
}
|
||||
@@ -437,7 +476,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
|
||||
private func close(_ flow: Flow, in context: RuleContext) {
|
||||
context.flows[flow.id] = nil
|
||||
if flow.socket >= 0 { Darwin.close(flow.socket); flow.socket = -1 }
|
||||
if flow.socket >= 0 {
|
||||
Darwin.close(flow.socket)
|
||||
flow.socket = -1
|
||||
}
|
||||
if let channel = flow.channel {
|
||||
_ = libssh2_channel_close(channel)
|
||||
_ = libssh2_channel_free(channel)
|
||||
@@ -447,7 +489,10 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
|
||||
private func close(_ context: RuleContext) {
|
||||
for flow in Array(context.flows.values) { close(flow, in: context) }
|
||||
if context.socket >= 0 { Darwin.close(context.socket); context.socket = -1 }
|
||||
if context.socket >= 0 {
|
||||
Darwin.close(context.socket)
|
||||
context.socket = -1
|
||||
}
|
||||
if let listener = context.remoteListener {
|
||||
_ = libssh2_channel_forward_cancel(listener)
|
||||
context.remoteListener = nil
|
||||
@@ -455,7 +500,8 @@ private nonisolated final class SSHForwardingWorker: @unchecked Sendable {
|
||||
}
|
||||
|
||||
private func cleanup() {
|
||||
timer?.cancel(); timer = nil
|
||||
timer?.cancel()
|
||||
timer = nil
|
||||
for context in Array(rules.values) { close(context) }
|
||||
rules.removeAll()
|
||||
if let session {
|
||||
|
||||
@@ -89,7 +89,7 @@ nonisolated struct SSHKeyMaterial: Sendable {
|
||||
var blobLength = 0
|
||||
let result = privateKey.withCString { privateBuffer in
|
||||
let parse: (UnsafePointer<CChar>?) -> Int32 = { passphraseBuffer in
|
||||
termind_libssh2_publickey_from_private_memory(
|
||||
libssh2PublicKeyFromPrivateMemory(
|
||||
session, &method, &methodLength, &blob, &blobLength,
|
||||
privateBuffer, privateKey.utf8.count, passphraseBuffer
|
||||
)
|
||||
@@ -307,7 +307,7 @@ extension SSHKeyMaterial.Generation {
|
||||
/// argument is nil. Using it here keeps imports and authentication consistent,
|
||||
/// including passphrase-protected OpenSSH keys.
|
||||
@_silgen_name("_libssh2_pub_priv_keyfilememory")
|
||||
private nonisolated func termind_libssh2_publickey_from_private_memory(
|
||||
private nonisolated func libssh2PublicKeyFromPrivateMemory(
|
||||
_ session: OpaquePointer?,
|
||||
_ method: UnsafeMutablePointer<UnsafeMutablePointer<UInt8>?>?,
|
||||
_ methodLength: UnsafeMutablePointer<Int>?,
|
||||
|
||||
@@ -2,7 +2,7 @@ import CSSH2
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
/// Installs a small cwd-reporting hook before remote output reaches Ghostty.
|
||||
/// Installs a small cwd-reporting hook before remote output reaches Ghostty.
|
||||
/// The probe, its echo, and the installation command stay inside this state
|
||||
/// machine; only the login banner and the final integrated prompt are emitted.
|
||||
nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
@@ -49,19 +49,17 @@ nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
// sourcing it. Its output precedes the shell marker, so a missing `sh`
|
||||
// still leaves the original shell-only bootstrap path usable.
|
||||
startCommand = Data(
|
||||
(
|
||||
" command sh -c 'f=; did=; dlike=; dver=; "
|
||||
+ "[ -r /etc/os-release ] && f=/etc/os-release; "
|
||||
+ "[ -z \"$f\" ] && [ -r /usr/lib/os-release ] && f=/usr/lib/os-release; "
|
||||
+ "if [ -n \"$f\" ]; then while IFS= read -r line; do case \"$line\" in "
|
||||
+ "ID=*) did=${line#ID=} ;; ID_LIKE=*) dlike=${line#ID_LIKE=} ;; "
|
||||
+ "VERSION_ID=*) dver=${line#VERSION_ID=} ;; esac; "
|
||||
+ "done < \"$f\"; fi; kn=$(uname -s 2>/dev/null); "
|
||||
+ "kr=$(uname -r 2>/dev/null); ka=$(uname -m 2>/dev/null); "
|
||||
+ "printf \"\\n__TRMD_%s_SYSTEM__%s|%s|%s|%s|%s|%s__\\n\" "
|
||||
+ "\"$1\" \"$did\" \"$dlike\" \"$dver\" \"$kn\" \"$kr\" \"$ka\"' sh '\(token)'; "
|
||||
+ "printf '\\n__TRMD_%s_SHELL__%s__\\n' '\(token)' \"$SHELL\"\n"
|
||||
).utf8
|
||||
(" command sh -c 'f=; did=; dlike=; dver=; "
|
||||
+ "[ -r /etc/os-release ] && f=/etc/os-release; "
|
||||
+ "[ -z \"$f\" ] && [ -r /usr/lib/os-release ] && f=/usr/lib/os-release; "
|
||||
+ "if [ -n \"$f\" ]; then while IFS= read -r line; do case \"$line\" in "
|
||||
+ "ID=*) did=${line#ID=} ;; ID_LIKE=*) dlike=${line#ID_LIKE=} ;; "
|
||||
+ "VERSION_ID=*) dver=${line#VERSION_ID=} ;; esac; "
|
||||
+ "done < \"$f\"; fi; kn=$(uname -s 2>/dev/null); "
|
||||
+ "kr=$(uname -r 2>/dev/null); ka=$(uname -m 2>/dev/null); "
|
||||
+ "printf \"\\n__TRMD_%s_SYSTEM__%s|%s|%s|%s|%s|%s__\\n\" "
|
||||
+ "\"$1\" \"$did\" \"$dlike\" \"$dver\" \"$kn\" \"$kr\" \"$ka\"' sh '\(token)'; "
|
||||
+ "printf '\\n__TRMD_%s_SHELL__%s__\\n' '\(token)' \"$SHELL\"\n").utf8
|
||||
)
|
||||
deadline = now + Self.responseTimeout
|
||||
}
|
||||
@@ -337,7 +335,8 @@ nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
|
||||
}
|
||||
|
||||
private func installationCommand(for shell: String?) -> Data? {
|
||||
let marker = "builtin printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' \"$(__termind_encode \"$HOME\")\"; builtin printf '\\n__TRMD_%s_DONE__\\n' '\(token)'"
|
||||
let marker =
|
||||
"builtin printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' \"$(__termind_encode \"$HOME\")\"; builtin printf '\\n__TRMD_%s_DONE__\\n' '\(token)'"
|
||||
let command: String
|
||||
switch shell {
|
||||
case "bash":
|
||||
@@ -424,7 +423,8 @@ nonisolated struct RemoteCommandEventFilter: Sendable {
|
||||
if let statusText = fields.first, fields.count <= 2,
|
||||
let status = Int32(statusText), (0...255).contains(status)
|
||||
{
|
||||
let duration = fields.count == 2
|
||||
let duration =
|
||||
fields.count == 2
|
||||
? Self.duration(milliseconds: fields[1])
|
||||
: nil
|
||||
update.events.append(.commandFinished(status, duration: duration))
|
||||
@@ -829,7 +829,8 @@ nonisolated final class SSHSessionWorker: @unchecked Sendable {
|
||||
let printableName = keyName.unicodeScalars
|
||||
.map { CharacterSet.controlCharacters.contains($0) ? " " : String($0) }
|
||||
.joined()
|
||||
let normalizedName = printableName
|
||||
let normalizedName =
|
||||
printableName
|
||||
.split(whereSeparator: \Character.isWhitespace)
|
||||
.joined(separator: " ")
|
||||
let safeName = normalizedName.isEmpty ? "Key" : normalizedName
|
||||
|
||||
@@ -67,8 +67,9 @@ nonisolated enum SSHSocketConnector {
|
||||
// the following libssh2 handshake wait for bytes already discarded.
|
||||
response.append(try readSome(socket, maximum: 1))
|
||||
}
|
||||
guard let firstLine = String(decoding: response, as: UTF8.self)
|
||||
.split(separator: "\n", maxSplits: 1).first
|
||||
guard
|
||||
let firstLine = String(decoding: response, as: UTF8.self)
|
||||
.split(separator: "\n", maxSplits: 1).first
|
||||
else { throw TermindSSHError.proxy("The HTTP proxy returned an empty response.") }
|
||||
let fields = firstLine.split(separator: " ")
|
||||
guard fields.count >= 2, let status = Int(fields[1]), (200..<300).contains(status) else {
|
||||
@@ -191,7 +192,8 @@ nonisolated enum SSHSocketConnector {
|
||||
var descriptor = pollfd(fd: socket, events: Int16(POLLOUT), revents: 0)
|
||||
let result = Darwin.poll(&descriptor, 1, Int32(max(1, timeout * 1000)))
|
||||
guard result > 0 else {
|
||||
throw TermindSSHError.socket(result == 0 ? String(localized: "Connection timed out.") : String(cString: strerror(errno)))
|
||||
throw TermindSSHError.socket(
|
||||
result == 0 ? String(localized: "Connection timed out.") : String(cString: strerror(errno)))
|
||||
}
|
||||
var socketError: Int32 = 0
|
||||
var errorLength = socklen_t(MemoryLayout<Int32>.size)
|
||||
@@ -216,7 +218,8 @@ nonisolated enum SSHSocketConnector {
|
||||
var buffer = [UInt8](repeating: 0, count: maximum)
|
||||
let count = Darwin.recv(socket, &buffer, buffer.count, 0)
|
||||
guard count > 0 else {
|
||||
throw TermindSSHError.proxy(count == 0 ? "The proxy closed the connection." : String(cString: strerror(errno)))
|
||||
throw TermindSSHError.proxy(
|
||||
count == 0 ? "The proxy closed the connection." : String(cString: strerror(errno)))
|
||||
}
|
||||
return Data(buffer.prefix(count))
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@ nonisolated struct SSHUserCertificate: Hashable, Sendable {
|
||||
throw SSHUserCertificateError.invalidFormat
|
||||
}
|
||||
|
||||
let publicBlob = SSHWireWriter.field(Data(plainMethod.utf8))
|
||||
let publicBlob =
|
||||
SSHWireWriter.field(Data(plainMethod.utf8))
|
||||
+ keyFields.reduce(into: Data()) { $0.append(SSHWireWriter.field($1)) }
|
||||
if let publicKey {
|
||||
let publicFields = publicKey.split(whereSeparator: \Character.isWhitespace)
|
||||
|
||||
@@ -36,7 +36,8 @@ nonisolated enum SecureEnclaveKey {
|
||||
nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, &accessError
|
||||
)
|
||||
else {
|
||||
let detail = accessError?.takeRetainedValue().localizedDescription
|
||||
let detail =
|
||||
accessError?.takeRetainedValue().localizedDescription
|
||||
?? String(localized: "Unknown Security framework error")
|
||||
throw SecureEnclaveKeyError.accessControl(detail)
|
||||
}
|
||||
@@ -47,7 +48,8 @@ nonisolated enum SecureEnclaveKey {
|
||||
)
|
||||
let publicBlob = sshPublicBlob(for: privateKey.publicKey.x963Representation)
|
||||
let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let publicKey = "ecdsa-sha2-nistp256 \(publicBlob.base64EncodedString())"
|
||||
let publicKey =
|
||||
"ecdsa-sha2-nistp256 \(publicBlob.base64EncodedString())"
|
||||
+ (trimmedComment.isEmpty ? "" : " \(trimmedComment)")
|
||||
return SecureEnclaveKeyMaterial(
|
||||
credential: SecureEnclaveKeyCredential(
|
||||
|
||||
@@ -53,7 +53,8 @@ nonisolated final class SystemSerialPortService: SerialPortServing, @unchecked S
|
||||
defer { IOObjectRelease(iterator) }
|
||||
while case let service = IOIteratorNext(iterator), service != 0 {
|
||||
defer { IOObjectRelease(service) }
|
||||
let product = property("USB Product Name", of: service)
|
||||
let product =
|
||||
property("USB Product Name", of: service)
|
||||
?? property("Product Name", of: service)
|
||||
?? property(kIOTTYBaseNameKey, of: service)
|
||||
for key in [kIOCalloutDeviceKey, kIODialinDeviceKey] {
|
||||
@@ -269,7 +270,10 @@ nonisolated private final class POSIXSerialPortConnection: SerialPortConnection,
|
||||
var offset = 0
|
||||
while offset < raw.count {
|
||||
let count = Darwin.write(descriptor, base.advanced(by: offset), raw.count - offset)
|
||||
if count > 0 { offset += count; continue }
|
||||
if count > 0 {
|
||||
offset += count
|
||||
continue
|
||||
}
|
||||
if count < 0, errno == EINTR { continue }
|
||||
throw SerialPortError.system(
|
||||
operation: String(localized: "Write serial port"), code: errno
|
||||
@@ -302,9 +306,10 @@ nonisolated private final class POSIXSerialPortConnection: SerialPortConnection,
|
||||
receive(Data(bytes.prefix(count)))
|
||||
} else if count == 0 || (errno != EAGAIN && errno != EINTR) {
|
||||
let code = count == 0 ? ENXIO : errno
|
||||
finish(SerialPortError.system(
|
||||
operation: String(localized: "Read serial port"), code: code
|
||||
).localizedDescription)
|
||||
finish(
|
||||
SerialPortError.system(
|
||||
operation: String(localized: "Read serial port"), code: code
|
||||
).localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,8 +196,8 @@ struct ConnectionJourneyView: View {
|
||||
.frame(width: 52, height: 52)
|
||||
.overlay {
|
||||
Image(systemName: headerSymbol)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(TermindColor.selectedForeground)
|
||||
.font(.system(size: 22, weight: .semibold))
|
||||
.foregroundStyle(TermindColor.selectedForeground)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
@@ -327,7 +327,7 @@ struct ConnectionJourneyView: View {
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(entry.endpoint).foregroundStyle(.secondary)
|
||||
Text(entry.message)
|
||||
}
|
||||
}
|
||||
.font(.caption.monospaced())
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
@@ -362,11 +362,12 @@ struct ConnectionJourneyView: View {
|
||||
}
|
||||
|
||||
private var headerDetail: String {
|
||||
let protocolName: String = switch journey.purpose {
|
||||
case .sshSession: "SSH"
|
||||
case .sftpSession: "SFTP"
|
||||
case .serialSession: String(localized: "Serial")
|
||||
}
|
||||
let protocolName: String =
|
||||
switch journey.purpose {
|
||||
case .sshSession: "SSH"
|
||||
case .sftpSession: "SFTP"
|
||||
case .serialSession: String(localized: "Serial")
|
||||
}
|
||||
let address = journey.destination?.address ?? ""
|
||||
if journey.mode == .reconnection {
|
||||
return String(localized: "Reconnecting \(protocolName) · \(address)")
|
||||
@@ -506,9 +507,10 @@ private struct ConnectionLogNativeList: NSViewRepresentable {
|
||||
content.update(
|
||||
listView, items: items,
|
||||
renderer: { item in
|
||||
AnyView(renderer(item)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14))
|
||||
AnyView(
|
||||
renderer(item)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 14))
|
||||
},
|
||||
locale: locale, colorScheme: colorScheme, animated: true
|
||||
)
|
||||
@@ -517,9 +519,14 @@ private struct ConnectionLogNativeList: NSViewRepresentable {
|
||||
private func height(for item: ConnectionLogListItem, width: CGFloat) -> CGFloat {
|
||||
guard width > 0, let root = content.rootView(for: item) else { return 26 }
|
||||
measurement.rootView = root
|
||||
return max(26, ceil(measurement.sizeThatFits(in: CGSize(
|
||||
width: width, height: .greatestFiniteMagnitude
|
||||
)).height) + 8)
|
||||
return max(
|
||||
26,
|
||||
ceil(
|
||||
measurement.sizeThatFits(
|
||||
in: CGSize(
|
||||
width: width, height: .greatestFiniteMagnitude
|
||||
)
|
||||
).height) + 8)
|
||||
}
|
||||
|
||||
private func configure(_ row: HostingListRow, item: ConnectionLogListItem) {
|
||||
@@ -1169,14 +1176,14 @@ private struct JourneyFailureCard: View {
|
||||
Button(action: cancel) {
|
||||
Text(cancelLabel).frame(width: 88)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
.buttonStyle(.bordered)
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button(action: retry) {
|
||||
Text(retryLabel).frame(width: 88)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
|
||||
@@ -241,9 +241,10 @@ nonisolated struct NativeDropPayload: Sendable {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let type = item.types.first(where: { type in
|
||||
UTType(type.rawValue)?.conforms(to: .image) == true
|
||||
}), let data = item.data(forType: type)
|
||||
guard
|
||||
let type = item.types.first(where: { type in
|
||||
UTType(type.rawValue)?.conforms(to: .image) == true
|
||||
}), let data = item.data(forType: type)
|
||||
else { continue }
|
||||
payload.images.append(data)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,8 @@ final class HostingListRow: ListRowView {
|
||||
/// Shared renderer and diff state for SwiftUI-backed ListView coordinators.
|
||||
/// List-specific setup, selection and scrolling stay in their owning adapter.
|
||||
@MainActor
|
||||
final class HostingListContent<Item> where
|
||||
final class HostingListContent<Item>
|
||||
where
|
||||
Item: Identifiable & Hashable & SendableMetatype, Item.ID: Hashable
|
||||
{
|
||||
typealias Renderer = @MainActor (Item) -> AnyView
|
||||
@@ -72,10 +73,11 @@ final class HostingListContent<Item> where
|
||||
|
||||
func rootView(for item: Item) -> AnyView? {
|
||||
guard let renderer else { return nil }
|
||||
return AnyView(renderer(item)
|
||||
.id(item.id)
|
||||
.environment(\.locale, locale)
|
||||
.environment(\.colorScheme, colorScheme))
|
||||
return AnyView(
|
||||
renderer(item)
|
||||
.id(item.id)
|
||||
.environment(\.locale, locale)
|
||||
.environment(\.colorScheme, colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +86,8 @@ protocol CursorHostingListItem: Identifiable, Hashable {
|
||||
}
|
||||
|
||||
/// Fixed-height hosting list shared by keyboard-driven chooser sheets.
|
||||
struct CursorHostingList<Item>: NSViewRepresentable where
|
||||
struct CursorHostingList<Item>: NSViewRepresentable
|
||||
where
|
||||
Item: CursorHostingListItem & SendableMetatype, Item.ID: Hashable
|
||||
{
|
||||
typealias NSViewType = ListView<Item>
|
||||
|
||||
@@ -80,14 +80,25 @@ struct OnboardingCoachMark: View {
|
||||
case .statusBarPath:
|
||||
switch presentation.pathDirection {
|
||||
case .sshToSFTP:
|
||||
String(localized: "Click the path to open this directory with SFTP. Right-click to copy it or open it in a new workspace.")
|
||||
String(
|
||||
localized:
|
||||
"Click the path to open this directory with SFTP. Right-click to copy it or open it in a new workspace."
|
||||
)
|
||||
case .sftpToSSH:
|
||||
String(localized: "Click the path to open this directory with SSH. Right-click to copy it or open it in a new workspace.")
|
||||
String(
|
||||
localized:
|
||||
"Click the path to open this directory with SSH. Right-click to copy it or open it in a new workspace."
|
||||
)
|
||||
case nil:
|
||||
String(localized: "Click the path to open the same directory in another session. Right-click for more options.")
|
||||
String(
|
||||
localized:
|
||||
"Click the path to open the same directory in another session. Right-click for more options.")
|
||||
}
|
||||
case .commandGenerator:
|
||||
String(localized: "Type what you want to do at the shell prompt, then press ⌘I. The generated command is inserted but never run automatically.")
|
||||
String(
|
||||
localized:
|
||||
"Type what you want to do at the shell prompt, then press ⌘I. The generated command is inserted but never run automatically."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -203,27 +203,27 @@ struct ManagedChooserHeader: View {
|
||||
|
||||
private var searchRow: some View {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(prompt, text: $query)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($searchFocused)
|
||||
.onSubmit(onSubmit)
|
||||
.onKeyPress(.upArrow) { moveCursor(-1) }
|
||||
.onKeyPress(.downArrow) { moveCursor(1) }
|
||||
.onKeyPress(.space, action: toggleCursor)
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField(prompt, text: $query)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($searchFocused)
|
||||
.onSubmit(onSubmit)
|
||||
.onKeyPress(.upArrow) { moveCursor(-1) }
|
||||
.onKeyPress(.downArrow) { moveCursor(1) }
|
||||
.onKeyPress(.space, action: toggleCursor)
|
||||
|
||||
if !query.isEmpty {
|
||||
Button {
|
||||
query = ""
|
||||
searchFocused = true
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("Clear search"))
|
||||
if !query.isEmpty {
|
||||
Button {
|
||||
query = ""
|
||||
searchFocused = true
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Text("Clear search"))
|
||||
}
|
||||
}
|
||||
.padding(Metrics.Space.s)
|
||||
.background(
|
||||
|
||||
@@ -233,19 +233,23 @@ struct SidebarNavigatorRow<Trailing: View>: View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Metrics.Space.s) {
|
||||
Image(systemName: symbol)
|
||||
.foregroundStyle(SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.secondary)
|
||||
))
|
||||
.foregroundStyle(
|
||||
SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.secondary)
|
||||
)
|
||||
)
|
||||
.frame(width: Metrics.Sidebar.rowIcon)
|
||||
.accessibilityHidden(true)
|
||||
Text(title)
|
||||
.foregroundStyle(SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.primary)
|
||||
))
|
||||
.foregroundStyle(
|
||||
SidebarSelectionStyle.foreground(
|
||||
isSelected: isSelected,
|
||||
controlActiveState: controlActiveState,
|
||||
unselected: AnyShapeStyle(.primary)
|
||||
)
|
||||
)
|
||||
.fontWeight(isSelected ? .semibold : .regular)
|
||||
Spacer(minLength: Metrics.Space.xs)
|
||||
trailing
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Foundation
|
||||
import Darwin
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct TermindMCPBridge {
|
||||
|
||||
@@ -299,7 +299,6 @@ struct AIProviderPersistenceTests {
|
||||
#expect(AIRole.commandGenerator.unsetLabel == "Same as Chat")
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
func catalogReplacementKeepsManualModelOverrides() {
|
||||
var original = reasoningModel("custom-reasoner")
|
||||
@@ -501,9 +500,10 @@ struct ModelCapabilityResolverTests {
|
||||
#expect(!chat.supportsNativeWebSearch(model: model))
|
||||
|
||||
var legacyModel = AIModel(id: "deepseek-chat")
|
||||
legacyModel.abilities = ModelCapabilityResolver.resolve(
|
||||
model: AIModel(id: "deepseek-chat"), provider: responsesShaped
|
||||
).model.abilities
|
||||
legacyModel.abilities =
|
||||
ModelCapabilityResolver.resolve(
|
||||
model: AIModel(id: "deepseek-chat"), provider: responsesShaped
|
||||
).model.abilities
|
||||
#expect(!legacyModel.abilities.contains(.webSearch))
|
||||
}
|
||||
|
||||
|
||||
@@ -54,10 +54,12 @@ private func turnsCalling(_ names: [String]) -> [[AgentModelStreamEvent]] {
|
||||
/// Requires authorization and may run beside its peers — the combination the
|
||||
/// single pending-approval slot could not survive.
|
||||
private func gatedTool(_ name: String, log: CallLog) -> AnyAgentTool {
|
||||
AnyAgentTool(descriptor: AgentToolDescriptor(
|
||||
name: name, summary: name, inputSchema: queueSchema,
|
||||
target: .host, safety: .requiresAuthorization, concurrency: .parallel
|
||||
)) { invocation, _ in
|
||||
AnyAgentTool(
|
||||
descriptor: AgentToolDescriptor(
|
||||
name: name, summary: name, inputSchema: queueSchema,
|
||||
target: .host, safety: .requiresAuthorization, concurrency: .parallel
|
||||
)
|
||||
) { invocation, _ in
|
||||
await log.start(name)
|
||||
await log.finish(name)
|
||||
return AgentToolResult(callID: invocation.call.id, content: name)
|
||||
@@ -66,10 +68,12 @@ private func gatedTool(_ name: String, log: CallLog) -> AnyAgentTool {
|
||||
|
||||
/// Never returns on its own; only cancellation ends it.
|
||||
private func hangingTool(_ name: String, log: CallLog) -> AnyAgentTool {
|
||||
AnyAgentTool(descriptor: AgentToolDescriptor(
|
||||
name: name, summary: name, inputSchema: queueSchema,
|
||||
target: .local, safety: .locallyReadOnly, concurrency: .parallel
|
||||
)) { invocation, _ in
|
||||
AnyAgentTool(
|
||||
descriptor: AgentToolDescriptor(
|
||||
name: name, summary: name, inputSchema: queueSchema,
|
||||
target: .local, safety: .locallyReadOnly, concurrency: .parallel
|
||||
)
|
||||
) { invocation, _ in
|
||||
await log.start(name)
|
||||
try await Task.sleep(for: .seconds(30))
|
||||
return AgentToolResult(callID: invocation.call.id, content: name)
|
||||
@@ -99,9 +103,11 @@ struct AgentApprovalQueueTests {
|
||||
)
|
||||
)
|
||||
|
||||
conversation.start(runtime: runtime, request: AgentRunRequest(
|
||||
conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval
|
||||
))
|
||||
conversation.start(
|
||||
runtime: runtime,
|
||||
request: AgentRunRequest(
|
||||
conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval
|
||||
))
|
||||
|
||||
await wait { conversation.pendingApprovals.count == 2 }
|
||||
// Both are waiting, but only the head is presented: authorizing three
|
||||
@@ -136,9 +142,11 @@ struct AgentApprovalQueueTests {
|
||||
)
|
||||
)
|
||||
|
||||
conversation.start(runtime: runtime, request: AgentRunRequest(
|
||||
conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval
|
||||
))
|
||||
conversation.start(
|
||||
runtime: runtime,
|
||||
request: AgentRunRequest(
|
||||
conversationID: conversation.id, prompt: "go", permissionMode: .askForApproval
|
||||
))
|
||||
|
||||
await wait { conversation.pendingApprovals.count == 2 }
|
||||
conversation.respondToApproval(allow: false)
|
||||
@@ -186,9 +194,12 @@ struct AgentApprovalQueueTests {
|
||||
#expect(conversation.runState == .waitingForUser)
|
||||
|
||||
// The review finishing must not announce that nothing is outstanding.
|
||||
conversation.handle(.reviewFinished(request, SecurityReviewDecision(
|
||||
verdict: .approve, risk: .low, userAuthorization: .sufficient, reason: "ok"
|
||||
)))
|
||||
conversation.handle(
|
||||
.reviewFinished(
|
||||
request,
|
||||
SecurityReviewDecision(
|
||||
verdict: .approve, risk: .low, userAuthorization: .sufficient, reason: "ok"
|
||||
)))
|
||||
#expect(conversation.runState == .waitingForUser)
|
||||
|
||||
conversation.cancelUserInput()
|
||||
@@ -234,9 +245,10 @@ struct AgentApprovalQueueTests {
|
||||
approval: AgentApprovalBroker(reviewer: nil, manualApproval: { _ in .allow })
|
||||
)
|
||||
|
||||
let stream = await runtime.start(AgentRunRequest(
|
||||
conversationID: UUID(), prompt: "go", permissionMode: .askForApproval
|
||||
))
|
||||
let stream = await runtime.start(
|
||||
AgentRunRequest(
|
||||
conversationID: UUID(), prompt: "go", permissionMode: .askForApproval
|
||||
))
|
||||
let collector = Task {
|
||||
var events: [AgentEvent] = []
|
||||
for await event in stream { events.append(event) }
|
||||
|
||||
@@ -120,7 +120,10 @@ struct AgentBackgroundCommandTests {
|
||||
let stream = AgentCommandOutputStream(interval: .zero) { _, _ in }
|
||||
let handle = await commands.adopt(
|
||||
hostID: UUID(), command: "deploy", stream: stream,
|
||||
task: Task { await released.wait(); return exited(0) }, session: StubSession()
|
||||
task: Task {
|
||||
await released.wait()
|
||||
return exited(0)
|
||||
}, session: StubSession()
|
||||
)
|
||||
stream.handler(Data("step 1 of 9\n".utf8), false)
|
||||
|
||||
@@ -151,7 +154,10 @@ struct AgentBackgroundCommandTests {
|
||||
let stream = AgentCommandOutputStream(interval: .zero) { _, _ in }
|
||||
let handle = await commands.adopt(
|
||||
hostID: UUID(), command: "deploy", stream: stream,
|
||||
task: Task { await released.wait(); return exited(0) }, session: StubSession()
|
||||
task: Task {
|
||||
await released.wait()
|
||||
return exited(0)
|
||||
}, session: StubSession()
|
||||
)
|
||||
|
||||
let seen = Sink()
|
||||
@@ -172,12 +178,18 @@ struct AgentBackgroundCommandTests {
|
||||
let start = ContinuousClock.now
|
||||
let stale = await commands.adopt(
|
||||
hostID: UUID(), command: "tail -f", stream: AgentCommandOutputStream { _, _ in },
|
||||
task: Task { await released.wait(); return exited(0) }, session: forgotten,
|
||||
task: Task {
|
||||
await released.wait()
|
||||
return exited(0)
|
||||
}, session: forgotten,
|
||||
now: start
|
||||
)
|
||||
_ = await commands.adopt(
|
||||
hostID: UUID(), command: "deploy", stream: AgentCommandOutputStream { _, _ in },
|
||||
task: Task { await released.wait(); return exited(0) }, session: StubSession(),
|
||||
task: Task {
|
||||
await released.wait()
|
||||
return exited(0)
|
||||
}, session: StubSession(),
|
||||
now: start.advanced(by: .seconds(590))
|
||||
)
|
||||
|
||||
@@ -203,7 +215,10 @@ struct AgentBackgroundCommandTests {
|
||||
let released = AsyncRelease()
|
||||
_ = await commands.adopt(
|
||||
hostID: UUID(), command: "tail -f", stream: AgentCommandOutputStream { _, _ in },
|
||||
task: Task { await released.wait(); return exited(0) }, session: session
|
||||
task: Task {
|
||||
await released.wait()
|
||||
return exited(0)
|
||||
}, session: session
|
||||
)
|
||||
|
||||
for _ in 0..<50 where await commands.count > 0 {
|
||||
@@ -226,7 +241,10 @@ struct AgentBackgroundCommandTests {
|
||||
let commands = AgentBackgroundCommands(sweepInterval: nil)
|
||||
let released = AsyncRelease()
|
||||
let signal = AgentInterjectionSignal()
|
||||
let task = Task<SSHExecResult, any Error> { await released.wait(); return exited(0) }
|
||||
let task = Task<SSHExecResult, any Error> {
|
||||
await released.wait()
|
||||
return exited(0)
|
||||
}
|
||||
let handle = await commands.adopt(
|
||||
hostID: UUID(), command: "deploy", stream: AgentCommandOutputStream { _, _ in },
|
||||
task: task, session: StubSession()
|
||||
@@ -262,7 +280,10 @@ struct AgentBackgroundCommandTests {
|
||||
signal.disarm()
|
||||
|
||||
let finished = Sink()
|
||||
let waiter = Task { await signal.wait(); finished.append("released") }
|
||||
let waiter = Task {
|
||||
await signal.wait()
|
||||
finished.append("released")
|
||||
}
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
|
||||
#expect(finished.values.isEmpty, "nothing is queued, so nothing to hurry for")
|
||||
@@ -302,7 +323,7 @@ private actor AsyncRelease {
|
||||
isReleased = true
|
||||
let pending = waiters
|
||||
waiters.removeAll()
|
||||
pending.forEach { $0.resume() }
|
||||
for waiter in pending { waiter.resume() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,16 @@ struct AgentBuiltInToolAvailabilityTests {
|
||||
sweepInterval: nil
|
||||
)
|
||||
|
||||
let mainAssistant = TermindAgentTools.tools(in: .init(
|
||||
hosts: hosts, connections: connections
|
||||
)).map(\.descriptor)
|
||||
let workspaceSidebar = TermindAgentTools.tools(in: .init(
|
||||
hosts: hosts, connections: connections, includesTerminalSuggestion: true
|
||||
)).map(\.descriptor)
|
||||
let mainAssistant = TermindAgentTools.tools(
|
||||
in: .init(
|
||||
hosts: hosts, connections: connections
|
||||
)
|
||||
).map(\.descriptor)
|
||||
let workspaceSidebar = TermindAgentTools.tools(
|
||||
in: .init(
|
||||
hosts: hosts, connections: connections, includesTerminalSuggestion: true
|
||||
)
|
||||
).map(\.descriptor)
|
||||
|
||||
#expect(!mainAssistant.contains { $0.name == "terminal_suggest" })
|
||||
#expect(workspaceSidebar.contains { $0.name == "terminal_suggest" })
|
||||
@@ -44,18 +48,19 @@ struct AgentBuiltInToolAvailabilityTests {
|
||||
conversationID: UUID(), base: FileManager.default.temporaryDirectory
|
||||
)
|
||||
let web = ScrubberWebClient(
|
||||
document: { _ in throw AgentWebError.emptyDocument },
|
||||
search: { _, _ in [] }
|
||||
document: { _ in throw AgentWebError.emptyDocument },
|
||||
search: { _, _ in [] }
|
||||
)
|
||||
let descriptors = AgentToolCatalog.registry(
|
||||
builtIn: .init(
|
||||
workspace: workspace, web: web, search: web,
|
||||
tasks: AgentTaskList { _ in }, plans: AgentPlanRecorder { _ in }
|
||||
),
|
||||
additional: TermindAgentTools.tools(in: .init(
|
||||
hosts: hosts, connections: connections,
|
||||
includesTerminalSuggestion: true, workspace: workspace
|
||||
))
|
||||
additional: TermindAgentTools.tools(
|
||||
in: .init(
|
||||
hosts: hosts, connections: connections,
|
||||
includesTerminalSuggestion: true, workspace: workspace
|
||||
))
|
||||
).descriptors
|
||||
|
||||
for descriptor in descriptors where descriptor.target != .mcp {
|
||||
|
||||
@@ -301,9 +301,10 @@ struct AgentCompactionTests {
|
||||
let transcript = conversation.transcript
|
||||
let estimate = conversation.contextEstimate
|
||||
|
||||
let record = try #require(conversation.prepareCompaction(
|
||||
summary: "summary", cut: span.cut
|
||||
))
|
||||
let record = try #require(
|
||||
conversation.prepareCompaction(
|
||||
summary: "summary", cut: span.cut
|
||||
))
|
||||
|
||||
#expect(conversation.transcript == transcript)
|
||||
#expect(conversation.contextEstimate == estimate)
|
||||
@@ -317,13 +318,15 @@ struct AgentCompactionTests {
|
||||
conversation.appendUserTurn("old " + filler(tokens: 600))
|
||||
conversation.appendUserTurn("recent " + filler(tokens: 20))
|
||||
let firstSpan = try #require(conversation.compactionSpan(keepRecentTokens: 20))
|
||||
let first = try #require(conversation.applyCompaction(
|
||||
summary: "first summary", cut: firstSpan.cut
|
||||
))
|
||||
let first = try #require(
|
||||
conversation.applyCompaction(
|
||||
summary: "first summary", cut: firstSpan.cut
|
||||
))
|
||||
|
||||
let second = try #require(conversation.prepareCompaction(
|
||||
summary: "second summary", cut: 1
|
||||
))
|
||||
let second = try #require(
|
||||
conversation.prepareCompaction(
|
||||
summary: "second summary", cut: 1
|
||||
))
|
||||
|
||||
#expect(second.compactedThroughMessageID == first.compactedThroughMessageID)
|
||||
#expect(second.sequence == first.sequence + 1)
|
||||
@@ -399,18 +402,19 @@ struct AgentCompactionTests {
|
||||
@Test
|
||||
func restoreDoesNotResurrectCompactedHistory() {
|
||||
let at: (TimeInterval) -> Date = { Date(timeIntervalSinceReferenceDate: $0) }
|
||||
let restored = AgentCompactionProjection.restore(messages: [
|
||||
AgentTranscriptMessage(role: .user, text: "very old", createdAt: at(1)),
|
||||
AgentTranscriptMessage(role: .assistant, text: "older reply", createdAt: at(2)),
|
||||
AgentTranscriptMessage(
|
||||
role: .user, text: "## Goal\nfirst", isCompaction: true, createdAt: at(2)
|
||||
),
|
||||
AgentTranscriptMessage(role: .user, text: "recent", createdAt: at(3)),
|
||||
AgentTranscriptMessage(
|
||||
role: .user, text: "## Goal\nsecond", isCompaction: true, createdAt: at(3)
|
||||
),
|
||||
AgentTranscriptMessage(role: .user, text: "newest", createdAt: at(4)),
|
||||
], records: [])
|
||||
let restored = AgentCompactionProjection.restore(
|
||||
messages: [
|
||||
AgentTranscriptMessage(role: .user, text: "very old", createdAt: at(1)),
|
||||
AgentTranscriptMessage(role: .assistant, text: "older reply", createdAt: at(2)),
|
||||
AgentTranscriptMessage(
|
||||
role: .user, text: "## Goal\nfirst", isCompaction: true, createdAt: at(2)
|
||||
),
|
||||
AgentTranscriptMessage(role: .user, text: "recent", createdAt: at(3)),
|
||||
AgentTranscriptMessage(
|
||||
role: .user, text: "## Goal\nsecond", isCompaction: true, createdAt: at(3)
|
||||
),
|
||||
AgentTranscriptMessage(role: .user, text: "newest", createdAt: at(4)),
|
||||
], records: [])
|
||||
|
||||
// The last marker, not the first: a conversation compacted twice has
|
||||
// two, and the older summary was folded into the newer one.
|
||||
|
||||
Loaded 100 of 139 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user