Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
fe50842
chore: ignore .worktrees directory
PrisedRabbit Mar 3, 2026
ea56a70
feat: add UserDefaults keys for web server config
PrisedRabbit Mar 3, 2026
2d7d4ce
feat: add WebSessionInfo model for web API
PrisedRabbit Mar 3, 2026
aea3677
feat: add dataPublisher and writeToProcess to ManagedLocalProcessTerm…
PrisedRabbit Mar 3, 2026
97c8220
feat: add TerminalStreamProxy for PTY-to-WebSocket bridging
PrisedRabbit Mar 3, 2026
836a83b
feat: register/unregister terminals with TerminalStreamProxy
PrisedRabbit Mar 3, 2026
b6ab012
feat: add AgentHubWebServer with HTTP and WebSocket support
PrisedRabbit Mar 3, 2026
60f37e1
feat: add webServer to AgentHubProvider and start/stop in app lifecycle
PrisedRabbit Mar 3, 2026
9f77ca4
feat: add xterm.js web client for AgentHub Web
PrisedRabbit Mar 3, 2026
7f8e21a
feat: add WebClient folder to Xcode bundle resources
PrisedRabbit Mar 3, 2026
9bdef24
fix: ensure HTTP response is sent before closing connection
PrisedRabbit Mar 3, 2026
466c59a
fix: escape single quotes and raw id in HTML to prevent XSS
PrisedRabbit Mar 3, 2026
4ead42f
feat: mobile web terminal with theme toggle, scrollback buffer, and P…
PrisedRabbit Mar 3, 2026
77492b1
feat: add onResize to TerminalListener protocol with WebSocketClientS…
PrisedRabbit Mar 3, 2026
b4b96ea
feat: add PTY size tracking and broadcastResize to TerminalStreamProxy
PrisedRabbit Mar 3, 2026
23e0f54
feat: broadcast PTY resize to web clients on Mac terminal window resize
PrisedRabbit Mar 3, 2026
702eed9
feat: CSS scale + zoom controls + server-driven terminal resize for w…
PrisedRabbit Mar 3, 2026
ef413b7
fix: defer applyScale until xterm.js renders, allow scroll when zoome…
PrisedRabbit Mar 3, 2026
b9a7bd0
fix: move zoom buttons to titlebar, fit terminal to height, visualVie…
PrisedRabbit Mar 3, 2026
309a3a2
fix: zoom badge indicator, pull-to-refresh block, safe-area insets fo…
PrisedRabbit Mar 3, 2026
40e309f
feat: mobile terminal toolbar, session names sync, scroll and focus f…
PrisedRabbit Mar 3, 2026
fcf4585
feat: web terminal settings toggle, desktop 100% scale, session URL p…
PrisedRabbit Mar 3, 2026
ff2e86e
fix: mobile autocorrect double-send + debug port 8081
PrisedRabbit Mar 4, 2026
97b1f4a
fix: web terminal new session connectivity and cross-device session sync
PrisedRabbit Mar 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.worktrees/
4 changes: 4 additions & 0 deletions app/AgentHub.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
/* Begin PBXBuildFile section */
7B03D9422F14D66700C36AA3 /* AgentHubCore in Frameworks */ = {isa = PBXBuildFile; productRef = 7B03D9412F14D66700C36AA3 /* AgentHubCore */; };
7B03D9502F18A00000C36AA3 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 7B03D94F2F18A00000C36AA3 /* Sparkle */; };
D2D6634548A441879BBFA54E /* WebClient in Resources */ = {isa = PBXBuildFile; fileRef = B79101C4594B4FB3AB0AAD92 /* WebClient */; };
/* End PBXBuildFile section */

