COORD: 44.21.90
OFFSET: +12.5°
SYS.READY
BUFFER: 99%
FOCAL_PT
BACK TO DEVLOG
POCKET-CONFIG-SYNC

Config Sync Wiring — workingDirectory and customCommand Were Inert

Discovered that workingDirectory and customCommand fields were completely inert in the config sync flow — wiring them through SSH session creation.

2026-01-24 // RAW LEARNING CAPTURE
PROJECTPOCKET-CONFIG-SYNC

Devlog: Config Sync Wiring — workingDirectory and customCommand Were Inert

Date: 2026-01-24

Starting Point

Branch feature/config-sync — previous session implemented the full "Config Sync from Mac" feature (PocketMac reads ~/.pocket/config.json, iOS pulls via SSH). HANDOFF.md noted Codable backward compat was fixed but flagged that workingDirectory and customCommand might not actually do anything.

Spoiler: customCommand was completely dead code. workingDirectory worked in one path but not the one synced sessions use.

Finding 1: customCommand — Total Dead Code

Grep for customCommand across both projects:

grep -rn "customCommand" Pocket/ PocketMac/

Present on Session and Project models (comment says // claude --resume). Stored in UserDefaults via PocketStore. Synced from config. Never read by any connection code.

The SSH bootstrap in SshClient.swift:168 is hardcoded:

let cdPrefix = workingDirectory.map { "cd \($0) && " } ?? ""
let command = "\(cdPrefix)export LANG=en_US.UTF-8; export PATH=$PATH:/opt/homebrew/bin:/usr/local/bin; echo '\(currentId)/\(currentPasskey)_xterm-256color' | etterminal ..."

No reference to customCommand anywhere in the connection flow. The field existed purely as data at rest.

Finding 2: workingDirectory — Broken for Synced Sessions

The workingDirectory mechanism itself works fine (the cdPrefix code above). The bug is which connection path synced sessions take.

Two Entry Points

SessionManager has two connect methods with different behaviors:

  1. connectToSession(_:) — Direct. Uses the session's own workingDirectory. Correct path.
  2. connectToProject(_:) — Smart. Has resume logic. Creates new sessions on failure. Loses metadata.

The Failure Chain

When a synced session (has workingDirectory = "~/Code/pocket", etClientId = "") gets connected via connectToProject:

  1. Finds disconnected session
  2. attemptResume() → checks SessionKeychainManager.getPasskey(for: session.id) → nil (never connected)
  3. Resume fails → startNewSession(for: project, ...)
  4. Creates brand new Session(projectId:, hostId:, etClientId: "", status: .disconnected) — no workingDirectory
  5. Fallback: session.workingDirectory ?? project.workingDirectory — both nil
  6. SshClient.connect() called with workingDirectory: nil

The synced session's workingDirectory lives on the Session object, not the Project. The new session has no workingDirectory. The project also doesn't (ConfigProject sets it on Session). Dead end.

The Fix

Detect synced sessions early in connectToProject — discriminator is etClientId.isEmpty (never connected = no ET credentials):

if let disconnectedSession = store.sessions(for: project.id).first(where: { $0.status == .disconnected }) {
    if disconnectedSession.etClientId.isEmpty {
        onStatusChange("Connecting...")
        connectToSession(disconnectedSession, completion: completion)
        return
    }
    // ... existing resume logic for previously-connected sessions
}

Routes to connectToSession which correctly passes session.workingDirectory to SshClient.

Fix: customCommand — Send After ET Connect

Semantics: after ET handshake completes, auto-type the command into the terminal. Like a user typing claude --resume\n.

Key design decisions:

  • Not part of SSH bootstrap — runs inside the already-connected terminal via EtClient.sendInput(data:)
  • 0.5s delay — shell needs time to initialize after ET handshake before accepting input
  • First-connect onlystartupCommandSent: Set<UUID> prevents re-sending on ET auto-reconnect (program is already running)
  • Fallback chainsession.customCommand ?? project.customCommand

Implementation in SessionManager.swift:

private var startupCommandSent: Set<UUID> = []

// In handleEtConnectionChange, after connection confirmed:
if !startupCommandSent.contains(sessionId),
   let session = store.session(for: sessionId) {
    let command = session.customCommand ?? store.project(for: session.projectId)?.customCommand
    if let command = command, !command.isEmpty {
        startupCommandSent.insert(sessionId)
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
            if let data = (command + "\n").data(using: .utf8) {
                self?.activeClients[sessionId]?.sendInput(data: data)
            }
        }
    }
}

Cleanup on disconnect: startupCommandSent.remove(sessionId).

Architectural Insight

The etClientId.isEmpty check is the key discriminator in the SessionManager:

StateetClientIdPasskey in KeychainCorrect Path
Synced, never connected""NoconnectToSession
Previously connected, disconnected"abc123"YesattemptResume
Previously connected, passkey lost"abc123"NostartNewSession

Synced sessions carry metadata (workingDirectory, customCommand) but no runtime credentials. They need connectToSession to preserve that metadata through the SSH bootstrap.

Terminal Input Mechanism

EtClient.sendInput(data:) at line 141 — raw bytes written to the ET channel. The terminal on the Mac receives them as if typed. Adding "\n" to the command string simulates pressing Enter. This is the same path that TerminalView.insertText() uses for keyboard input.

Build Result

cd Pocket && xcodebuild -scheme Pocket -destination 'platform=iOS Simulator,name=iPhone 17 Pro' build
# ** BUILD SUCCEEDED **

SourceKit shows false positive diagnostics (type resolution fails in isolation) — real build passes clean.

Files Changed

  • Pocket/Pocket/Core/SessionManager.swift — workingDirectory routing fix + customCommand wiring
LOG.ENTRY_END
ref:pocket-config-sync
RAW