Skip to content

Commit c11b956

Browse files
author
itsdodobitch
committed
Prepare Hermes Desktop v0.9.1
1 parent ce79481 commit c11b956

11 files changed

Lines changed: 340 additions & 58 deletions

File tree

Sources/HermesDesktop/Models/KanbanModels.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ struct KanbanBoard: Codable, Hashable, Sendable {
182182
let hasHermesCLI: Bool
183183
let dispatcher: KanbanDispatcherStatus?
184184
let latestEventID: Int?
185+
let warning: String?
185186
let tasks: [KanbanTask]
186187
let assignees: [KanbanAssignee]
187188
let tenants: [String]
@@ -195,6 +196,7 @@ struct KanbanBoard: Codable, Hashable, Sendable {
195196
case hasHermesCLI = "has_hermes_cli"
196197
case dispatcher
197198
case latestEventID = "latest_event_id"
199+
case warning
198200
case tasks
199201
case assignees
200202
case tenants
@@ -209,6 +211,7 @@ struct KanbanBoard: Codable, Hashable, Sendable {
209211
hasHermesCLI: false,
210212
dispatcher: nil,
211213
latestEventID: nil,
214+
warning: nil,
212215
tasks: [],
213216
assignees: [],
214217
tenants: [],

Sources/HermesDesktop/Models/SessionModels.swift

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ struct SessionMessageDisplay: Identifiable, Hashable, Sendable {
204204
init(message: SessionMessage) {
205205
id = message.id
206206
role = message.role
207-
content = message.content
207+
content = message.content?.strippingTerminalControlArtifacts
208208
timestampText = message.timestamp?.dateValue.map(DateFormatters.shortDateTimeString(from:))
209209

210210
let displayMetadata = message.displayMetadata ?? [:]
@@ -223,6 +223,35 @@ struct SessionMessageDisplay: Identifiable, Hashable, Sendable {
223223
}
224224
}
225225

226+
private extension String {
227+
var strippingTerminalControlArtifacts: String {
228+
var cleaned = self
229+
let escape = "\u{001B}"
230+
let bell = "\u{0007}"
231+
cleaned = cleaned.replacingOccurrences(
232+
of: "\(escape)\\][^\(bell)\(escape)]*(?:\(bell)|\(escape)\\\\)",
233+
with: "",
234+
options: .regularExpression
235+
)
236+
cleaned = cleaned.replacingOccurrences(
237+
of: "\(escape)\\[[0-?]*[ -/]*[@-~]",
238+
with: "",
239+
options: .regularExpression
240+
)
241+
cleaned = cleaned.replacingOccurrences(
242+
of: "(?m)(^|\\s)(?:\\d{1,3}m;?){2,}(?:\\s+|(?=[.,:!?)]|$))",
243+
with: "$1",
244+
options: .regularExpression
245+
)
246+
cleaned = cleaned.replacingOccurrences(
247+
of: "(?m)(^|\\s)(?:\\d{1,3};){1,8}\\d{1,3}m(?:\\s+|(?=[.,:!?)]|$))",
248+
with: "$1",
249+
options: .regularExpression
250+
)
251+
return cleaned
252+
}
253+
}
254+
226255
struct SessionMetadataDisplayItem: Identifiable, Hashable, Sendable {
227256
let key: String
228257
let value: JSONValue

Sources/HermesDesktop/Services/KanbanBrowserService.swift

Lines changed: 90 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2164,6 +2164,7 @@ final class KanbanBrowserService: @unchecked Sendable {
21642164
"has_hermes_cli": has_cli,
21652165
"dispatcher": dispatcher_status(),
21662166
"latest_event_id": None,
2167+
"warning": None,
21672168
"tasks": [],
21682169
"assignees": [],
21692170
"tenants": [],
@@ -2175,18 +2176,35 @@ final class KanbanBrowserService: @unchecked Sendable {
21752176
conn = None
21762177
try:
21772178
if kb is not None:
2178-
conn = connect_for_board(kb, board_slug, writable=False)
2179-
tasks = [
2180-
task_object_to_dict(task, conn)
2181-
for task in kb.list_tasks(conn, include_archived=include_archived)
2182-
]
21832179
try:
2184-
assignees = kb.known_assignees(conn)
2185-
except Exception:
2180+
conn = connect_for_board(kb, board_slug, writable=False)
2181+
tasks = [
2182+
task_object_to_dict(task, conn)
2183+
for task in kb.list_tasks(conn, include_archived=include_archived)
2184+
]
2185+
try:
2186+
assignees = kb.known_assignees(conn)
2187+
except Exception:
2188+
assignees = direct_assignees(conn)
2189+
try:
2190+
stats = kb.board_stats(conn)
2191+
except Exception:
2192+
stats = direct_stats(conn)
2193+
except Exception as exc:
2194+
try:
2195+
if conn is not None:
2196+
conn.close()
2197+
finally:
2198+
conn = None
2199+
base["warning"] = (
2200+
"The remote Hermes Kanban module could not read this board "
2201+
f"({exc}). Showing a direct database view instead. "
2202+
"Some Kanban actions may still fail until Hermes Agent and the board database schema are in sync on the host."
2203+
)
2204+
conn = connect_sqlite_readonly(db_path)
2205+
conn.row_factory = sqlite3.Row
2206+
tasks = direct_tasks(conn, include_archived)
21862207
assignees = direct_assignees(conn)
2187-
try:
2188-
stats = kb.board_stats(conn)
2189-
except Exception:
21902208
stats = direct_stats(conn)
21912209
else:
21922210
conn = connect_sqlite_readonly(db_path)
@@ -2250,33 +2268,40 @@ final class KanbanBrowserService: @unchecked Sendable {
22502268
conn = None
22512269
try:
22522270
if kb is not None:
2253-
conn = connect_for_board(kb, board_slug, writable=False)
2254-
task = kb.get_task(conn, task_id)
2255-
if task is None:
2256-
return None
2257-
parent_ids = kb.parent_ids(conn, task_id)
2258-
child_ids = kb.child_ids(conn, task_id)
2259-
comments = [comment_to_dict(item) for item in kb.list_comments(conn, task_id)]
2260-
events = [event_to_dict(item) for item in kb.list_events(conn, task_id)]
2261-
runs = [run_to_dict(item) for item in kb.list_runs(conn, task_id)]
2262-
worker_log = None
22632271
try:
2264-
if supports_keyword(kb.read_worker_log, "board"):
2265-
worker_log = kb.read_worker_log(task_id, tail_bytes=65536, board=board_slug)
2266-
else:
2267-
worker_log = kb.read_worker_log(task_id, tail_bytes=65536)
2268-
except Exception:
2272+
conn = connect_for_board(kb, board_slug, writable=False)
2273+
task = kb.get_task(conn, task_id)
2274+
if task is None:
2275+
return None
2276+
parent_ids = kb.parent_ids(conn, task_id)
2277+
child_ids = kb.child_ids(conn, task_id)
2278+
comments = [comment_to_dict(item) for item in kb.list_comments(conn, task_id)]
2279+
events = [event_to_dict(item) for item in kb.list_events(conn, task_id)]
2280+
runs = [run_to_dict(item) for item in kb.list_runs(conn, task_id)]
22692281
worker_log = None
2270-
return {
2271-
"task": task_object_to_dict(task, conn),
2272-
"parent_ids": parent_ids,
2273-
"child_ids": child_ids,
2274-
"comments": comments,
2275-
"events": events,
2276-
"runs": runs,
2277-
"worker_log": worker_log,
2278-
"home_channels": home_channels_for_task(conn, task_id),
2279-
}
2282+
try:
2283+
if supports_keyword(kb.read_worker_log, "board"):
2284+
worker_log = kb.read_worker_log(task_id, tail_bytes=65536, board=board_slug)
2285+
else:
2286+
worker_log = kb.read_worker_log(task_id, tail_bytes=65536)
2287+
except Exception:
2288+
worker_log = None
2289+
return {
2290+
"task": task_object_to_dict(task, conn),
2291+
"parent_ids": parent_ids,
2292+
"child_ids": child_ids,
2293+
"comments": comments,
2294+
"events": events,
2295+
"runs": runs,
2296+
"worker_log": worker_log,
2297+
"home_channels": home_channels_for_task(conn, task_id),
2298+
}
2299+
except Exception:
2300+
try:
2301+
if conn is not None:
2302+
conn.close()
2303+
finally:
2304+
conn = None
22802305
22812306
conn = connect_sqlite_readonly(db_path)
22822307
conn.row_factory = sqlite3.Row
@@ -2316,23 +2341,37 @@ final class KanbanBrowserService: @unchecked Sendable {
23162341
})
23172342
runs = []
23182343
if table_exists(conn, "task_runs"):
2319-
for item in conn.execute(
2320-
"SELECT * FROM task_runs WHERE task_id = ? ORDER BY started_at ASC, id ASC",
2321-
(task_id,),
2322-
).fetchall():
2344+
run_columns = table_columns(conn, "task_runs")
2345+
if "task_id" in run_columns:
2346+
order_columns = []
2347+
if "started_at" in run_columns:
2348+
order_columns.append("started_at ASC")
2349+
if "id" in run_columns:
2350+
order_columns.append("id ASC")
2351+
order_sql = ", ".join(order_columns) if order_columns else "rowid ASC"
2352+
run_rows = conn.execute(
2353+
f"SELECT * FROM task_runs WHERE task_id = ? ORDER BY {order_sql}",
2354+
(task_id,),
2355+
).fetchall()
2356+
else:
2357+
run_rows = []
2358+
for item in run_rows:
2359+
keys = set(item.keys())
2360+
def get(name, default=None):
2361+
return item[name] if name in keys else default
23232362
runs.append({
2324-
"id": int_value(item["id"], 0),
2325-
"task_id": item["task_id"],
2326-
"profile": item["profile"],
2327-
"step_key": item["step_key"],
2328-
"status": item["status"],
2329-
"outcome": item["outcome"],
2330-
"summary": item["summary"],
2331-
"error": item["error"],
2332-
"metadata": parse_json_object(item["metadata"]),
2333-
"worker_pid": int_value(item["worker_pid"]),
2334-
"started_at": int_value(item["started_at"], 0),
2335-
"ended_at": int_value(item["ended_at"]),
2363+
"id": int_value(get("id"), 0),
2364+
"task_id": get("task_id"),
2365+
"profile": get("profile"),
2366+
"step_key": get("step_key"),
2367+
"status": get("status", ""),
2368+
"outcome": get("outcome"),
2369+
"summary": get("summary"),
2370+
"error": get("error"),
2371+
"metadata": parse_json_object(get("metadata")),
2372+
"worker_pid": int_value(get("worker_pid")),
2373+
"started_at": int_value(get("started_at"), 0),
2374+
"ended_at": int_value(get("ended_at")),
23362375
})
23372376
log_path = worker_log_path(task_id, board_slug)
23382377
worker_log = None

Sources/HermesDesktop/Services/SSH/SSHTransport.swift

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ final class SSHTransport: @unchecked Sendable {
3939

4040
private enum ConnectionPurpose {
4141
case service
42+
case serviceWithoutMultiplexing
4243
case terminalShell
4344
}
4445

@@ -70,11 +71,29 @@ final class SSHTransport: @unchecked Sendable {
7071
purpose: .service
7172
)
7273

73-
return try await processRunner.run(
74+
let result = try await processRunner.run(
7475
executableURL: URL(fileURLWithPath: "/usr/bin/ssh"),
7576
arguments: arguments,
7677
standardInput: standardInput
7778
)
79+
80+
guard result.exitCode != 0,
81+
shouldRetryWithoutMultiplexing(result) else {
82+
return result
83+
}
84+
85+
let retryArguments = sshArguments(
86+
for: connection,
87+
remoteCommand: remoteCommand,
88+
allocateTTY: allocateTTY,
89+
purpose: .serviceWithoutMultiplexing
90+
)
91+
92+
return try await processRunner.run(
93+
executableURL: URL(fileURLWithPath: "/usr/bin/ssh"),
94+
arguments: retryArguments,
95+
standardInput: standardInput
96+
)
7897
}
7998

8099
func executeJSON<Response: Decodable>(
@@ -177,9 +196,9 @@ final class SSHTransport: @unchecked Sendable {
177196
"-o", "ControlPersist=300",
178197
"-o", "ControlPath=\(paths.controlPath(for: connection))"
179198
])
180-
case .terminalShell:
181-
// Keep interactive terminal shells isolated from background RPC-style
182-
// requests so an open PTY session cannot destabilize profile reloads.
199+
case .serviceWithoutMultiplexing, .terminalShell:
200+
// Keep direct sessions isolated when multiplexing could be stale or
201+
// when an open PTY should not share the background RPC connection.
183202
arguments.append(contentsOf: [
184203
"-o", "ControlMaster=no",
185204
"-S", "none"
@@ -279,6 +298,23 @@ final class SSHTransport: @unchecked Sendable {
279298
return "SSH command failed with exit code \(exitCode)."
280299
}
281300

301+
private func shouldRetryWithoutMultiplexing(_ result: SSHCommandResult) -> Bool {
302+
let message = [result.stderr, result.stdout]
303+
.map { $0.lowercased() }
304+
.joined(separator: "\n")
305+
306+
return message.contains("no route to host") ||
307+
message.contains("network is unreachable") ||
308+
message.contains("connection timed out") ||
309+
message.contains("operation timed out") ||
310+
message.contains("connection refused") ||
311+
message.contains("connection reset") ||
312+
message.contains("connection closed") ||
313+
message.contains("broken pipe") ||
314+
message.contains("mux_client") ||
315+
message.contains("control socket")
316+
}
317+
282318
private func isLoopbackTarget(_ target: String?) -> Bool {
283319
guard let target else { return false }
284320
let normalized = target.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()

Sources/HermesDesktop/Services/SessionBrowserService.swift

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ final class SessionBrowserService: @unchecked Sendable {
153153
model = None
154154
if context["session_model_column"]:
155155
model = sanitize_model(record.get(context["session_model_column"]))
156+
model = latest_model_for_session(context, session_id, model)
156157
157158
items.append({
158159
"id": session_id,
@@ -522,17 +523,47 @@ final class SessionBrowserService: @unchecked Sendable {
522523
return direct
523524
524525
metadata = record.get("metadata")
526+
if not isinstance(metadata, dict):
527+
metadata_text = stringify(metadata)
528+
if metadata_text:
529+
try:
530+
parsed_metadata = json.loads(metadata_text)
531+
if isinstance(parsed_metadata, dict):
532+
metadata = parsed_metadata
533+
except Exception:
534+
pass
525535
if isinstance(metadata, dict):
526536
nested = sanitize_model(
527537
metadata.get("model") or
528538
metadata.get("model_name") or
529-
metadata.get("default_model")
539+
metadata.get("default_model") or
540+
metadata.get("active_model")
530541
)
531542
if nested:
532543
return nested
533544
534545
return None
535546
547+
def latest_model_for_session(context, session_id, fallback=None):
548+
query = (
549+
f"SELECT * FROM {quote_ident(context['message_table'])} "
550+
f"WHERE {quote_ident(context['message_session_id_column'])} = ? "
551+
"ORDER BY "
552+
)
553+
if context["message_timestamp_column"]:
554+
query += f"{quote_ident(context['message_timestamp_column'])} DESC, "
555+
query += f"{quote_ident(context['message_id_column'])} DESC LIMIT 80"
556+
try:
557+
rows = context["connection"].execute(query, (session_id,)).fetchall()
558+
except Exception:
559+
return fallback
560+
for row in rows:
561+
record = dict(zip(context["message_columns"], row))
562+
model = extract_model_from_record(record)
563+
if model:
564+
return model
565+
return fallback
566+
536567
def parse_timestamp_value(value):
537568
if value is None:
538569
return None

Sources/HermesDesktop/Services/Terminal/TerminalViewHost.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,6 @@ final class TerminalViewHost: NSObject, LocalProcessTerminalViewDelegate {
218218
}
219219

220220
self.hostView.submit(initialInput)
221-
hostView.submit(initialInput)
222221
}
223222
}
224223
}

Sources/HermesDesktop/Views/Kanban/KanbanView.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,10 @@ struct KanbanView: View {
351351
subtitle: boardSubtitle(board)
352352
) {
353353
VStack(alignment: .leading, spacing: 12) {
354+
if let warning = board.warning {
355+
KanbanWarningBanner(message: warning)
356+
}
357+
354358
if let warning = dispatcherWarning(for: board) {
355359
KanbanWarningBanner(message: warning)
356360
}

0 commit comments

Comments
 (0)