Builds, tests, archives and runs an Xcode project or workspace on iOS or macOS through xcodebuild — a live filterable build log, scheme and destination pickers read from the project, and log streaming for simulators and USB devices, including iOS 16 and earlier over libimobiledevice, whose app logs stay readable while the device is locked. macOS only.
Install
# from npm (prebuilt)
dsh plugin --profile web add dsh-xcodebuild
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:harde1/dsh-xcodebuild
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).
README
An Xcode build and development loop for DeepSeek Harness: build, test, clean, archive and run an iOS/macOS project, with a live build log you can filter — driven either by the agent through six tools, or by hand from a panel inside the conversation.
Inspired by the SweetPad VS Code extension. The package and composition row keep the id
dsh-xcodebuild; XcBuild is the name shown in the UI.
What it adds
Seven model-facing tools
| Tool | Purpose |
|---|---|
xcode_doctor |
What this machine has and what it lacks, with the install command for each gap, plus the Xcode in use. Run this first when a device will not install or launch. |
xcode_project |
Detect a .xcworkspace / .xcodeproj / Package.swift; list schemes, configurations, targets, and any SweetPad defaults in .vscode/settings.json. |
xcode_destinations |
Every channel that can name a device, merged: -showdestinations (simulators, My Mac, the hardware Xcode manages) + xcdevice (Xcode's own device layer) + devicectl (CoreDevice) + the classic USB channel. Each entry carries a ready-to-use destination string, whether it is reachable, and which sources saw it; recommended is never an unreachable device. |
xcode_run |
build / test / clean / archive / run. Streams the full log into a background run and returns the collected compiler errors. run installs with simctl on a simulator, devicectl on a device CoreDevice knows, ideviceinstaller + ios-deploy on iOS 16 and earlier, and does neither for macOS. |
xcode_log |
The run's log: buffered tail, incremental slice by line number, or a regex-filtered view. Safe to call mid-build. |
xcode_lldb |
Debug the app this workspace last ran. view-hierarchy attaches LLDB to it, stops it, returns the key window's view tree (class, frame, text, hidden, stack-view attributes) and writes the same tree, with a screenshot cropped per view, as a .lookin file Lookin.app opens; lookin opens that file; command runs any raw LLDB command (po, bt, breakpoint set, expression) in the same session; attach / interrupt / detach / status manage that session. On an iOS 16 or earlier device, which CoreDevice cannot reach, LLDB attaches through a debugserver port forwarded by pymobiledevice3 (pipx install pymobiledevice3); the canvas draws each control's colours, text and pictures, fetched in the background and cached per process. |
xcode_device_log |
The log of a simulator (simctl spawn, snapshot or a bounded live window) or of physical hardware. On iOS 16 and earlier, pass bundleId and the app's own per-launch log (Documents/PPCrashLog/log_<timestamp>.log) is read out of its sandbox — the file that survives a crash, readable while the device is locked; mode: "syslog" gives the live idevicesyslog window instead. |
Model guidance, so the tools are used without being named
- A short system-prompt section (
dsh-xcodebuild, viactx.systemPrompt.section) is present on every step. It tells the model to use thexcode_*tools instead of shelling out toxcodebuild, and which tool answers which situation (build errors, a crash after launch, a missing tool). - A runtime skill,
xcode-build-loop(viactx.skills.register), holds the full procedure: pick project → scheme → destination, run, read errors, fix, rerun, and read the device log. The model loads it on demand, so the long text costs nothing until an Xcode task comes up.
Both are optional services (ctx.inject), so a profile without them still mounts the tools.
The panel follows the model, and comes forward for a build. xcode_run tags its run with the
calling session's working directory, so the panel open on that project picks it up within a few
seconds of idle polling: it clears the old log, streams the new one, matches scheme / configuration /
destination to what is actually building, and marks the run by agent. Stop works on it like on any
other run.
A build, run, test or archive started by the model also opens the panel: the official right
sidebar is asked for this plugin's tab with sidebarRight.openTab, and a shell whose column is owned
by better-sidebar — or that has no sidebar at all — gets the floating panel instead, because a step
nobody can see is the one thing this is for. Reads (xcode_lldb, xcode_device_log, xcode_log, and
the rest) never take the screen that way; they are recorded in the panel's AI strip instead, which
names the step in the panel's own words (正在构建 · Enshi · Debug) with how long ago it was. The
switch beside it, AI 调用时自动打开, turns the opening off and is remembered in localStorage under
dsh-xcodebuild:autoOpen (on by default). The host records each step in agentSteps
(noteAgentStep), keyed by workspace like the runs, and the panel is shown only its own session's.
A panel, in three seats, taking whichever sidebar the shell has:
- Docked in
dsh-better-sidebar, when that plugin is installed — viactx.betterSidebar.registerTab, so it appears as a tab in its workbench (right sidebar or bottom panel) with a title chip and a close button supplied by the host. The tab is added from better-sidebar's own list; there is deliberately no permanent header button, because a second fixed entry beside the session title would only be clutter once a sidebar can host it. - Docked in the shell's own right sidebar, when better-sidebar is not installed — via
ctx.sidebarRightTabs.register, the same tab registry the shell's Files tab uses. The panel becomes a tab type there, listed in that sidebar's Guide (a capsule carrying the title and description) and in its add-tab menu; the shell draws the chip and the close button. Same rule: no header button, because the sidebar offers the tab itself. - Floating — the fallback for a shell with neither sidebar: the same panel in
shell.overlay, with its own head row and close button, and a header button to open it — that button is the only way in when there is no sidebar to add the tab from, so it survives for exactly that case and renders nothing otherwise.
One mark wherever it is docked. The plugin is recognised by the same four-blade drawing in
every seat: the header button's emblem, the icon better-sidebar draws in its tab strip, the shell's
Guide capsule, the shell's tab chip, and the floating panel's head row. The seats disagree about how
a size is handed over — better-sidebar calls icon(size), the shell's Guide renders entry.icon as
a component and passes {size, className} — so one component (Mark) is the drawing and a one-line
adapter answers the first shape. Each drawing declares its own gradient, because several seats are on
screen at once and url(#…) would otherwise resolve to whichever identical definition the document
happens to hold first. test/client-interaction.test.mjs compares the drawn path across the seats,
so "the same mark" is a test result rather than a claim.
The two sidebars are alternatives rather than layers: better-sidebar wins when it is there, and the
official right sidebar is the fallback. Because each service arrives whenever it arrives,
syncSeats() reads both and gives back a seat it has to, so the arrival order cannot leave the panel
in two places or in none.
Both are reached through ctx.inject([…], …) and deliberately not through dsh.client.inject.
That list is a hard dependency: naming dsh-better-sidebar (or the right sidebar's package) there
would stop this plugin's client half from loading for anyone who does not have it, which is the
opposite of having a fallback. All three seats are covered by test/client-interaction.test.mjs.
Whichever seat it lands in, the panel gives you: a project picker (below), scheme / destination / configuration selectors with a
⟳ beside the destination list that re-reads it on demand, Build / Run / Test / Clean / Archive / Stop,
a colour-coded streaming log, and a filter bar.
A debugger, in a drawer along the bottom. ⌘L, or the LLDB button in the status row, opens a
strip that is hidden the rest of the time. View Hierarchy answers the question a screenshot cannot:
it attaches LLDB to the app this project last ran, stops it, and draws the key window's view tree —
class, address, frame, text, hidden flag, and a stack view's axis/distribution/alignment, which
is usually why a screen looks wrong. The stop is the old way of doing it: the app loads a small helper
the first time it is read (see Reading an app without stopping it), and from then on it answers
for itself while it keeps running. A filter narrows it by class, text or address without asking the
host again — the rows that match are highlighted, the ancestors that place them are dimmed, and the
bar says how many matched. The tree folds like Lookin's: a triangle on every row that has children
(⌥-click for a whole branch), the arrow keys to walk and fold it, the fold kept by address so a
re-read leaves it as it was, and three modes — smart content-open default, everything open, or only
the outer windows — with a view picked on the canvas opening its own way into view. And a box at the
bottom takes any LLDB command. 添到聊天 — on a view's right-click menu, on the picked view's chip,
on any line of the build log or the LLDB transcript, and beside a build's error count — puts it into
the chat as a reference that says where it came from (source), what exactly it points at
(refers to), and which tool call reaches it or reads around it, so the model knows precisely what
is meant. The drawer is the same
session the model uses, so a session the model starts opens it — transcript and all — and the user
can take the prompt over from there. ⤢ gives the drawer the whole panel (and ⤡ gives it back;
the panel itself is resizable by its corner), and a double-click on a row focuses that view's
subtree.
The canvas shows the app, with the view frames on it. Between the tree and the inspector sits
Lookin's preview, built the same way — from LLDB alone. It is the window's own picture, rendered
inside the app while it is stopped (one view, scale 1, one short stop) and drawn in the canvas's own
coordinates, so it zooms, turns and pans with the boxes; 2D shows the frames flat, 3D pulls them
apart by overlap depth and a drag turns the stack, 隐藏视图 draws the hidden ones dashed, and a
box carries its class name when it is big enough on screen to hold one. A slide moves the camera
and never the scale — a trackpad reports a two-finger slide as a wheel event with no modifier,
which is why every scroll used to change the zoom — while a pinch (or ⌘+wheel) zooms and the
percentage beside 适配 is itself a control: drag it sideways, double-click it for 100 %. Clicking a box picks that
view, a double-click focuses it, a right-click opens the row's menu — and 截图 turns the picture
off for a pure frame drawing, 刷新截图 reads it again. Rendering the app's own layer is also what
fixes what used to be invisible here: no CoreGraphics struct may be a value in an LLDB expression
on Xcode 26 (error: attempt to use a deleted function for CGSize size = [layer frame].size and
for CGRectMake, in every language mode), so the geometry travels as four doubles out of NSValue
and the context is a bitmap context — which is what made the per-view images real rather than always
falling back to a cropped screenshot. Where even that cannot run — a device whose UIKit module the
installed Xcode has no SDK for, so no CGFloat and no CGContextRef can be named — the window is a
screen capture cropped to its frame instead, taken with the platform's own tool and sips, which
needs no debugger at all; the note beside the picture says so.
An inspector beside the tree, built on LLDB alone. Picking a view opens three panes and no
Lookin server anywhere. They are drawn from one read: the stop that reads the tree also reads
every view's attributes and layout, and a click afterwards costs nothing — no second attach, no
second stop, no round trip at all. Attaching is the expensive half (4 s to a simulator, 15–23 s to a
phone), while one _ivarDescription on a process that is already stopped costs about 20 ms and one
layout report about 10 ms, so reading them per click paid the expensive half over and over. 96 views
of a test app come back as 2.9 MB of detail in 6 s, once. The pass is bounded (400 views, 6 s) and
says how many it reached — 已缓存 N 个视图 in the tree's stats, with (部分) when it stopped
early — so a view the bounds left out is still read on demand when it is opened, and 刷新 re-reads
the picked view even when it is cached:
- 属性 — everything the app says about the object, grouped by the class that declares it, read
with
_ivarDescription.⌘L-style rows that can be written get an editor chosen from the declared type: a switch for aBOOL, a field for a number, a text field for a string, a colour well for aUIColor, four boxes for aCGRect. The edit goes to the app assetValue:forKey:and the pane then shows what the app reports, so a value its own layout code writes back says so instead of pretending to have changed. - 布局 — Auto Layout in one expression: whether the autoresizing mask is still translating, whether the engine calls the layout ambiguous, the intrinsic size, hugging and resistance, this view's own constraints, and the ones living on its ancestors that mention it. An address inside a constraint's text is a button that selects that view.
- 预览 — the rendered image of the view, solo or with its subtree, with zoom and pan.
Above the panes, the chain from the window down to the picked view, each link a jump.
A tree Lookin can open. Lookin is the better window onto a hierarchy, and
the Lookin button in the drawer opens the tree there — but Lookin normally needs its own server
inside the app. Lookin being installed is checked rather than assumed: with it the drawer offers
Lookin, and without it the same slot offers Reveal — the file in Finder — because a button
labelled "Lookin" that quietly opened Finder would be a lie. This does not: every read writes what it just read as
/tmp/dsh-xcodebuild/lookin-<when>.lookin, which Lookin.app opens directly. That is what makes an
app reachable only by the debugger — no LookinServer, a release binary, a device you can attach
to and nothing more — browsable in the same tree view, with each node's class chain, frame, alpha
and hidden flag — the frames are the same superview-relative numbers the panel prints, which is the
space Lookin's own files use. The class chain is asked of the runtime, one expression per distinct
class, which is why the first read of a session takes a moment longer than the ones after it. Ten
files are kept.
A row is a question. Clicking a view in the tree opens a detail pane beside it: the view's own
image — Solo for the control alone, Group for it with its subtree — then the numbers behind it,
its frame and bounds, its background colour as a swatch, its layer class and the chain of classes it
inherits from. That is Lookin's window onto a hierarchy, in the drawer, without Lookin installed.
The pane is fetched per click rather than shipped with the tree, because the tree is text and the images are not: one view's two renders at a time, parsed by the host, so a colour that comes out the wrong shade is a failing test instead of a screenshot someone has to squint at.
The images are the views' own. Each node carries the two images Lookin shows, and both are
rendered inside the app, because a screenshot cannot produce either: solo is the control alone
(its sublayers are hidden while its layer is drawn, the way LookinServer does it) and group is
the control with its subtree. Cropping one screen capture, which is what this used to do, gives a
region of a flat image — never the control alone, and for anything scrolled off screen not even a
faithful view of it.
The renders come out through the app's own sandbox: a simulator's data container is a directory on
this machine, so the PNGs are read straight out of it, while a physical device is fetched with
xcrun devicectl device copy from. Views are rendered at up to 1024 pixels on their long edge and
views too large for a drawing context are skipped. When the render cannot happen at all — an iOS 16
or earlier device has no devicectl — the export falls back to cropping one screen capture, and the
note beside the file says so.
Measured on an iPhone 13 (iOS 26.6.2) under Xcode 26.0.1, because these are the facts the implementation is shaped around:
- A device attach is asynchronous and slow.
device process attach -p <pid>returns at once, and the process stops 6-25 s later with the stop arriving as a message. The plugin waits forProcess N stoppedand nudges withprocess status; it does not treat a thread'sstop reason = …line as the process being stopped — LLDB prints one while it still reports the process as running, and trusting it made every expression fail. - In that window, nothing but
scriptmay be asked.process statusandprocess interruptboth need a process LLDB has already launched, so while the attach is settling they answererror: Process must be launched.and nothing more; asking for a stop once every 3 s printed seven of them on one attach, for a nudge that changed nothing, since the attach itself is what sends the app SIGSTOP. The wait therefore reads the process state first —script print("xcb-lldb-state=" + str(lldb.debugger.GetSelectedTarget().GetProcess().GetState())), which is answered whatever the state is, including0with no process at all — and interrupts only a process LLDB reports asrunning: an app that was already running when the attach happened is the one case where attaching does not stop it. - A process must have run before it can be inspected. Launching under the debugger
(
mode=launch) starts the app for real and attaches after it has had time to build its UI. Withdevicectl --start-stoppedinstead, the dump returnederror: use of undeclared identifier 'UIApplication'— no windows, no Objective-C runtime. - The dump is one expression:
po [[[[UIApplication sharedApplication] windows] firstObject] recursiveDescription].windowsis deprecated but still answers on iOS 26, and its first element is the key window. Each view line's depth is the number of|before its<, so the tree is rebuilt from indentation rather than trusted from a pretty-printer.
Choosing a project
Naming a directory is not enough to name a project. A real iOS checkout offers several, and almost all of the extra candidates are noise:
Gemoy.xcworkspace ← the one you want
Gemoy.xcodeproj ← the target list that workspace already wraps
Gemoy.xcodeproj/project.xcworkspace ← inside the bundle above
PodCache/MLeaksFinder/*.xcodeproj ← a cached pod
OtherProject/YNLive/YNLive.xcworkspace ← a genuinely different project
So the path field searches rather than asserts, and the search result decides:
- one hit — adopted outright, no question asked;
- several — a picker listing each by name and relative path, so the choice is informed rather than a guess made for you;
- none — said plainly, as an empty answer rather than an error.
A chosen project keeps a Change button that brings the picker back. Two rules remove the noise:
a search never descends into a .xcodeproj / .xcworkspace (they are bundles, not containers of
other projects), and a .xcodeproj sharing a basename with a .xcworkspace beside it is dropped as
the workspace's own target list. Pods, PodCache, Carthage, build, DerivedData, node_modules
and dot-directories are skipped, the walk is depth-limited, and it is capped so a huge tree cannot
stall the panel.
Filtering, and searching
Filtering and searching are different questions, and the panel keeps them apart. A filter changes which lines exist as far as the panel is concerned — they disappear, and it can reach lines the browser no longer holds by asking the host. A search changes nothing: it marks the hits inside the lines already on screen and takes you to them.
Filtering — which lines
Two axes, combined:
- Text — committed on blur, not on every keystroke. Typing a filter is one intent and running
it is another; a per-keystroke filter would re-query the host and repaint the list on every
character, churning the log under the caret while the pattern is still half-written. Press Enter or
click anywhere else to apply (the field outlines in amber while uncommitted).
Escclears.↑/↓walk the filters used before, without committing one. - Severity — the four levels a system log has, in its own words:
verbose,info,warning,error(see the table below for how the app'sLogLevelis written to the device). A build log has more kinds of line than that, so the build-only ones join the level they belong to: the compiler'snotes are part of a diagnostic and sit with the warnings, the progress chatter (CompileSwift,Ld, …) is the lowest level there is, and sections, test results and "BUILD SUCCEEDED" are information. How a line is drawn does not change — a note is still grey, a finished build is still green. The toggles apply immediately, joined by aProblemsshortcut for "the diagnostics only" (which keeps notes with their warnings). The levels are drawn from one table inlib/client.js, and the suite walks the buttons and proves that between them they reach every kind the classifier can produce — a kind with no button would be a line nothing could ever show again once it was off.
Text filtering runs on the host, not in the browser. The panel keeps a rendering window of the
log; the host retains the last 20 000 lines per run. A filter that only searched the browser window
would silently miss the beginning of a large build — exactly the case where you reach for a filter.
The .* button switches the pattern to a regular expression; an invalid one falls back to the local
substring match instead of erroring on every poll.
Searching — where in those lines
⌘F (or Ctrl+F) opens the search bar at the top of the log. It is hidden the rest of the
time: a whole row of the panel is a lot to spend on a job that usually takes a second, and the
binding is the one a browser's own find has already taught everyone. While it is open the caret is
in the box — a second ⌘F selects what is there, so typing replaces it. An empty bar closes
itself when focus leaves it — clicking away, or Esc — because an empty search box is a row of
the panel with nothing in it. The judgement is made on blur and never on the keystroke: deleting a
character to retype it is not the user saying they are done, and a bar that vanished mid-edit would
be a bar you could not type in. Focus moving to the same box in another seat of the panel (docked
and floating can both be mounted over one store) is not "leaving" either. The bar searches what is on screen, live, and hides nothing:
- The count is live —
3 / 17— because "is this string in this build at all" is the question you are asking while typing, and making you press Enter to find it out would be the filter's behaviour applied to the wrong question. - Every occurrence is highlighted, and the line you are currently on is highlighted differently, so the number in the count and the mark on screen always agree.
↑/↓(andEnter/Shift+Enter) walk the hits and wrap around the ends; they are greyed out when nothing matches, and the count saysno hitsrather than0 / 0.- Moving to a hit scrolls it to the middle of the view and stops the view following the tail —
the
↓ Latestbutton appears, which is how you get back to watching the build. Typing alone never moves the log; only asking to go somewhere does. - The needle is literal text, case-insensitive, never a pattern: a build log is full of
[,(and*, and(has to find a bracket rather than be compiled.Escclears. - Hits past the render window are still reachable: the window (last 2 500 lines by default)
follows the current hit, so pressing
↓cannot walk into lines that are no longer in the DOM.
↑/↓ walk what each box was given before
Both boxes keep their own history — the filter's and the search's are separate, because a filter and
a search are different questions — and ↑/↓ walk it the way a shell does: ↑ is back in time and
↓ is forward, at the oldest entry it stays rather than wrapping, and ↓ past the newest puts back
the half-written text that was in the box when the walk started. Only finished inputs are
remembered: a filter when it is committed (Enter or clicking away), a search when it is finished
with (Enter, or Esc). So ↑ never offers a prefix of what is being typed, and typing again ends
the walk — which is what makes ↓ mean "my draft" again.
Reading an app without stopping it. Every read used to be a debugger stop: the app was frozen,
asked, and let go — 4 s of attaching on a simulator and 15–23 s on a phone, and a frozen app in
between that loses animation frames and the very state being looked at. So the app is asked to answer
for itself instead. LLDB is used once, to dlopen a helper out of the plugin's own cache and
start it (lib/probe/xcb-probe.m, compiled here for the app's platform and, on a phone, signed with
a development identity and copied into the app's data container). That helper listens on the app's
own loopback — iproxy forwards it to this Mac for a phone — and every read afterwards is a line of
JSON over a socket: the tree, the styles, the screen size and class chains, an _ivarDescription, a
layout report, the per-view pictures, and an edit, which the app applies and reports back. UIKit is
only touched on the main thread, a read that cannot be answered is an error rather than a wait, and
the host always applies a deadline, because a debugger-stopped app would freeze the main-queue hop.
The helper costs one short stop to load and nothing after it. Everything it does is what the debugger
path already did, in the same text, parsed by the same parsers — so the two are comparable, and the
debugger is still there for everything the helper cannot do: an app that is already stopped (a
breakpoint, a manual interrupt), a target with no SDK to build for, no signing identity, no iproxy,
or a version of the helper this plugin does not know. Any of those falls back to the old path and says
why; the status row says which of the two is in use — 读视图不停 App when the app is answering for
itself, and 调试器读取:<reason> when it is not. The helper is deliberately small: it makes no
decisions, it never touches Lookin's protocol or its server, and it is loaded only into an app this
plugin is already debugging. Measured against a test app on an iPhone 17 Pro simulator: the injection
itself is ~250 ms, the first read of a session 12 s (attaching the debugger, building the helper,
reading the tree and every view's attributes and layout once, and writing the Lookin file), and every
read after it ~230 ms — with the app never stopped, which its own ticking label proves between reads. All of
that is measured on a simulator, where the whole path is exercised. On a phone the helper itself is
built and signed here (arm64, Apple Development), but the parts only a phone can exercise — copying
it into the app's data container, reaching it through iproxy, and a signature that phone accepts —
have not been run against hardware yet, so a failure there names the signature rather than guessing.
What it needs on the machine
Run the xcode_doctor tool — or open the panel, which says the same thing in a warning row — to see
which of these are present, with the version of Xcode in use. Missing entries are reported with the
command that installs them; a tool that is absent is never left to surface later as
spawn … ENOENT or as a device-not-found that blames the phone. The classic channel's three formulae
are the only optional gap the doctor offers an install command for: a missing xcbeautify changes how
the log reads and nothing else, so it is reported without being turned into advice to install the
device toolchain.
| Tool | Provided by | Needed for |
|---|---|---|
xcodebuild, xcrun, xcode-select, plutil |
Xcode and macOS | everything |
idevice_id, ideviceinfo, idevicesyslog |
brew install libimobiledevice |
seeing, identifying, and reading the log of an iOS 16 or earlier device |
ideviceinstaller |
brew install ideviceinstaller |
installing onto such a device |
ios-deploy |
brew install ios-deploy |
launching on such a device |
iproxy |
brew install libusbmuxd (already pulled in by libimobiledevice) |
reaching the helper inside the app on a phone, so reads need no debugger stop |
A phone CoreDevice can see but has no pairing with is checked before a run starts building:
nothing about a device is remembered, so tapping Trust and pressing Run again works with no refresh,
re-selection or restart, and a phone that is still unpaired is refused in about a second instead of
after the whole build — naming the phone, and saying that CoreDevice was asked again and still says
unpaired (which is how a Trust tap that did not land is told apart from one that did). It is also a
failure of its own wherever it arrives unannounced, and not the says nothing kind: the device is
offered, and then every command answers
The device must be paired before it can be connected. (com.apple.dt.CoreDeviceError error 2 (0x02)).
That sentence names no phone, no cause and no remedy, so no route passes it through — the destination
list says (not paired) beside the device, and anything that fails against it says which phone it is
and to unlock it, tap Trust, or pair it again with xcrun devicectl manage pair --device <udid>.
| xcbeautify | brew install xcbeautify | turning build output into one line per task, with warnings and errors marked. Optional, and used automatically when it is installed |
These are three separate formulae, and that is the trap. brew install libimobiledevice
provides neither ideviceinstaller nor ios-deploy, so following the obvious instruction leaves
both install and launch broken:
brew install libimobiledevice ideviceinstaller ios-deploy libusbmuxd
The device tools are optional in a strict sense — simulators, macOS, and every device Xcode manages
through CoreDevice (iOS 17 and later) need none of them. They are not optional if you plug in an
iPhone X, because devicectl cannot see it at all: it is not merely refused there, it is absent from
xcrun devicectl list devices, which is why such a device used to fail as one that does not exist.
test/dependencies.test.mjs scrapes every command lib/index.js can run out of its own source and
fails if one is not registered, so this table cannot silently fall behind the code.
Installation
dsh plugin --profile web add /path/to/this/directory
Then restart the profile.
A link: install needs the host package linked too — or DSH lands in Safe Mode
lib/index.js imports defineTool from @deepseek-ai/dsh-tools as a runtime value. Host
packages are deliberately not dependencies: a plugin installed normally resolves them by walking up
the directory tree into profiles/node_modules, where the deployment puts them.
A development install from a directory is recorded as link:, which puts the plugin's real path
somewhere outside the profile tree — so that walk never reaches profiles/node_modules, the import
fails, the plugin tree fails to load, and DSH treats a failed plugin load as fatal: it recovers into
Safe Mode with every third-party plugin disabled. The tell is in
~/Library/Logs/DSH Desktop/harness.log:
plugin recovery detection: dsh-xcodebuild
[desktop] safe mode: third-party web profile bundles are blocked
The loader's own message is misleading — it reports the outermost specifier, because its retry path swallows the real error:
Cannot find package 'dsh-xcodebuild' ← not the actual problem
Cannot find package '@deepseek-ai/dsh-tools' imported from .../lib/index.js ← the actual problem
So after any rm -rf node_modules, run:
npm run link-host
node_modules/ is gitignored, so a fresh clone needs this once. To avoid the fragile link entirely,
install the plugin as a real package instead — pack it and add the tarball, so it lands under the
profile where host packages resolve on their own:
npm pack
dsh plugin --profile web add file:/absolute/path/to/dsh-xcodebuild-0.1.1.tgz
Keeping a profile on the version you are working on
A profile can be a generation install (the symlink points into
profiles/.generations/live/<pkg>+<version>+<hash>) or a plain one (a real directory). The plugin
manager owns the first kind and refuses to promote a tree that is not self-contained, so
npm run sync:profiles only writes the second kind; use plugin_manager install_bundle dsh-xcodebuild@<version> for the first. Run it before blaming a behaviour on this checkout — a
profile sitting on an old version has its own code, and its failures read as if the working tree
were at fault.
Notable behaviour
Destinations come from four sources, not one. xcodebuild -showdestinations describes what
Xcode's own layer currently manages, and on a bench where that layer and CoreDevice disagree it stops
listing a phone that is plugged in, unlocked and buildable — measured on an iPhone 13 running iOS
26.6.2 under Xcode 26.0.1, where the phone vanished from -showdestinations while xcrun xcdevice list still reported it available: true, devicectl still reported its tunnel connected, and
xcodebuild -destination 'platform=iOS,id=<udid>' still built. On the listing alone the panel offered
a simulator for a phone sitting on the desk.
So every channel is read and merged by device id — xcdevice, devicectl, and idevice_id on the
classic channel — mapped into the same record shape, with the sources that saw each device recorded on
it. Reachability only ever goes up in the merge: a channel that cannot see a device has no vote,
because a lost tunnel is not evidence that the phone is gone. A device nothing can reach stays in the
list, marked and sorted last, and it can never become the default.
The classic channel is the one that keeps answering when both of Xcode's own layers are wedged, which
is why it is read last and without a version filter: needsLegacyChannel decides which install
channel a run takes, and that decision is made per run from the same version — filtering the list by
version is what previously hid a modern phone on a bench where this was the only channel still seeing
it.
- Derived data is Xcode's own, so builds stay warm for Xcode.app as well. Pass
derivedDataPathto redirect it. - The built
.appis located via-showBuildSettings, not by constructing a path: with the default derived data the product directory contains a per-project hash. - Log lines are classified in-process (see
lib/classify.js): the panel colours errors, warnings and tasks, and the severity filter groups them, so the kind has to be attached to each line as it arrives. xcbeautifyis used automatically when the machine has it, and is invisible when it does not. Nothing is configured:xcodebuild's output is piped through it, and the panel prints the pipeline it is running. Three details make that safe rather than clever:- Only the flags that version lists in
--helpare passed, and the resulting argv is then run once on a trivial line before any build relies on it. A formatter that rejects its argv exits at once and leaves the build writing into a pipe nobody drains; whatever that does to the build, it is not something a log formatter is allowed to cause. When the probe fails, the formatter is simply not used. NSUnbufferedIO=YESis set on the formatter. Measured on xcbeautify 2.28.0: with stdout on a pipe rather than a terminal its own output is block buffered, so the panel would receive the whole build log at the moment the build ended — no progress, and no errors while they still mattered. With it, lines arrive as the build produces them (measured at 0.8s / 1.5s / 2.2s against a producer printing every 0.7s, versus one 6000-byte burst at the end).--preserve-unbeautifiedis included, because xcbeautify silently drops the task lines it does not recognize (CompileSwift,Ld) without it, and--disable-colored-outputkeeps escape sequences out of a panel that does its own colouring. SetDSH_XCODEBUILD_NO_BEAUTIFY=1to leave it off on a machine that has it. If the formatter dies mid-build the run keeps going with raw output and says so on its own line.
- Only the flags that version lists in
- Archives land in
~/Library/Developer/Xcode/Archives/so Xcode's Organizer lists them. - A workspace is asked twice for its configurations.
xcodebuild -list -json -workspace X.xcworkspaceanswers with{ workspace: { name, schemes } }and reports no configurations at all — the configurations belong to the.xcodeprojinside. Asking only the workspace therefore makes every project look like it has nothing butDebugandRelease; a project that also buildsTest-Releasesilently loses it from the picker.listSchemesfollows up with the wrapped.xcodeproj(named after the workspace, or the only one in the directory) and merges the two answers. The merge is a pure function inlib/listing.js, pinned against real captured output bytest/list-schemes.test.mjs. runpicks its install tool from the destination, not from a default.platform=iOS Simulator,…installs withxcrun simctl,platform=macOS,…with neither (the built.appis already the runnable artifact), andplatform=iOS,…splits further by asking the device its own version.simctlcannot see hardware at all and answersInvalid device: <udid>— which is howrunused to report failure after a successful device build.- Two device channels, split by iOS version rather than by the shape of the id.
devicectlis CoreDevice, and CoreDevice begins at iOS 17: an iPhone X on iOS 16 is not merely refused there, it is absent fromdevicectl list devices, so an install against it failed as a device that does not exist. The same generation is still reachable over the classic lockdown protocol, so the device's ownProductVersiondecides (< 17→ libimobiledevice). The threshold governs the destination list too, because-showdestinationsomits exactly the devices CoreDevice does not manage;showDestinationsappends what xcodebuild left out, so hardware that is not yet prepared for development can still be chosen at all. - Both channels launch as an attached session, and the modern one was wrong about that. A one-shot
devicectl device process launchreturns in a second and leaves the panel with nothing to read: the app's own logging only exists while something is connected to it.--consoleis the modern equivalent of the classic attached session — it connects the app's standard streams and waits for it to exit — and measured on the iPhone 12 it put 128 KB of the app's own[I]/[D]/[V]lines into the panel in the first twelve seconds, live, in the same format the classic channel only ever had in a file. A signal sent to that session is forwarded to the app, and which one ends it was measured rather than assumed: devicectl ignores SIGINT (it survived it, and so did the app), while SIGTERM terminates the app — devicectl reportedApp terminated due to signal 15— and then ends devicectl itself. ios-deploy's handler treats SIGTERM like SIGINT and SIGKILLs its own group, so one signal now means the same thing on both channels: Stop sends SIGTERM, escalates to SIGKILL after five seconds if the session has not ended by itself — a hung devicectl would otherwise be a run that can never leaverunning— andsettleAttachedRunsettles both channels the same way, with the runrunningfor as long as the app is. - The modern container reader is a different tool, and it is 55 times faster. iOS 17 and later reach
the app's data container with
devicectl device copy from --domain-type appDataContainer --domain-identifier <bundleId>, which needs no debug session and no developer disk image: measured, the whole log directory — four files, 348 KB — arrived in 0.9 seconds, against 50 seconds for the 2.4 MB the classicios-deploy --downloadmoved. That is what makes it a fallback rather than a last resort, and it is armed only by continued console silence: devicectl's own launch line arrives whether or not the app ever writes, so the app's own line — the one carrying its pid — is the proof that the console is carrying its logging, and a reader that starts anyway would put every line in the panel twice. Both channels read the same file with the same helpers inlib/legacy-applog.js, because it is the same app writing it. - A helper is never handed to
devicectlwithout the app's name.devicectl device info filesanddevicectl device copy toboth resolve the app's container from the bundle id they are given, and an empty one is a malformed request rather than a missing answer: the command answersInvalid argument (NSPOSIXErrorDomain error 22 (0x16))— a bare EINVAL with nothing in it to act on — which reads as a phone refusing the operation when the real cause is that the plugin does not know which app it is reading. So the pair is checked before either command is built, and the note names the cause and what to do:no bundle id is known for this app, so the helper has nowhere to be put: run the app once, or pass bundleId. The probe is an optimization, so this never blocks a read — it falls back to the debugger exactly as before, with a sentence that can be acted on. - Every look at the device list shows the cache and then refreshes it. A test bench plugs and
swaps hardware all day, so the list has to be re-read on every look or it goes stale invisibly — and
xcodebuild -showdestinationstakes seconds, so waiting for it before drawing anything is what made the control feel dead. Both happen instead, in that order: the cached list is painted immediately with its age on screen (cached 2m5s ago · refreshing…, in the stale colour), and the command runs regardless and replaces it. A look at the list is a refresh — the panel, adopting a project, changing the scheme, opening the dropdown and the⟳button each re-read it, and the only thing skipped is a second read while the first is still running. A read that lands while the dropdown is open is held and applied when it shuts, because rebuilding an open<select>'s options can close the list the user is choosing from. The cache is keyed by scheme, because destinations follow the scheme — a scheme that only builds for simulators must not be shown another scheme's hardware. The selection is re-validated against the live list, so a phone unplugged since the cache was written disappears from the list and the selection moves to the host's recommendation instead of pointing at nothing. - The list is ordered, and re-ordered on every refresh. xcodebuild's own order is neither grouped nor
stable for a bench:
platform:macOSis printed first, and the iOS 16 devices this plugin discovers itself are appended after everything — so a phone plugged in a moment ago appeared last, below a dozen simulators, exactly when it was the thing being looked for.sortDestinationsimposes a total, deterministic order — connected hardware, then simulators, then a Mac; concrete entries before generic placeholders; names read the way a person reads them (iPhone 9beforeiPhone 12, numbers before letters soiPhone 16precedesiPhone X); newest OS first; and the host's own order as the final tiebreak, so two records that agree on everything never swap under the pointer. Being total is what makes a newly connected device take its place in the list rather than appearing at the end. - The legacy install and launch are two different tools on purpose.
ideviceinstaller -u <udid> install <app>installs. That spelling is read off the tool's own--helprather than assumed: 1.2.0 — what Homebrew carries — moved installing from the flag-i <app>to the commandinstall <app>and rejects the old flag outright, with its own usage text as the answer, so a build that was fine reported a failed install (lib/legacy-install.js). Launching usesios-deploy --noinstall --noninteractive, because ios-deploy's own install path fails against this generation of AMDevice with0xe8000067while its lldb-driven launch works —--noinstallis the entire point of the pairing. Neither tool reports its outcome in words, so neither is read for one; the launch witness below is what decides. Both tools are brewed rather than shipped by Xcode, so they are resolved from the Homebrew prefixes instead of assumed to be on the PATH the harness was launched with. - A launch is a launch only when something says the app is running. What decides is the app's own
new log file: an iOS 16 app writes
Documents/PPCrashLog/log_<timestamp>.logas its process starts, so a name that was not in the container before the launch is a device fact, not a claim. Nothing ios-deploy prints can be read instead — thesuccessline comes fromstr(startup_error)the momentLaunch()returns, i.e. before the process is known to be alive, and the message that would contradict it is never printed at all, because Python block-buffers stdout when it is a pipe andos._exitdiscards the buffer (python3 -c "print('x'); import os; os._exit(1)" | catcaptures zero bytes). The oldexitCode !== 0 && !/^success$/m.test(stdout)test let that line veto a real failure and reported every legacy run as launched, withartifact.pid === nulland nothing on the device. Arunwhose install or launch fails reportsfailed, with the reason innoteanderrors, even thoughxcodebuilditself exited0.lib/legacy-launch.jsholds the wording of that failure, andlib/legacy-applog.jsthe witness it is built on. --justlaunchis not passed, because it interrupts the app it just launched. It implies--debug(ios-deploy.m:3688-3692) and then makes the attached path unreachable (:3400-3401): lldbruns the app,safequitdetaches, the CLI returns — and on a real iPhone X the app comes up and is closed again as that happens, which is the "launches, then instantly goes away" symptom. An attached session is what keeps it up, so the launch is spawned detached, in the background, with its console redirected to a file, and the run staysrunningfor as long as the session is attached. The flag is--noninteractive(-I) rather than--debug: it needs no stdin — a detached child has none — andautoexit_commandreports the app's own fate as machine-readable markers (PROCESS_EXITED,PROCESS_CRASHED,PROCESS_STOPPED,PROCESS_DETACHED,PROCESS_NOT_STARTED) with a stack trace under the bad ones, which the panel colours as errors.-O/--outputis still not passed: measured on the iPhone X with-I -O <file> -E <file>, both files stayed at 0 bytes while the app's own log grew, because the app's output reaches the console through lldb rather than through the process's stdout — the console file this plugin redirects is the real capture, and the app's own log below is the live one. Stop ends it by signalling the child's process group, which is also why the child is detached: ios-deploy answers SIGTERM/SIGINT/SIGHUP by SIGKILLing its own group (:1460-1464), so a child sharing this host's group would take the host down with it.-O/--outputis still not passed — that file is opened only byautoexit(lldb.py:103-106, ios-deploy.m:1143-1156) and would be an inert flag pretending to capture a console — the plugin redirects the child's own stdout and stderr instead.- An app that dies on the phone is a verdict about the run, not a line in its log. This was a real bug and the ugliest kind: a launch that crashed settled green. The settlement asked one question — did the app ever come up? — and a crash answers that with a yes, so a crash and the user quitting the app were the same input, and
…
Links
More in this category
yjh051108/dsh-routing-suite★ 6994
One repository, three parts: a runtime injector for DSH plugin packages (inject, hot-reload, unload, promote a dev staging tool to the front, route self-heal, plus a settings-page plugin manager that lists, unloads and drags folders in to internalize), a task-aware reasoning-mode router agent preset (router-standard / router-spec / router-react), and a graded two-level task protocol whose six tools (commit_star, lock_stage, revise_do, edit_plan, mark_task, redteam_verdict) pin task state to disk. The injector implementation ships in-tree, so the install carries its own behaviour rather than a dependency list.
strukto-ai/mirage#dsh★ 3762
Swaps the filesystem and bash providers for a mirage virtual workspace: file tools and shell commands run over mounted resources (RAM, S3, Redis, Slack, Gmail, Notion, Postgres) instead of the host disk, with per-mount read/write/exec modes, per-command sandbox routing (monty, pyodide, quickjs in process; docker, e2b, daytona remote), and installed CLIs (git, gh, slack, linear, ntn, gws, or one you register) as head words in the virtual terminal.
first-fluke/oh-my-agent#dsh★ 1357
Runs project OMA checks before and after tool execution and when a turn tries to finish. Installs its OMA CLI dependency and initializes unconfigured projects on first use. Requires Node.js 22+; initial setup needs network access and project write access.
MichengAI/dsh-skills-manager★ 444
Manages local DeepSeek Harness skills in Settings and shows shared Agent skills as read-only.
hust-open-atom-club/oh-dsh★ 324
Community distribution: TUI, desktop, and Web UI as one bundle with layered installation.
Fishquito7/dsh-skill-mcp-panel★ 248
Manages DSH skills and MCP servers from the web settings: skill cards with hot enable/disable, workspace scopes, groups, batch migration and drag-and-drop import, plus stdio/HTTP MCP CRUD with connection tests, secret redaction and the unified dsh-panel CLI.
Community comments
Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.