Skip to content

Commit 43ab2f6

Browse files
2witstudiosclaude
andcommitted
feat: Worktree project root, terminal filter, installer fixes
Engine now sets PU_PROJECT_ROOT when spawning agents so CLI commands resolve the correct project root from worktrees. Terminal output filter strips DEC 2026 sync sequences and clamps cursor-up overshoots to work around SwiftTerm rendering bugs. CLIInstaller moves plugin registration off the main thread and adds os.log diagnostics. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6b12a2d commit 43ab2f6

19 files changed

Lines changed: 132 additions & 49 deletions

File tree

apps/purepoint-macos/purepoint-macos/Services/CLIInstaller.swift

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import Foundation
2+
import os.log
3+
4+
private let logger = Logger(subsystem: "com.purepoint.macos", category: "CLIInstaller")
25

36
enum CLIInstaller {
47
private static let home = FileManager.default.homeDirectoryForCurrentUser
@@ -16,7 +19,9 @@ enum CLIInstaller {
1619
installBinary(from: macosDir)
1720
let pluginUpdated = installPlugin(from: macosDir)
1821
if pluginUpdated {
19-
registerWithClaudeCode()
22+
DispatchQueue.global().async {
23+
registerWithClaudeCode()
24+
}
2025
}
2126
}
2227

@@ -39,7 +44,6 @@ enum CLIInstaller {
3944

4045
/// Copy bundled plugin into the local marketplace at ~/.pu/marketplace/plugins/pu/
4146
/// and write the marketplace manifest. Returns true if files were updated.
42-
@discardableResult
4347
private static func installPlugin(from macosDir: URL) -> Bool {
4448
let bundledPlugin =
4549
macosDir
@@ -113,14 +117,21 @@ enum CLIInstaller {
113117

114118
/// Register the local marketplace and install the plugin via `claude` CLI.
115119
private static func registerWithClaudeCode() {
116-
guard let claudePath = findClaude() else { return }
120+
guard let claudePath = findClaude() else {
121+
logger.warning("Claude Code CLI not found — skipping plugin registration")
122+
return
123+
}
117124
let mktPath = marketplaceDir.path
118125

119126
// Add marketplace (idempotent — re-adding an existing one is fine)
120-
run(claudePath, "plugins", "marketplace", "add", mktPath)
127+
if !run(claudePath, "plugins", "marketplace", "add", mktPath) {
128+
logger.error("Failed to add marketplace at \(mktPath)")
129+
}
121130

122131
// Install the plugin (idempotent — installing an already-installed plugin is fine)
123-
run(claudePath, "plugins", "install", "pu@purepoint")
132+
if !run(claudePath, "plugins", "install", "pu@purepoint") {
133+
logger.error("Failed to install pu@purepoint plugin")
134+
}
124135
}
125136

126137
private static func findClaude() -> String? {

apps/purepoint-macos/purepoint-macos/Services/DaemonAttachSession.swift

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,19 @@ actor DaemonAttachSession {
142142
lastFullRefreshAtNanos = now
143143
}
144144
let bytes = [UInt8](data)
145+
let termRows = await MainActor.run { tv?.getTerminal().rows ?? 24 }
146+
let filtered = Self.filterTerminalOutput(bytes, maxRows: termRows)
145147
await MainActor.run {
146-
tv?.feed(byteArray: ArraySlice(bytes))
147-
if shouldForceFullRefresh {
148-
tv?.getTerminal().updateFullScreen()
148+
tv?.feed(byteArray: ArraySlice(filtered))
149+
// DEBUG: Log terminal buffer state to find desync
150+
if let term = tv?.getTerminal() {
151+
let buf = term.buffer
152+
if buf.y == 0 && buf.yDisp > 0 {
153+
// swiftlint:disable:next line_length
154+
print(
155+
"[TermDBG] CURSOR AT TOP: y=\(buf.y) x=\(buf.x) yDisp=\(buf.yDisp) scrollTop=\(buf.scrollTop) scrollBottom=\(buf.scrollBottom) rows=\(term.rows) cols=\(term.cols)"
156+
)
157+
}
149158
}
150159
tv?.needsDisplay = true
151160
}
@@ -159,6 +168,56 @@ actor DaemonAttachSession {
159168
}
160169
}
161170
}
171+
172+
// MARK: - Terminal Output Filter
173+
174+
/// Filter terminal output to work around SwiftTerm rendering issues:
175+
/// 1. Strip DEC 2026 synchronized output sequences (SwiftTerm#203 — sync buffer
176+
/// snapshot mistracking causes cursor/scroll desync).
177+
/// 2. Clamp CSI n A (cursor-up) sequences so n never exceeds viewport rows.
178+
/// Ink's eraseLines() emits cursor-up counts that can exceed viewport height,
179+
/// causing the cursor to overshoot row 0 and desync the buffer.
180+
private static func filterTerminalOutput(_ bytes: [UInt8], maxRows: Int) -> [UInt8] {
181+
let syncBegin: [UInt8] = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x32, 0x36, 0x68]
182+
let syncEnd: [UInt8] = [0x1b, 0x5b, 0x3f, 0x32, 0x30, 0x32, 0x36, 0x6c]
183+
let maxUp = max(maxRows - 1, 1)
184+
185+
var result: [UInt8] = []
186+
result.reserveCapacity(bytes.count)
187+
var i = 0
188+
while i < bytes.count {
189+
// Strip DEC 2026 begin/end (8 bytes each)
190+
if i + 8 <= bytes.count {
191+
let slice = Array(bytes[i..<i + 8])
192+
if slice == syncBegin || slice == syncEnd {
193+
i += 8
194+
continue
195+
}
196+
}
197+
// Clamp CSI n A (cursor up): \x1b [ <digits> A
198+
if bytes[i] == 0x1b, i + 2 < bytes.count, bytes[i + 1] == 0x5b {
199+
var j = i + 2
200+
var digits = 0
201+
var hasDigits = false
202+
while j < bytes.count, bytes[j] >= 0x30, bytes[j] <= 0x39 {
203+
digits = digits * 10 + Int(bytes[j] - 0x30)
204+
hasDigits = true
205+
j += 1
206+
}
207+
if j < bytes.count, bytes[j] == 0x41, hasDigits {
208+
// It's CSI <n> A — clamp n
209+
let clamped = min(digits, maxUp)
210+
let replacement = Array("\u{1b}[\(clamped)A".utf8)
211+
result.append(contentsOf: replacement)
212+
i = j + 1
213+
continue
214+
}
215+
}
216+
result.append(bytes[i])
217+
i += 1
218+
}
219+
return result
220+
}
162221
}
163222

164223
enum DaemonAttachError: Error, LocalizedError {

crates/pu-cli/src/commands/agent_def.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ fn parse_tags(tags: &str) -> Vec<String> {
2121

2222
pub async fn run_list(socket: &Path, json: bool) -> Result<(), CliError> {
2323
daemon_ctrl::ensure_daemon(socket).await?;
24-
let project_root = commands::cwd_string()?;
24+
let project_root = commands::project_root_string()?;
2525
let resp = client::send_request(socket, &Request::ListAgentDefs { project_root }).await?;
2626
let resp = output::check_response(resp, json)?;
2727
output::print_response(&resp, json)?;
@@ -41,7 +41,7 @@ pub async fn run_create(
4141
json: bool,
4242
) -> Result<(), CliError> {
4343
daemon_ctrl::ensure_daemon(socket).await?;
44-
let project_root = commands::cwd_string()?;
44+
let project_root = commands::project_root_string()?;
4545
let tags_vec = parse_tags(tags);
4646
let resp = client::send_request(
4747
socket,
@@ -66,7 +66,7 @@ pub async fn run_create(
6666

6767
pub async fn run_show(socket: &Path, name: &str, json: bool) -> Result<(), CliError> {
6868
daemon_ctrl::ensure_daemon(socket).await?;
69-
let project_root = commands::cwd_string()?;
69+
let project_root = commands::project_root_string()?;
7070
let resp = client::send_request(
7171
socket,
7272
&Request::GetAgentDef {
@@ -87,7 +87,7 @@ pub async fn run_delete(
8787
json: bool,
8888
) -> Result<(), CliError> {
8989
daemon_ctrl::ensure_daemon(socket).await?;
90-
let project_root = commands::cwd_string()?;
90+
let project_root = commands::project_root_string()?;
9191
let resp = client::send_request(
9292
socket,
9393
&Request::DeleteAgentDef {

crates/pu-cli/src/commands/bench.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub async fn run_bench(
3535

3636
daemon_ctrl::ensure_daemon(socket).await?;
3737

38-
let project_root = crate::commands::cwd_string()?;
38+
let project_root = crate::commands::project_root_string()?;
3939
let resp = client::send_request(
4040
socket,
4141
&Request::Suspend {
@@ -65,7 +65,7 @@ pub async fn run_bench(
6565
pub async fn run_play(socket: &Path, agent_id: &str, json: bool) -> Result<(), CliError> {
6666
daemon_ctrl::ensure_daemon(socket).await?;
6767

68-
let project_root = crate::commands::cwd_string()?;
68+
let project_root = crate::commands::project_root_string()?;
6969
let resp = client::send_request(
7070
socket,
7171
&Request::Resume {

crates/pu-cli/src/commands/clean.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ pub async fn run(
1818
}
1919

2020
daemon_ctrl::ensure_daemon(socket).await?;
21-
let project_root = crate::commands::cwd_string()?;
21+
let project_root = crate::commands::project_root_string()?;
2222

2323
if all {
2424
// Get status to find all worktree IDs

crates/pu-cli/src/commands/diff.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ pub async fn run(
1313
) -> Result<(), CliError> {
1414
daemon_ctrl::ensure_daemon(socket).await?;
1515

16-
let project_root = crate::commands::cwd_string()?;
16+
let project_root = crate::commands::project_root_string()?;
1717
let resp = client::send_request(
1818
socket,
1919
&Request::Diff {

crates/pu-cli/src/commands/gate.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ pub async fn run(socket: &Path, event: &str, project_root: Option<String>) -> Re
1010
daemon_ctrl::ensure_daemon(socket).await?;
1111
let project_root = match project_root {
1212
Some(pr) => pr,
13-
None => commands::cwd_string()?,
13+
None => commands::project_root_string()?,
1414
};
1515

1616
// Git hooks run inside the worktree directory (cwd), while project_root points to

crates/pu-cli/src/commands/grid.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::output;
1111
pub async fn run(socket: &Path, action: GridAction) -> Result<(), CliError> {
1212
daemon_ctrl::ensure_daemon(socket).await?;
1313

14-
let project_root = crate::commands::cwd_string()?;
14+
let project_root = crate::commands::project_root_string()?;
1515

1616
match action {
1717
GridAction::Show { json } => {

crates/pu-cli/src/commands/kill.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ pub async fn run(
4545

4646
daemon_ctrl::ensure_daemon(socket).await?;
4747

48-
let project_root = crate::commands::cwd_string()?;
48+
let project_root = crate::commands::project_root_string()?;
4949
let resp = client::send_request(
5050
socket,
5151
&Request::Kill {

crates/pu-cli/src/commands/mod.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ pub fn cwd_string() -> Result<String, CliError> {
2727
Ok(std::env::current_dir()?.to_string_lossy().to_string())
2828
}
2929

30+
/// Resolve the project root directory.
31+
/// Checks `PU_PROJECT_ROOT` env var first (set by the engine for worktree agents),
32+
/// falls back to the current working directory.
33+
pub fn project_root_string() -> Result<String, CliError> {
34+
if let Ok(root) = std::env::var("PU_PROJECT_ROOT") {
35+
if !root.is_empty() {
36+
return Ok(root);
37+
}
38+
}
39+
cwd_string()
40+
}
41+
3042
/// Parse --var KEY=VALUE pairs into a HashMap.
3143
pub fn parse_vars(vars: &[String]) -> Result<HashMap<String, String>, CliError> {
3244
let mut map = HashMap::new();

0 commit comments

Comments
 (0)