/* Begin PBXContainerItemProxy section */
Expand All @@ -32,6 +33,7 @@
7B03D90A2F14D4E200C36AA3 /* AgentHub.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AgentHub.app; sourceTree = BUILT_PRODUCTS_DIR; };
7B03D9172F14D4E400C36AA3 /* AgentHubTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AgentHubTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
7B03D9212F14D4E400C36AA3 /* AgentHubUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AgentHubUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
B79101C4594B4FB3AB0AAD92 /* WebClient */ = {isa = PBXFileReference; lastKnownFileType = folder; name = WebClient; path = AgentHub/WebClient; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
Expand Down Expand Up @@ -86,6 +88,7 @@
7B03D91A2F14D4E400C36AA3 /* AgentHubTests */,
7B03D9242F14D4E400C36AA3 /* AgentHubUITests */,
7B03D90B2F14D4E200C36AA3 /* Products */,
B79101C4594B4FB3AB0AAD92 /* WebClient */,
);
sourceTree = "<group>";
};
Expand Down Expand Up @@ -225,6 +228,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D2D6634548A441879BBFA54E /* WebClient in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
10 changes: 10 additions & 0 deletions app/AgentHub/AgentHubApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,23 @@ class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDele
// Note: We intentionally do NOT clean up orphaned processes here
// because we can't distinguish between processes spawned by AgentHub
// vs processes the user started directly in Terminal.app
let webEnabled = UserDefaults.standard.object(forKey: AgentHubDefaults.webServerEnabled) as? Bool ?? false
if webEnabled {
Task {
try? await provider.webServer.start()
}
}
}

func applicationWillTerminate(_ notification: Notification) {
// Terminate all active terminal processes on app quit
provider.terminateAllTerminals()
// Stop all dev servers spawned for web preview
DevServerManager.shared.stopAllServers()
// Stop the embedded web terminal server
Task {
await provider.webServer.stop()
}
}

// MARK: - UNUserNotificationCenterDelegate
Expand Down
737 changes: 737 additions & 0 deletions app/AgentHub/WebClient/index.html

Large diffs are not rendered by default.

209 changes: 209 additions & 0 deletions app/AgentHub/WebClient/xterm.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* https://github.com/chjj/term.js
* @license MIT
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*/

/**
* Default styles for xterm.js
*/

.xterm {
cursor: text;
position: relative;
user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
}

.xterm.focus,
.xterm:focus {
outline: none;
}

.xterm .xterm-helpers {
position: absolute;
top: 0;
/**
* The z-index of the helpers must be higher than the canvases in order for
* IMEs to appear on top.
*/
z-index: 5;
}

.xterm .xterm-helper-textarea {
padding: 0;
border: 0;
margin: 0;
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
position: absolute;
opacity: 0;
left: -9999em;
top: 0;
width: 0;
height: 0;
z-index: -5;
/** Prevent wrapping so the IME appears against the textarea at the correct position */
white-space: nowrap;
overflow: hidden;
resize: none;
}

.xterm .composition-view {
/* TODO: Composition position got messed up somewhere */
background: #000;
color: #FFF;
display: none;
position: absolute;
white-space: nowrap;
z-index: 1;
}

.xterm .composition-view.active {
display: block;
}

.xterm .xterm-viewport {
/* On OS X this is required in order for the scroll bar to appear fully opaque */
background-color: #000;
overflow-y: scroll;
cursor: default;
position: absolute;
right: 0;
left: 0;
top: 0;
bottom: 0;
}

.xterm .xterm-screen {
position: relative;
}

.xterm .xterm-screen canvas {
position: absolute;
left: 0;
top: 0;
}

.xterm .xterm-scroll-area {
visibility: hidden;
}

.xterm-char-measure-element {
display: inline-block;
visibility: hidden;
position: absolute;
top: 0;
left: -9999em;
line-height: normal;
}

.xterm.enable-mouse-events {
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
cursor: default;
}

.xterm.xterm-cursor-pointer,
.xterm .xterm-cursor-pointer {
cursor: pointer;
}

.xterm.column-select.focus {
/* Column selection mode */
cursor: crosshair;
}

.xterm .xterm-accessibility,
.xterm .xterm-message {
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
z-index: 10;
color: transparent;
pointer-events: none;
}

.xterm .live-region {
position: absolute;
left: -9999px;
width: 1px;
height: 1px;
overflow: hidden;
}

.xterm-dim {
/* Dim should not apply to background, so the opacity of the foreground color is applied
* explicitly in the generated class and reset to 1 here */
opacity: 1 !important;
}

.xterm-underline-1 { text-decoration: underline; }
.xterm-underline-2 { text-decoration: double underline; }
.xterm-underline-3 { text-decoration: wavy underline; }
.xterm-underline-4 { text-decoration: dotted underline; }
.xterm-underline-5 { text-decoration: dashed underline; }

