dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
在 harness 中通过 xcodebuild 编译、测试、归档并运行 iOS 或 macOS 工程——实时可过滤的构建日志、从工程自身读取的 scheme 与目标设备选择,以及模拟器与 USB 真机的日志流,包含经 libimobiledevice 支持的 iOS 16 及更早设备,其 app 日志在设备锁屏时依然可读。仅限 macOS。
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.
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
dsh-xcodebuild, via ctx.systemPrompt.section) is present on
every step. It tells the model to use the xcode_* tools instead of shelling out to xcodebuild,
and which tool answers which situation (build errors, a crash after launch, a missing tool).xcode-build-loop (via ctx.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:
dsh-better-sidebar, when that plugin is installed — via
ctx.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.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.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:
_ivarDescription. ⌘L-style rows that can be written get an editor chosen from the
declared type: a switch for a BOOL, a field for a number, a text field for a string, a colour well
for a UIColor, four boxes for a CGRect. The edit goes to the app as setValue: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.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:
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 for
Process N stopped and nudges with process status; it does not treat a thread's
stop 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.script may be asked. process status and process interrupt
both need a process LLDB has already launched, so while the attach is settling they answer
error: 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, including 0 with no process at all — and interrupts only
a process LLDB reports as running: an app that was already running when the attach happened is the
one case where attaching does not stop it.mode=launch) starts the app for real and attaches after it has had time to build its UI.
With devicectl --start-stopped instead, the dump returned
error: use of undeclared identifier 'UIApplication' — no windows, no Objective-C runtime.po [[[[UIApplication sharedApplication] windows] firstObject] recursiveDescription]. windows is 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.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:
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 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.
Two axes, combined:
Esc clears. ↑/↓
walk the filters used before, without committing one.verbose, info, warning,
error (see the table below for how the app's LogLevel is 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's
notes 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 a Problems shortcut for "the diagnostics
only" (which keeps notes with their warnings). The levels are drawn from one table in
lib/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.
⌘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:
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.↑ / ↓ (and Enter / Shift+Enter) walk the hits and wrap around the ends; they are
greyed out when nothing matches, and the count says no hits rather than 0 / 0.