Skip to content

Commit 0ee7bd8

Browse files
authored
Merge pull request #48 from zmanian/fix/question-tool-version-write
Fix question tool version file written as null byte
2 parents 4fea0a1 + 75e1864 commit 0ee7bd8

File tree

3 files changed

+46
-27
lines changed

3 files changed

+46
-27
lines changed

Wisp/Services/ExecSession.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ enum ExecEvent: Sendable {
99
case data(Data)
1010
/// Exec session ID from the session_info control frame
1111
case sessionInfo(id: String)
12+
/// Process exit code from the exec stream
13+
case exit(code: Int)
1214
}
1315

1416
final class ExecSession: Sendable {
@@ -79,6 +81,7 @@ final class ExecSession: Sendable {
7981
case 3: // exit
8082
let exitCode = payload.first.map { Int($0) } ?? -1
8183
logger.info("Exit frame received, code=\(exitCode)")
84+
continuation.yield(.exit(code: exitCode))
8285
continuation.finish()
8386
return
8487
default:

Wisp/Services/SpritesAPIClient.swift

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ final class SpritesAPIClient {
122122
// but Go's net/url (1.17+) silently drops query parameters containing literal
123123
// semicolons. Manually encode them so the server receives the full command.
124124
if let encoded = components.percentEncodedQuery {
125-
components.percentEncodedQuery = encoded.replacingOccurrences(of: ";", with: "%3B")
125+
components.percentEncodedQuery = encoded
126+
.replacingOccurrences(of: ";", with: "%3B")
127+
.replacingOccurrences(of: "+", with: "%2B")
126128
}
127129

128130
return ExecSession(url: components.url!, token: spritesToken ?? "")
@@ -383,12 +385,13 @@ final class SpritesAPIClient {
383385
// MARK: - Exec Helpers
384386

385387
/// Run a command on a sprite via exec WebSocket, collecting output.
386-
/// Returns the accumulated stdout/stderr text and whether the command completed before timeout.
388+
/// Returns the accumulated stdout/stderr text and whether the command exited successfully before timeout.
387389
func runExec(spriteName: String, command: String, env: [String: String] = [:], timeout: Int = 15) async -> (output: String, success: Bool) {
388390
let session = createExecSession(spriteName: spriteName, command: command, env: env)
389391
session.connect()
390392
var output = Data()
391393
var timedOut = false
394+
var exitCode: Int?
392395

393396
let timeoutTask = Task {
394397
try await Task.sleep(for: .seconds(timeout))
@@ -400,16 +403,19 @@ final class SpritesAPIClient {
400403
for try await event in session.events() {
401404
if case .data(let chunk) = event {
402405
output.append(chunk)
406+
} else if case .exit(let code) = event {
407+
exitCode = code
403408
}
404409
}
405410
} catch {
406-
// Expected on timeout disconnect or command failure
411+
// Expected on timeout disconnect
407412
}
408413

409414
timeoutTask.cancel()
410415
session.disconnect()
411416
let text = String(data: output, encoding: .utf8) ?? ""
412-
return (text, !timedOut)
417+
let succeeded = !timedOut && (exitCode.map { $0 == 0 } ?? true)
418+
return (text, succeeded)
413419
}
414420

415421
// MARK: - Private

Wisp/ViewModels/ChatViewModel.swift

Lines changed: 33 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ final class ChatViewModel {
6767
private var turnHasMutations = false
6868
private var pendingForkContext: String?
6969
private var apiClient: SpritesAPIClient?
70-
private var mcpSetupTask: Task<Bool, Never>?
70+
7171
/// UUIDs of Claude NDJSON events already processed.
7272
/// Used by reconnect to skip already-handled events instead of clearing content.
7373
private var processedEventUUIDs: Set<String> = []
@@ -173,12 +173,6 @@ final class ChatViewModel {
173173

174174
func loadSession(apiClient: SpritesAPIClient, modelContext: ModelContext) {
175175
self.apiClient = apiClient
176-
if UserDefaults.standard.bool(forKey: "claudeQuestionTool") {
177-
mcpSetupTask = Task { [weak self] in
178-
guard let self else { return false }
179-
return await self.installClaudeQuestionToolIfNeeded(apiClient: apiClient)
180-
}
181-
}
182176
guard let chat = fetchChat(modelContext: modelContext) else { return }
183177

184178
sessionId = chat.claudeSessionId
@@ -595,20 +589,20 @@ final class ChatViewModel {
595589
) async {
596590
status = .connecting
597591

598-
// Wait for MCP setup to finish (no-op if setup task not running or already done)
592+
// Delete old service, then use a fresh name so logs start clean
593+
let oldServiceName = serviceName
594+
serviceName = "wisp-claude-\(UUID().uuidString.prefix(8).lowercased())"
595+
try? await apiClient.deleteService(spriteName: spriteName, serviceName: oldServiceName)
596+
597+
// Install question tool after service cleanup (sprite is awake at this point)
599598
if UserDefaults.standard.bool(forKey: "claudeQuestionTool") {
600-
let toolReady = await mcpSetupTask?.value ?? false
599+
let toolReady = await installClaudeQuestionToolIfNeeded(apiClient: apiClient)
601600
if !toolReady {
602601
status = .error("Claude question tool failed to install — disable it in Settings or try again")
603602
return
604603
}
605604
}
606605

607-
// Delete old service, then use a fresh name so logs start clean
608-
let oldServiceName = serviceName
609-
serviceName = "wisp-claude-\(UUID().uuidString.prefix(8).lowercased())"
610-
try? await apiClient.deleteService(spriteName: spriteName, serviceName: oldServiceName)
611-
612606
// Persist the new service name immediately for reconnect
613607
saveSession(modelContext: modelContext)
614608

@@ -1457,22 +1451,38 @@ final class ChatViewModel {
14571451
remotePath: ClaudeQuestionTool.serverPyPath,
14581452
data: Data(ClaudeQuestionTool.serverScript.utf8)
14591453
)
1460-
try await apiClient.uploadFile(
1461-
spriteName: spriteName,
1462-
remotePath: ClaudeQuestionTool.versionPath,
1463-
data: Data(ClaudeQuestionTool.version.utf8)
1464-
)
14651454
} catch {
14661455
logger.error("Claude question tool installation failed: \(error)")
14671456
return false
14681457
}
1469-
// Make server.py executable
1470-
_ = await apiClient.runExec(
1458+
// Make server.py executable and write version file via exec
1459+
// (the fs/write API corrupts very small payloads to null bytes)
1460+
let installCommand = "\(ClaudeQuestionTool.chmodCommand) && mkdir -p ~/.wisp/claude-question && echo -n '\(ClaudeQuestionTool.version)' > \(ClaudeQuestionTool.versionPath)"
1461+
let (installOutput, installSuccess) = await apiClient.runExec(
14711462
spriteName: spriteName,
1472-
command: ClaudeQuestionTool.chmodCommand,
1463+
command: installCommand,
14731464
timeout: 10
14741465
)
1466+
guard installSuccess else {
1467+
let trimmedOutput = installOutput.trimmingCharacters(in: .whitespacesAndNewlines)
1468+
logger.error("Claude question tool install command failed: \(trimmedOutput)")
1469+
return false
1470+
}
1471+
1472+
let verificationCommand =
1473+
"if test -x \(ClaudeQuestionTool.serverPyPath) && [ \"$(cat \(ClaudeQuestionTool.versionPath) 2>/dev/null)\" = '\(ClaudeQuestionTool.version)' ]; then printf '\(ClaudeQuestionTool.version)'; else exit 1; fi"
1474+
let (verificationOutput, verificationSuccess) = await apiClient.runExec(
1475+
spriteName: spriteName,
1476+
command: verificationCommand,
1477+
timeout: 10
1478+
)
1479+
guard verificationSuccess,
1480+
verificationOutput.trimmingCharacters(in: .whitespacesAndNewlines) == ClaudeQuestionTool.version
1481+
else {
1482+
logger.error("Claude question tool verification failed: \(verificationOutput)")
1483+
return false
1484+
}
1485+
14751486
return true
14761487
}
14771488
}
1478-

0 commit comments

Comments
 (0)