.xterm-overline {
text-decoration: overline;
}

.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }

.xterm-strikethrough {
text-decoration: line-through;
}

.xterm-screen .xterm-decoration-container .xterm-decoration {
z-index: 6;
position: absolute;
}

.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
z-index: 7;
}

.xterm-decoration-overview-ruler {
z-index: 8;
position: absolute;
top: 0;
right: 0;
pointer-events: none;
}

.xterm-decoration-top {
z-index: 2;
position: relative;
}
2 changes: 2 additions & 0 deletions app/AgentHub/WebClient/xterm.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,14 @@ public enum AgentHubDefaults {
/// Type: Bool (default: false)
public static let flatSessionLayout = "\(keyPrefix)hub.flatSessionLayout"

// MARK: - Web Server Settings

/// Whether the built-in web server is enabled. Type: Bool (default: false)
public static let webServerEnabled = "\(keyPrefix)webServer.enabled"

/// Web server listen port. Type: Int (default: 8080)
public static let webServerPort = "\(keyPrefix)webServer.port"

// MARK: - Theme Settings

/// Selected color theme name
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Central service provider for AgentHub
//

import AppKit
import Foundation
import ClaudeCodeSDK
import os
Expand Down Expand Up @@ -130,6 +131,50 @@ public final class AgentHubProvider {
IntelligenceViewModel(claudeClient: claudeClient)
}()

/// Embedded HTTP + WebSocket server for web terminal client
public private(set) lazy var webServer: AgentHubWebServer = {
let storedPort = UserDefaults.standard.integer(forKey: AgentHubDefaults.webServerPort)
#if DEBUG
let defaultPort: UInt16 = 8081
#else
let defaultPort: UInt16 = 8080
#endif
let port = (1024...65535).contains(storedPort) ? UInt16(storedPort) : defaultPort
return AgentHubWebServer(
port: port,
sessionProvider: { [weak self] in
guard let self else { return [] }
return await MainActor.run {
let vm = self.sessionsViewModel
return vm.monitoredSessions.map { pair in
WebSessionInfo(
session: pair.session,
state: pair.state,
hasTerminal: vm.sessionsWithTerminalView.contains(pair.session.id),
customName: vm.sessionCustomNames[pair.session.id]
)
}
}
},
sessionFocusHandler: { [weak self] sessionId in
guard let self else { return }
await MainActor.run {
// Find which VM owns this session and update webFocusedSessionId
let claudeVM = self.claudeSessionsViewModel
let codexVM = self.codexSessionsViewModel
// ProviderMonitoringItem.id format: "{provider.rawValue.lowercased()}-{sessionId}"
if claudeVM.isMonitoring(sessionId: sessionId) {
claudeVM.webFocusedSessionId = "claude-\(sessionId)"
} else if codexVM.isMonitoring(sessionId: sessionId) {
codexVM.webFocusedSessionId = "codex-\(sessionId)"
}
// Bring the Mac app to foreground
NSApp.activate(ignoringOtherApps: true)
}
}
)
}()

// MARK: - Initialization

/// Creates a provider with the specified configuration
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// WebSessionInfo.swift
// AgentHub
//

import Foundation

/// Lightweight session representation serialized as JSON for the web client API.
public struct WebSessionInfo: Codable, Sendable {
public let id: String
public let slug: String
public let projectPath: String
public let branchName: String?
public let statusLabel: String
public let hasTerminal: Bool

public init(session: CLISession, state: SessionMonitorState?, hasTerminal: Bool, customName: String? = nil) {
self.id = session.id
self.slug = customName ?? session.slug ?? String(session.id.prefix(8))
self.projectPath = session.projectPath
self.branchName = session.branchName
self.statusLabel = state?.status.label ?? "idle"
self.hasTerminal = hasTerminal
}
}

private extension SessionStatus {
var label: String {
switch self {
case .thinking: return "thinking"
case .executingTool(let name): return "executing: \(name)"
case .waitingForUser: return "waiting"
case .awaitingApproval(let tool): return "approval: \(tool)"
case .idle: return "idle"
}
}
}
Loading