All notable changes to @compoundingtech/smalltalk (renaming → @compoundingtech/smalltalk) are
recorded here. The project is pre-1.0; expect breaking changes in
minor releases until 1.0.
The PreCompact flush hook (examples/claude-code/hooks/pre-compact.impl.sh)
previously wrote a "reconstruct-from-scratch" stub over any now.md older
than 5 minutes ($ST_PRECOMPACT_FRESH_S). That clobbered real captured
state — a good but 6-minute-old now.md was replaced with a stub on
compaction, losing decisions/open-threads the model had flushed. Data loss.
Now it's EMPTY-only: a present now.md with non-whitespace content is left
untouched regardless of age (real-but-stale state restores better than a
stub, and the read-side session-start hook already declines to inject a
now.md older than $ST_REHYDRATE_STALE_S). The stub fires only when now.md
is absent, 0-byte, or whitespace-only. $ST_PRECOMPACT_FRESH_S no longer
applies.
examples/codex/session-start.sh now injects the agent's last durable
working-state ($ST_ROOT/$ST_AGENT/context/now.md) as a
<context source="st/context/now.md"> block, staleness-guarded and
absent-able — parity with the claude session-start hook. Previously the
codex hook injected only the unread inbox, so codex agents (vauban, any
codex worker) cold-booted with no working-state to restore.
The hook now emits when EITHER a fresh now.md OR a non-empty inbox is
present (previously: only on a non-empty inbox). Both absent → still a
silent exit, so the no-now.md + empty-inbox case is unchanged. Staleness
threshold shares the claude hook's $ST_REHYDRATE_STALE_S (default 24h).
st ding now accepts --root PATH (alias --st-root), which overrides
$ST_ROOT and the install default for the state root the daemon watches.
Put it in the launch command so it survives a pty restart — a restart
reuses the stored command but can drop/replace env, so a daemon relying only
on $ST_ROOT can silently fall back to the default root (~/.local/state/smalltalk)
and watch the wrong inbox. That mismatch makes a daemon re-poke stranded
messages from the wrong root forever (phantom pokes) while never delivering
the agent's real inbox.
Also: on startup, if $ST_ROOT is unset (and no --root) and more than
one state root exists on disk under ~/.local/state/, st ding now emits a
loud one-line stderr WARN naming the roots — the exact ambiguity that
otherwise takes a multi-round investigation to spot.
Both are additive and back-compat: existing st ding invocations are
unchanged (single-root setups never warn).
The [DING] poke now carries a short, stable per-message id:
[DING] new smalltalk message: [id:jq0d78] <subject> (from <sender>); check your inbox
The id is the rand6 suffix of the message filename (canonical
LAYOUT-004 <unix-ms>-<rand6>.md; outside filenames fall back to the
basename). It's unique per message and stable across re-pokes, so an
agent can tell a re-poke of a message it already handled from a
genuinely new one by glance — without an st message ls every time.
Placed AFTER the new smalltalk message: marker so that marker (which
ding-mode bus-instructions files pattern-match on) stays contiguous.
Purely additive / back-compat: an agent still running older
bus-instructions parses <subject> (from <sender>) unchanged; the
[id:…] is extra.
Pinned by evals + cos (piece (j)'s debug logs surfaced it,
before I could even ship them): st ding was DYING at startup
when the watched identity's $ST_ROOT/<id>/{inbox,archive}
folder didn't exist yet.
Repro:
- Convoy's native launch spawns the worker's ding sidecar BEFORE the agent has written anything to its own inbox — the folder doesn't exist yet.
runDing's watcher's first poll throwsAgentNotHostedError→ error bubbles up →runDing's finally block clears every timer → the daemon exits.- The target session is never poked. The delegation eventually lands (cos creates the folder + writes a message), but the ding is already dead. Worker parks silently. 90s re-scan never rescues it because the ding process itself is gone.
- Race-flaky because convoy sometimes wins the ordering (folder exists before ding boots) but usually doesn't.
Fix (matches what the error message itself suggests):
cmdDingClicallsensureIdentityDirs(identity, root)BEFORE handing off torunDing. Idempotent — a no-op when the folder already exists. Runs after the identity is resolved but before the pty probe (so it lands even when ding would otherwise exit-2 for missing pty).- Same lazy-create semantic every other verb uses when the invoker is the identity's own owner. Ding shouldn't need to race the agent's first write.
Test coverage (2 new tests in
cmdDingCli — startup-race hardening):
- Missing identity folder →
cmdDingClicreates it before touching runDing. Regression guard on the "watcher errored: agent folder missing" stderr line NOT appearing. - Pre-existing identity folder → no-op (idempotent).
Uses inode compare to prove
mkdirSyncdidn't rewrite.
Tests use a fake ptyProbe returning unavailable so
cmdDingCli returns 2 without running runDing — enough to
observe the mkdir side-effect without a real pty dependency.
This is the last piece for capstone 6/0 — the pinned mechanism across ALL the parking (worker in ts5/7/8, resumed cos in ts6 where its inbox may not have re-existed post-respawn). Ding is now robust to being spawned before its target agent's folder exists AND to the agent folder momentarily disappearing (via a future paranoid check — currently the fix covers only the startup race, which is the one convoy hits).
Full suite: 1394 pass (2 new tests), 3 pre-existing integration skipped. Pre-push name-hygiene grep: clean.
Held on feat/kill-st-entirely until the reboot signal.
LIVE via the npm link. Evals re-runs the capstone the moment
this commit lands.
Orchestration note: convoy is also adding bus-folder-creation
to its native-launch wiring as the architectural root — this
smalltalk-side mkdir -p is the defense-in-depth belt so the
ding doesn't die on ANY future ordering bug from ANY launcher
(convoy, hand-launch, alternate harnesses).
Piece (i)'s 90s quiet-window did not close the capstone: the
with-kill capstone re-run still parked the worker for the full
320s. Evals traced (poked worker screen + peeked cap-wk-ding):
worker frozen at "waiting for the task" while the delegation
sits unarchived; two unarchived delegations neither triggered
their arrival poke NOR the re-scan re-pokes for 180s.
cap-wk-ding is a headless daemon → pty peek shows an empty
screen; the delivery-decision layer is invisible. Evals needs
per-tick + per-delivery visibility to pin the failure mechanism.
Ship: ST_DING_DEBUG=1 env knob. When set, ding emits verbose
[st ding debug] lines to stderr:
- Startup scan summary:
startup scan: statusMtimeMs=<n> inbox=<n> eligible=<n>(+ file list when eligible > 0). - Per-rescan-tick summary:
rescan tick: inbox=<n> in-flight-skipped=<n> quiet-skipped=<n> attempted=<n>(+ file list when attempted > 0). - Per-pty-send attempt:
pty send → session="<sess>" status=<n> preview=<first-non-key-sequence>(+ stderr tail when non-empty).
Wire:
- New
debug?: booleanfield onDingDeps. cmdDingClireadsST_DING_DEBUG === '1'from env (any other value is off) → passes asdebug: debugModetorunDing.- The
sendwrapper (send-serialization chain) emits the pty send log line on every successfulrawSend. - Documented in
st ding --helpunder the "Env overrides" block.
Test coverage:
cmdDingCli --helpoutput includesST_DING_DEBUG=1+ "rescan-tick summary" + the[st dingprefix hint.runDing — debug: trueemits[st ding debug] rescan tick:with correct counts +[st ding debug] pty send → session=… status=…on delivery.runDing — debug: false(default) → NO[st ding debug]lines emitted (production-quiet regression guard).
Full suite: 1392 pass (3 new tests), 3 pre-existing integration skipped. Pre-push name-hygiene grep: clean.
Held on feat/kill-st-entirely until the reboot signal. LIVE
via the npm link. Evals will re-run the capstone with
ST_DING_DEBUG=1 on both cap-*-ding sessions and capture the
per-filename delivery + rescan trace across the respawn window —
that's the last mile to pinning the mechanism + owner.
Changed (st-kill piece (i) — capstone 6/0 last-tuning: rescanQuietAfterDeliveryMs 5min → 90s, env-overridable)
Piece (h) covered the never-delivered case (down-window backlog,
re-poked in 60s). Evals traced a real remaining stall: the
delivered-but-parked case. An agent that WAS poked, parked
anyway (mid---resume boot-ritual skip, wedged reply), whose file
is now "recently delivered" — the re-scan skipped it for 5 min,
which is > the capstone's 220s LOOP-CLOSED window, so it never
re-poked in-window → stall.
Fix:
-
DEFAULT_RESCAN_QUIET_AFTER_DELIVERY_MS: 5 * 60 * 1000→90_000(5 min → 90s). A delivered-but-parked agent now gets re-poked at ≤150s total (60s scan interval + 90s quiet). Well inside the 220s grade window. Trade-off: a healthy agent taking > 90s to archive gets a re-nudge — acceptable noise (archive latency is typically much shorter). -
Env-overridable knobs in
cmdDingCli:ST_DING_RESCAN_INTERVAL_MS— scan interval (default 60000). Set to 0 to disable the tick.ST_DING_RESCAN_QUIET_MS— quiet window after a successful delivery (default 90000). Tune down for aggressive re-poking.- Parsed via a small
parseEnvMshelper that ignores malformed values with a stderr warning (typo in an env file can't crash the daemon). - Both documented in
st ding --helpunder a new "Env overrides for the periodic backlog re-scan:" block.
Test coverage:
cmdDingCli --helpoutput asserts both env-var names + the default hints (Default 60000 (60s),Default 90000 (90s)) so evals can rely on the surface.
Full suite: 1389 pass (2 new tests + the default-value change), 3 pre-existing integration skipped. Pre-push name-hygiene grep: clean.
Held on feat/kill-st-entirely until the reboot signal. LIVE
via the npm link (installed st = the held-branch working tree)
so evals re-run against it immediately. Last tuning before
capstone 6/0.
Ding now periodically re-scans the inbox and re-pokes for files
that are unarchived AND not-recently-delivered. Covers the
respawn/--resume case that the initial scanStartupBacklog
missed.
The gap it closes:
- Ding sidecar survives when the target claude session dies + respawns (they're independent pty sessions).
- Message arrives during the down window →
deliverfails → 5 retries → dropped from the buffer. - File still sits unarchived in the inbox; nothing re-pokes when claude returns.
- Claude respawns via
--resume→ context preserved, but the persona's "drain your inbox on boot" ritual is skipped. - Result before this fix: agent comes back with a backlog it doesn't know about.
The fix (ding-only; no convoy-side contract):
- New
runRescanTickfires everyrescanIntervalMs(default 60s, per cos's tuning — capstone grades LOOP-CLOSED in ~220s). - Reads inbox via
readdirSync; for each valid filename:- Skip if in-flight (present in the flush buffer or the read- retry queue).
- Skip if delivered within the last
rescanQuietAfterDeliveryMs(default 5 min — gives the agent time to read + archive before we re-nudge). - Otherwise → feed to
onEvent, which respects busy/dnd, buffers if suppressed, delivers if available.
- New
deliveredAt: Map<Filename, number>tracks last successful delivery timestamp per filename. Updated in both the directdeliverpath (onEvent) and the flush-loop path (tryFlush). Pruned lazily at the top ofrunRescanTick— entries for archived files (no longer in inbox) get deleted so the map doesn't grow unbounded. - The startup-window dedup (
startupSeen) is bypassed by the re-scan: a periodic re-poke is a first-class trigger, not a startup-race dedup event. Added a{ bypassStartupDedup?: boolean }option toonEvent. - Retry-cap log line updated:
giving up on <file>now ends with "will re-attempt via the periodic backlog re-scan" so operators don't panic that a message was lost. - Timer lifecycle:
startRescanTickon the same lifecycle path as the other periodic ticks;stopRescanTickin the finally block.unref()so the timer doesn't hold node open on abort. - New
DingDepsfields:rescanIntervalMs?(default 60s; set to 0 to disable — pre-tick push-only behavior) andrescanQuietAfterDeliveryMs?(default 5 min). - New
common.tsimport:msNow— used for timestamp tracking.
Test coverage (5 new tests in runDing — periodic backlog re-scan):
- Unarchived file with no prior delivery → re-scan re-pokes on the next tick.
- Recently-delivered file → re-scan SKIPS re-poking within the quiet window.
- Archived files are pruned from
deliveredAt— the same filename re-planted later gets re-poked. rescanIntervalMs: 0→ tick disabled (pre-tick behavior).- Failed delivery burst → next re-scan re-pokes once the sender is healthy again (the canonical reboot self-healing case).
Tests default rescanIntervalMs: 0 in the shared startDing
helper so pre-existing tests don't see surprise re-pokes; the new
describe block opts in explicitly.
Split decision with convoy-claude:
- Option A (ding-only periodic re-scan) vs option B (convoy
respawn-signal). Chose A per cos's endorsement + convoy's
agreement: no cross-project contract, ship on the held branch,
covers respawn + wedged-
--resume+ busy→available flip. Also fits cos's "minimal ding, single-purpose" directive: ding's one job (wake the agent for its inbox), extended to cover the down-window case.
Full suite: 1387 pass (5 new tests), 3 pre-existing integration skipped. Pre-push name-hygiene grep: clean.
Held on feat/kill-st-entirely until the reboot signal. Last
piece for capstone 6/0.
Architecture decision (Nathan → cos): smalltalk = pure message
bus + minimal st ding sidecar. Convoy owns launch natively
going forward. Convoy-claude signaled handoff-ready (native
launch committed ts-port 9775c99, convoy no longer shells
st launch), so the whole launch surface is now deleted.
Deleted:
src/commands/launch.ts(~2400 lines):cmdLaunch,cmdLaunchCli,LaunchInput,LaunchResult,LAUNCH_HELP,DING_BUS_INSTRUCTIONS,resolveStShimPath(no longer needed here — it was launch-only), the persona-install + DING-BUS.md-install helpers, the hooks-writer, and everyst launchverb +--ding/--mcpflag.src/commands/launch-core.ts:cmdLaunchCoreCli,parseLaunchCoreInput, the JSON-in schema-validation surface. The__launch-corehidden verb went with it.tests/unit/launch.test.ts(~2700 lines): 162 tests covering the launch surface.tests/unit/launch-core.test.ts: the JSON-in/JSON-out contract tests.
Wire changes:
src/cli.tsdispatch: droppedcase 'launch':andcase '__launch-core':; dropped the two related imports. Dropped thelaunchsubcommand from the top-levelst helptext (visible surface).src/commands/completions.ts: dropped thelaunchentry (13 lines) so shell completions no longer suggest the verb.
Docstring cross-refs updated:
src/mcp/capabilities.ts—CHANNEL_INSTRUCTIONSdoc block no longer referencesDING_BUS_INSTRUCTIONS(moved to convoy). New note: convoy owns the DING-BUS.md template; when the shared bus contract changes, keep MCP's blurb in sync with convoy's vendored version.src/commands/reply.tsheader: dropped the "was missing through the ding-mode work" backstory (that was launch-era context); kept the ding-contract note.src/commands/ding.ts[DING]-prefix rationale doc: reworded the DING-BUS.md refs to "the launcher's bus-instructions file" (transport-agnostic phrasing).
What smalltalk KEEPS (per convoy's ask):
- The bus:
st message send/reply/ls/read/archive/thread,st agents,st status. Unchanged. st ding— the sidecar binary. Convoy spawnsst ding <agent>-claude --identity <agent>as a runtime command. Kept as a bin surface.- The three Claude Code hook scripts under
examples/claude-code/hooks/{session-start,pre-compact, stop-failure}.sh+bin/st. Convoy's native launch writes each agent's.claude/settings.local.jsonreferencing these by absolute path (resolved via SMALLTALK_DIR / sibling../smalltalk). Kept as a stable public location.
What was DING-BUS.md's owner:
- Convoy vendored the DING-BUS.md template (captured from
st-launch output, with the "spawn children" section updated
to
convoy addsincest launchis gone). Smalltalk no longer maintains the ding-mode instructions template — that's convoy's now.
Full suite: 1382 pass (down from 1558 pre-deletion by the removed launch tests), 3 pre-existing integration skipped.
Pre-push name-hygiene grep: clean.
Held on feat/kill-st-entirely until the reboot signal.
Next: trim st ding to minimal per cos's directive (single-
purpose: inbox-watch + wake the agent; orchestration lives in
convoy).
Post-cutover the transport preference flips: st launch (both
harnesses) defaults to ding-mode delivery via an st ding
sidecar. The MCP transport is now opt-in via --mcp.
Rationale: ding-mode works in every environment (no MCP server needed), while MCP mode requires a functioning MCP transport. An operator no longer needs to know the difference — the default just works — and can opt into MCP push delivery when they specifically want it.
Behavior changes:
st launch claude→ NO.mcp.jsonwrite, NO--dangerously-load-development-channelsargv,st dingsidecar added, DING-BUS.md instructions installed via@DING-BUS.mdCLAUDE.md import.st launch claude --mcp→ old behavior:.mcp.jsonwritten,--dangerously-load-development-channels server:stin argv, no ding sidecar.st launch claude --ding→ same as no flag (explicit opt-in; redundant but accepted).st launch codex→ unchanged (codex has always been ding-mode by default; no.mcp.jsonwrite unless--mcpis passed).
CLI:
--mcpboolean flag added to the parser + LAUNCH_HELP.LaunchInput.mcp?: booleanfield added;LaunchInput.dingdocstring rewritten (redundant post-cutover; explicit opt-in).- Ding-mode resolution rule inside
cmdLaunch:- Explicit
--ding→ ding - Explicit
--mcp→ MCP (opt-in) - Neither → ding (default)
- Explicit
Docs:
- LAUNCH_HELP rewritten to describe both
--ding(redundant explicit) and--mcp(opt-in) flags. - Examples list updated: the default now says "ding-mode
(default)", the MCP examples pass
--mcpexplicitly.
Test updates:
channel mode defaultsdescribe rewritten: claude defaults to ding-mode (channel off);--mcpopts into channel-mode; codex unchanged.pty.toml generation > claude preview has no ding sidecar→ reframed toclaude --mcp preview has no ding sidecar+ companion "claude preview HAS ding sidecar by default" test.st.network tag on claude launch (no ding)→ reframed to--mcp (no ding)— the tag is still present in the agent block.--ding claude ding-modedescribe → renamed to--ding vs --mcp (ding-mode is the default post-cutover). Test cases rewritten:default→ asserts ding sidecar, no .mcp.json, no channel--mcp→ asserts .mcp.json + channel + no ding sidecar--ding(explicit) → asserts same as defaultdefault → cmdInit NOT called(no .mcp.json write)--mcp → .mcp.json IS written(opt-in write)
- Persona describe tests (
copies persona to PERSONA.md,idempotent,not-ending-in-newline) → passmcp: trueto isolate persona-only behavior from the ding-bus append. dry-run summarytest → pass--mcpto expect "channel mode: on" in the summary.codex live path→ asserts.mcp.jsondoes NOT exist by default (codex is ding-mode; MCP is opt-in).claude live path→ passesmcp: trueto keep asserting the.mcp.jsonwrite.launch-core.test.ts— the JSON-in/JSON-out test now passesmcp: truefor thechannel: trueassertion. Optional-bool parser inlaunch-core.tsextended to acceptmcp.
Also — nested cleanups from earlier pieces that slipped through:
src/index.tsandsrc/types.tsheader docstrings: reverted bad@compoundingtech/smalltalk→@compoundingtech/stsed (npm scope must stay).src/commands/init.ts+src/commands/launch.tsreference- message strings: same revert.src/commands/mcp.tsdocstring: removed an attribution to a specific maintainer + a stale$ST_ROOTmention in the identity-fallback chain comment.
Full suite: 1558 pass, 3 pre-existing integration skipped. Pre-push name-hygiene grep: clean.
Held on feat/kill-st-entirely until the reboot signal.
Comprehensive scrub of remaining smalltalk/Smalltalk references across
source + tests + hook scripts + README.
smalltalkvariable names renamed tost— the parameter(smalltalk: St)→(st: St)across all MCP tool files, the localconst smalltalk = createSt(...)→const st = createSt(...)inlib.ts+mcp/index.ts+ding.ts, and every downstreamsmalltalk.method()call →st.method().- DingDeps + McpServerHandle interface fields renamed
smalltalk: St→st: St. Consumers updated. - Test fixture
FakeStrenamed toFakeSt; test helpermakeFakeSt→makeFakeSt. Allhandle.smalltalk/fake.smalltalkrefs updated tohandle.st/fake.st. - README — the "Note on the name" dual-alias section reduced
to a one-line note; the "Names" section rewritten to describe
the post-cutover surface (
st+smalltalkbinaries,st_*MCP tools,ST_AGENT/ST_ROOT/ST_CONFIGenv, no smalltalk fallback). Programmatic-API example updated toimport { createSt, ... } from '@compoundingtech/smalltalk'(npm scope preserved — it's the package identity, not a personal-brand ref). MCP server section rewritten to describe thest_*-only tool surface and_meta['st/error']wire key. - Command file header comments — 17 files' header comments
scrubbed of
smalltalkverb refs. st-webin comments →the web UI(generic — external product isn't shipped by this repo).~/.local/state/st/~/.config/ststray path segments from an over-aggressive bulk sed corrected back to~/.local/state/smalltalk/~/.config/smalltalk(the state root name issmalltalk, not the CLI-command namest).createStdefaultconfigRoot— was~/.config/st, corrected to~/.config/smalltalk.parsePeerdefault bare-hostname resolution — was<host>:.local/state/st/, corrected to<host>:.local/state/smalltalk/.sync.tsresolvePeer — same correction for the peer fallback path.tidy-checknotificationfromfield — was'st-system', now'st-system'. Test updated.completionsoutput — regenerated from generic scrub; test assertscomplete -c st(not-c smalltalk).- Wire meta key —
_meta['smalltalk/error']→_meta['st/error']refs in error-mapping module and every consuming test. stErrorToToolResultfunction (wasstErrorToToolResult) andreadStErrorPayload(wasreadStErrorPayload) — public error-mapping exports renamed.
Test fixture data cosmetic:
- A handle used as a test fixture identity renamed to
operatorin test-only fixtures. Load-bearing exception: NPM scope refs like@compoundingtech/smalltalkand@compoundingtech/ptyare preserved — they're the actual package identifiers. - The 3-segment legacy filename regression test uses
legacyas the middle segment — semantic behavior of the test is unchanged.
Vestigial migration tools removed:
tools/cutover/rewrite-mcp-json.ts+ teststools/cutover/rewrite-pty-toml.ts+ teststools/cutover/sweep.ts+ teststools/cutover/directory itself
Full suite: 1555 pass, 3 pre-existing integration skipped.
Pre-push name-hygiene grep: clean (with @compoundingtech/ npm scope
excluded per the updated memory'd pattern).
Held on feat/kill-st-entirely until the reboot signal.
Changed (st-kill piece (c) — SDK / wire renames: StError → StError, createSt → createSt, Smalltalk → St, wire meta key flip)
Public SDK surface and wire-format constants renamed to their post-cutover canonical shapes:
StErrorbase class + all 15 subclasses (AgentRequiredError,AgentNotHostedError,InvalidAgentError,InvalidFilenameError,MessageNotFoundError,InvalidStateError,InvalidPriorityError,InvalidDurationError,SyncFailedError,PeersConfigMissingError,PeersConfigInvalidError,EmptyBodyError,ArchiveConflictError,ResourceNotFoundError,InvalidResourceUrlError, plus the legacyInvalidIdentityErroralias) renamed toStError+ subclasses. Public export from@compoundingtech/smalltalk/errors.createSt()factory renamed tocreateSt().Smalltalkinterface renamed toSt.StOptionsinterface renamed toStOptions.- Wire meta key:
ST_ERROR_META_KEY = 'smalltalk/error'flipped toST_ERROR_META_KEY = 'st/error'(silent flip — no external embedder is reading_meta['smalltalk/error']today). resolveStBinPath()renamed toresolveStShimPath(). The redundant sibling helperresolveStBinPath(stBin)(which took an already-resolved bin path and returned itsbin/stsibling) has been dropped —resolveStShimPath()returnsbin/stdirectly since #56.
Test refs bulk-updated:
_meta['smalltalk/error']→_meta['st/error']across integration + unit MCP tests.- ~30
Smalltalk-as-type refs across test files updated toSt. createStcall sites in embedding tests →createSt.
Full suite: 1555 pass, 3 pre-existing integration skipped.
Pre-push name-hygiene grep: clean. Updated the memory'd grep pattern to exclude the @compoundingtech/ npm scope — that's the actual package identity in package.json + import statements, not a personal-brand leak. All other myobie references (comments, test fixtures) are still caught.
Changed (st-kill piece (b) — CLI + env: bin/smalltalk, members, st- plugin prefix, and ST_* env vars removed)
Post-cutover the CLI surface + env fallbacks are st_* only.
bin/smalltalk— deleted.package.json:binno longer declares thesmalltalkentry.case 'members':CLI alias indispatchTopremoved.st agentsis the sole canonical verb.st-<cmd>— dropped from the git-style PATH-plugin scan infindPlugin. Onlyst-<cmd>andsmalltalk-<cmd>prefixes are tried now.ST_ROOT/ST_IDENTITY/ST_CONFIG/ST_CHANNEL_DEBUG— no longer honored by any code path.stRootFrom,stConfigFrom,envAgentFrom,resolveAgent, andcmdMcpCliall read onlyST_*env vars now. TheST_IDENTITY → ST_AGENTlegacy alias (a smalltalk-era rename) is preserved with a one-time deprecation warning.stRootFrom/stConfigFrom/stRoot/stConfighelpers renamed tostRootFrom/stConfigFrom/stRoot/stConfig— ~200 call sites acrosssrc/lib.tsand every command module.ResolveAgentOpts.stRootfield renamed tostRoot.CliContext.stRoot→stRoot;CliContext.stConfig→stConfig.warnStFallbackinternal helper renamed towarnLegacyEnvFallback(now only covers theST_IDENTITY → ST_AGENTdeprecation).invokedAsFromnow defaults to'st'(not'smalltalk'); theInvokedAstype narrowed from'smalltalk' | 'st' | 'smalltalk'to'st' | 'smalltalk'.canonicalServerNamealways returns'st';SERVER_INFO.nameis'st';buildServerInfoaccepts only'st'.~/.local/state/smalltalkand~/.config/smalltalk— no longer read as fallbacks.defaultStateRootreturns~/.local/state/smalltalk;defaultConfigDirreturns~/.config/smalltalk..mcp.jsonlegacysmalltalkkey — no longer read or migrated.cmdInitonly reads/writesmcpServers.st; a pre-cutover file with only asmalltalk:entry is treated as absent (thestentry gets added; thesmalltalkentry is left alone).- User-visible verb strings — bulk-scrubbed across source +
hook scripts + tests:
st ding→st ding,st init→st init,`smalltalk X`in error messages →`st X`,<st-root>in tool descriptions →<st-root>,<channel source="smalltalk">phrasing removed from source comments. Codex hooks (examples/codex/*.sh) + Claude Code hooks (examples/claude-code/hooks/*.sh) rewritten st-only.
Piece (a)'s EXPECTED_TOOL_NAMES regression guard extended with
piece (b)'s reframed alias tests:
env-var resolution (ST_* only)— regression guards that$ST_ROOT,$ST_IDENTITY,$ST_CONFIGare NOT honored (each individually assertion).state-dir resolution— always resolves to~/.local/state/smalltalkeven when~/.local/state/smalltalkexists (regression guard).bin/smalltalk is REMOVED— new source-guard regression assertion.st-<cmd>plugin prefix NOT scanned — new regression assertion.package.json bin— asserts onlyst+smalltalkdeclared.
Cutover tools (tools/cutover/rewrite-mcp-json.ts,
tools/cutover/rewrite-pty-toml.ts, tools/cutover/sweep.ts) and
their tests removed — the migration they were built for is done.
Full suite: 1555 pass (3 skipped integration flakes). Pre-push name-hygiene grep: clean.
Regression guard on source-level developer-path leaks in
init.ts broadened from a single-person literal match to a
generic OS-home-directory prefix catch — any absolute
developer-machine path fails the check.
Held on feat/kill-st-entirely until the reboot signal
(Option B).
Changed (st-kill piece (a) — MCP tools registered under st_* only; dual-register removed; members alias retired)
Post-cutover the MCP tool surface is st_* only. The historical
dual-register mechanism has been fully removed:
src/mcp/tools/dual-register.ts— deleted.- 9 tool files rewritten to call
mcp.registerTool('st_<name>', …)directly. membersdeprecated alias retired.st_agentsis the sole canonical name.CHANNEL_INSTRUCTIONSfully rewritten st-only — everyst_*tool name,st statusverb,<channel source="smalltalk">reference, and "Smalltalk threads stay on smalltalk" convention line replaced.EXPECTED_TOOL_NAMESisst_*only (was 28-entry dual-prefixed; now 13-entry).- Tool titles + descriptions scrubbed: no
st message,$ST_IDENTITY, or<st-root>phrasing remains.
Test coverage:
channel-instructions.test.ts— st-only load-bearing substrings; added case-insensitive assertion thatsmalltalkdoesn't appear anywhere (cutover lock-in).aliases.test.tsItem 3 — reframed as "registry isst_*only"; asserts st_ present AND st_ alias GONE.mcp/lifecycle.test.ts— EXPECTED_TOOL_NAMES array flipped.mcp/context.test.ts— dropped dual-register alias smoke.- ~250
st_*tool-name refs across 21 test files bulk-renamed tost_*(mechanical).
Load-bearing: first st-kill piece touching the MCP wire
surface. Held on feat/kill-st-entirely until reboot signal
(Option B).
Full suite: 1595 pass, 4 pre-existing flakes.
Two st ding hardening follow-ups from the same review that
produced #72 — both pre-reboot, both critical for
ding-as-primary-transport reliability.
Fix 1: session-flap debounce. A pty --permanent session is
auto-restarted by pty's supervisor. Between the old process's
exit and the new pidfile write there's a window where
process.kill(pid, 0) returns ESRCH, but the session is
actually about to come back. Without debounce, ding's session-
watch tick would trip the exit-when-gone path on that first
miss; the daemon exits; its own supervisor restarts it
eventually but arrivals during the gap are missed.
Fix: require SESSION_GONE_DEBOUNCE_MISSES = 3 consecutive
"gone" observations before tripping. Any alive observation
resets the counter. A quick flap (1–2 ticks of "gone") rides
through cleanly; a real session death still trips within a few
ticks (~90s at the default 30s watch interval, but tests use
aggressive intervals to verify the math). Probe errors
(transient permission glitches, etc.) reset the counter too —
an unknown probe state shouldn't accumulate as evidence of
"gone."
Debounce logs the intermediate miss count so an operator can
grep the daemon's stderr for flap patterns: target session "<name>" appears gone (miss 1/3); debouncing before exit.
Fix 2: PATH-robustness probe at boot. A ding daemon that
can't spawn pty runs forever with zero successful deliveries.
Especially load-bearing under a supervisor (launchd/systemd/
cron) whose environment strips PATH — the daemon comes up
"healthy" and silently drops every notice for its entire
uptime.
Fix: probe pty --help synchronously at the top of
cmdDingCli (after arg parsing + identity validation, before
starting timers/watchers). On ENOENT or non-zero exit, emit a
multi-line LOUD stderr banner (same shape as the hooks-loud
banner from #59), naming the specific probe failure and how to
fix it (typical fix: set PATH explicitly in the supervisor's
unit/plist). Return exit code 2 — refuse to start rather than
run forever with zero deliveries.
New exported helpers for testability:
probePtyOnPath(): PtyProbeResult— the boot probe itselfcmdDingCliaccepts an optional third arg{ ptyProbe?: () => PtyProbeResult }— tests inject a mock probe to exercise both the available and unavailable paths without shelling out
7 new unit tests:
- Session-flap debounce: single "gone" miss then alive again → ding stays running; N consecutive misses → ding exits; flapping (alternating gone/alive) never trips; probe error resets the miss counter (conservative).
- PATH probe: unavailable → cmdDingCli refuses to start with
the LOUD banner + exit 2; available → cmdDingCli proceeds
past the probe (no banner);
probePtyOnPathhappy path in the test env returns available.
Existing 65 ding tests unchanged and passing.
Full suite: 1596 pass, only the 4 pre-existing integration flakes.
Follow-up hardening findings NOT in this PR (deliberately scoped down; documented as known / deferred):
isSessionAlivefalse-positive on PID reuse — low-severity, documented behavior- Cosmetic
st ding:log strings + unbounded buffer cap + SIGINT double-tap — fold into st-kill piece (d)
With smalltalk going away entirely and ding-mode becoming the DEFAULT
for st launch, st ding is now the PRIMARY inbox transport for
the whole network — not a fallback. On an MCP-hostile machine
(the operator's cos → supervisor → worker chain), a ding daemon
that crashes, silently loses messages, or garbles deliveries =
the whole setup fails silently.
A targeted hardening review found 3 blocker-severity gaps + 1 important (all with concrete production failure scenarios). This PR closes them.
Blocker 1: concurrent pty send calls were unserialized (race).
Watcher-onEvent, buffer-flush drain, tidy tick, and startup scan
could all spawn pty send concurrently against the same session.
With --with-delay 0.5 widening the per-send window to ~500ms,
text-A/text-B/return-A/return-B could interleave on the receiving
terminal — the first Enter committing A with B stuck in the paste
buffer, or A never committing because B's return already fired.
Fix: every pty send invocation goes through a per-daemon async
chain. The chain awaits the previous send's completion (regardless
of outcome) before invoking the next. Serialization is added at
the send reference used inside runDing, so all downstream
callers (deliver, tidy tick, etc.) get it automatically. Also
guards tryFlush re-entry (setInterval doesn't skip a tick when
the previous callback is still awaiting deliver) via a
flushing flag.
Blocker 2: at-most-once semantics on pty send failure
(retry-semantics). deliver() logged + dropped when send threw
OR returned non-zero. The operator's rule is at-least-once. A
transient pty error (target respawning, ECHILD/EPIPE, brief
supervisor hiccup) silently lost a notice.
Fix: deliver() returns a boolean (true on success, false on
failure). Callers requeue on false with an incremented retries
counter. Cap at MAX_DELIVER_RETRIES = 5 per event — a
permanently-broken target drops the notice with a loud stderr line
("giving up on after 5 deliver attempts; check pty session
or restart ding") rather than either dropping silently OR
blocking newer arrivals forever.
Blocker 3: buildEvent read failures silently dropped
(retry-semantics). onEvent at :404-410 caught read errors and
returned without buffering. Peer's atomic-rename race → read sees
mid-write → throws → notice lost. The nearby comment claimed
"lean toward delivering — better than silently dropping" but the
read branch violated it.
Fix: on read failure, buffer the bare filename in a new
readPending list. Each flush tick retries the reads; on success,
the event moves into the main buffer for delivery. Same
at-least-once guarantee as the send-retry path.
Important 4: startup scan → watcher-arm race window.
Historic ordering was scan → arm watcher. Files arriving BETWEEN
the readdirSync snapshot and the watcher arming were in neither
source — silently dropped on daemon startup. The relevant comment
even acknowledged the hole ("Files arriving DURING the scan are
out of luck").
Fix: arm the watcher's for-await loop FIRST (in a concurrent async
task), THEN run the scan. Both sources feed onEvent. onEvent
dedups via a startupSeen set for the first
STARTUP_DEDUP_WINDOW_MS (60 seconds — well beyond any real FS
watcher settle time). After the window closes the set is cleared;
the watcher is the sole event source from that point on.
8 new unit tests cover:
- Three concurrent arrivals → sender's peak concurrent-inflight count stays ≤ 1 (send serialization guard).
- Busy → available: 3 buffered notices flush without send overlap (flush-timer serialization guard).
pty sendfails once → requeues + delivers on next flush (retry-on-send-fail).pty sendfails permanently → gives up after MAX_DELIVER_RETRIES with a loud log (retry cap).smalltalk.readfails 1 time → filename buffered, delivers on next tick (retry-on-read-fail).smalltalk.readfails 3 times → still delivers on the 4th tick (multi-retry).- Watcher fires the same filename twice → delivered exactly once (startup dedup, direct).
- Two distinct filenames + one duplicate → 2 deliveries, dupe dropped (dedup precision).
Existing 57 ding tests unchanged and passing (regression-safe refactor).
Full suite: 1589 pass, only the 4 pre-existing integration flakes.
Follow-ups (from the same review, not in this PR):
- Session-flap tripping exit-when-gone (debounce needed) — separate PR pre-reboot
spawn('pty')PATH robustness (boot-time probe) — separate PR pre-rebootisSessionAlivefalse-positive on PID reuse — low-severity, documented as known behavior- Cosmetic
st ding:log-string leaks + unbounded buffer + SIGINT double-tap → fold into st-kill piece (d)
Added (st __launch-core — hidden JSON-in/JSON-out entrypoint for the convoy bridge)
The launch write logic (identity resolution, .mcp.json,
session-id bootstrap, hooks, persona/DING-BUS install, pty.toml
emission, argv construction) currently lives in src/commands/ launch.ts — 2226 LOC of battle-tested TypeScript. When convoy
absorbs launch, it's a Swift codebase and can't import the TS
package as a library. Two-step cutover per convoy-claude:
- This PR (reboot moment): kill the
st launchuser surface (in the st-kill branch), but keep the write logic in TS behind a stable hidden entrypoint convoy calls via subprocess. - Fast-follow post-reboot: convoy ports helpers to Swift one at a time, guarded by golden-file parity tests against this entrypoint's output. Subprocess drops when parity is green; smalltalk becomes pure bus.
Contract (stable, additive-only):
- STDIN: JSON body matching
LaunchInput(minusenvandstRoot, which come from the invoker's process env). Unknown fields are IGNORED — forward-compat when older smalltalk sees a field a newer convoy sends. - STDOUT: JSON body of
LaunchResulton exit 0. Every written file's absolute path is enumerated on a*Pathfield (null when not written this launch). - STDERR: error message on non-zero exit.
- EXIT CODE: 0 = success, 1 = validation error (input
malformed, harness not
claude/codex, bad field type), 2 = internal error (unexpected exception).
Hidden: NOT listed in st help, st --help, or shell
completions. Reachable only by name. This is not a user surface
— it's the convoy contract. Regression-guarded via a test that
asserts st help output does not mention __launch-core.
Design decisions:
- JSON body over flags: pure data contract; convoy sends only what it knows; adding a new launch input field never ripples flag-parsing changes into convoy.
- Additive-only field renames (same commitment as
AgentSummary.identity→agent): field renames ship additive-then-deprecate so convoy's binder can be tolerant across releases. - Not a helper library: the Swift/TS boundary forces subprocess-bridge; a "TS package API" is unusable from Swift. The subprocess is the pragmatic API.
15 new unit tests cover:
- Happy path: valid claude dry-run JSON → LaunchResult JSON on stdout, exit 0.
- Composition:
--ding+--freshlayer correctly through the JSON (channel false, argv omits--resume+ channels flag). - Codex harness works too.
- Extra unknown JSON fields are ignored (forward-compat guard).
- Minimal input (just
harness) uses defaults. - Validation: non-JSON stdin, non-object JSON, missing/wrong
harness, wrong-typeidentity/ding, extra positional argv — each emits a clear stderr message and returns exit 1. --helpreturns 0 with contract description on stderr; stdout stays clean.- Hidden regression guard:
st helpoutput does NOT mention__launch-core.
Full suite: 1581 pass, only the 4 pre-existing integration flakes.
End-to-end smoke:
$ echo '{"harness":"claude","identity":"alice","ding":true,"fresh":true,"dryRun":true}' \
| st __launch-core
{"identity":"alice","channel":false,"ding":true,"fresh":true,
"argv":["claude","--permission-mode","bypassPermissions"], …}
$ echo $?
0
Ready for convoy-claude to wire the bridge ahead of the reboot.
Every ding-mode agent booting from DING-BUS.md (installed by
st launch --ding since #61) was instructed to run
st message reply <filename> -m "<body>" on inbox arrivals, but
the CLI dispatcher at src/cli.ts:dispatchMessage didn't route
reply — every agent hit unknown subcommand: reply on their
first response. Load-bearing gap that made ding-mode delivery
functional but reply-ability broken.
Fix:
- New
src/commands/reply.tswithcmdReply(programmatic entry) andcmdReplyCli(CLI wrapper). Same locate + derive semantics as the MCPst_msg_replytool:- Take
<thread-filename>positional - Locate across
<self>/inbox,<self>/archive, and every other identity'sarchive/(cross-identity case after sync mirrors a peer's archive back to the local tree) - Derive recipient from the thread's
from:frontmatter - Derive default subject as
re: <original-subject>(or omit if the original had none) - Body via
-m <body>/--message <body>or stdin (mutually exclusive; the "both provided" case throws to prevent silent drops — matchesst message sendguard) - Optional
--subject Soverride +--from IDsender override
- Take
case 'reply':added todispatchMessage(src/cli.ts:165)messageUsage()help text extended to include the new verb; top-levelst helpalso listsreplyalongsidesend | ls | read | archive | thread- Shared
locateThreadextracted fromsrc/mcp/tools/reply.tsintosrc/locate-thread.tsso CLI and MCP entry points can't drift on locate semantics (single source of truth)
14 new unit tests in tests/unit/reply.test.ts:
- Recipient derived from thread
from:; reply lands in that identity's inbox within-reply-to:+ derived subject - Locates thread in own archive (post-archive reply works)
- Locates thread in a peer's archive (cross-identity post-sync case)
--subjectoverride wins over derived default- No subject in reply when parent had no subject
- Thread not found →
MessageNotFoundError-shaped error - Missing
$ST_AGENT→ clear error naming the env - CLI
-minline body writes reply + prints filename to stdout - CLI
--messagelong-form works - CLI
--subjectoverride - CLI
--helpprints usage - CLI no
<thread>arg → clear error - CLI
-m+ piped stdin → throws (matchessend.tsguard) - Regression:
st message reply <fn>no longer errors withunknown subcommand: replyvia the top-levelrunClientry
Full suite: 1567 pass, only the 4 pre-existing integration flakes.
End-to-end smoke: st message reply <fn> -m "<body>" < /dev/null
writes into <derived-recipient>/inbox/ with from: +
in-reply-to: + derived subject: frontmatter set. Ding-mode
agents can now reply per the DING-BUS.md contract.
Bug traced to #61 (DING-BUS.md install) — the contract documented
a verb that didn't exist. Discovered via a targeted st --help
- completions audit (see follow-up PRs on the same audit's completions binary-target and coverage-gap findings).
Companion change to --fresh, bundled per cos (same code area).
Previously st launch DERIVED PTY_ROOT = <ST_ROOT>/pty and
IGNORED a directly-set $PTY_ROOT. Now:
$PTY_ROOTset + non-empty in the invoker's env → use verbatim- Else
$ST_ROOTset (non-default network) → derive<ST_ROOT>/pty(Q2-A nested, unchanged from #68) - Else → skip (default-network case, unchanged)
One fix, two problems:
- Eval stev-retirement cutover unblocked — it needs a short,
decoupled per-run pty root (
/tmp/stev-<runid>) that the nested-derived form can't produce. - Unix 104-byte socket-path limit sidestepped — a nested
<ST_ROOT>/ptyunder a deep sandbox path pushes pty's socket paths over the OS ceiling. A direct short$PTY_ROOTavoids it.
Emit is now independent of ST_ROOT: a launch with $PTY_ROOT set
but no $ST_ROOT emits PTY_ROOT alone (default bus, isolated pty
— the pure stev-retirement shape). The historic "matched-pair"
invariant from #68 is deliberately loosened for the decouple case;
the docstring on buildPtyToml.opts.ptyRoot names the precedence
rule.
4 new / updated tests:
- Nested-derive case (ST_ROOT only) → PTY_ROOT = ST_ROOT/pty (unchanged from #68; test description updated).
- Direct $PTY_ROOT env → baked verbatim, both agent + ding blocks; negative guard against the derived-nested form re-appearing.
- Direct $PTY_ROOT without $ST_ROOT → PTY_ROOT emitted alone, ST_ROOT absent (pure stev shape).
- Empty-string $PTY_ROOT → falls through to derive (regression guard on the null-ish check).
New mode that skips the pinned-session bootstrap and OMITs
--resume from the launched argv. The agent starts with a
completely clean context and must rehydrate from durable state
alone (now.md + git + bus).
Mechanism for the resumability eval's fresh-vs---resume
A/B — Arm D — and, per the larger roadmap, the mechanism for
eventually dropping the session-id-resume ceremony
entirely. If durable-state rehydration proves sufficient across
the eval, --resume becomes redundant and convoy add gets
simpler.
Semantics:
- One-off, not a rewrite.
--freshleaves any existing.claude-session-id/.codex-session-idfile byte-for-byte untouched. The next non-fresh launch from the same cwd resumes the pinned session as usual. Reversible. - No jsonl bootstrap. The one-shot
claude --print --session-id <SID> "session init"that normally seeds the jsonl store is skipped — Claude Code auto-mints its own session on start. - Symmetric across harnesses:
st launch codex --freshemits barecodex(noresume <sid>) even when a pin file exists. - Composes orthogonally with
--ding,--persona,--permanent,--permission-mode,--agent, etc. Fresh affects session-id + argv only. LaunchInput.fresh?: boolean+LaunchResult.fresh: booleanfields;LaunchResult.claudeSessionIdPathreturnsnullunder fresh mode.
Argv delta:
# Without --fresh (baseline)
claude --permission-mode bypassPermissions --dangerously-load-development-channels server:st --resume <SID>
# With --fresh
claude --permission-mode bypassPermissions --dangerously-load-development-channels server:st
11 new unit tests: default (no --fresh) has --resume (regression
baseline); --fresh omits --resume; live run doesn't write the
pin; live run with pre-existing pin preserves it byte-for-byte;
non-fresh live run still writes the pin (scoped-skip guard);
LaunchResult.claudeSessionIdPath === null under --fresh;
codex fresh skips reading .codex-session-id even when present;
non-fresh codex still uses codex resume <sid> (regression
baseline); --fresh + --ding compose orthogonally; CLI --fresh
threads through + dry-run summary; CLI default reports
fresh mode: no.
Full suite: 1550 pass, only the 4 pre-existing integration flakes.
Related: pty-claude may need a matching pty-claude-launcher.sh
option if their launcher hard-codes --resume; not blocking on
that — st launch builds argv directly and doesn't shell out to
the launcher script.
Set-side companion to pty-claude's Phase-2 PR (pty#55 — per-network
PTY_ROOT + pty --root). st launch now emits PTY_ROOT in the
pty.toml [sessions.X.env] block whenever the network is non-default,
matching the existing ST_ROOT env-line emit trigger byte-for-byte.
Derivation (Q2-A, nested): PTY_ROOT = <ST_ROOT>/pty. A network's
whole state (bus + pty) lives under one ST_ROOT dir, so rm -rf $ST_ROOT removes the network entirely — the end-state Nathan
specified ("rm the folder, network's gone").
Semantics:
- Matched-pair invariant with
ST_ROOT.PTY_ROOTemits if and only ifST_ROOTdoes. A bare-bus / shared-pty split would defeat the "rm the folder" semantic; the pair moves in lockstep. - Both session blocks tagged: main session + ding sidecar env
blocks each carry the same
PTY_ROOTvalue. Consistent with the existingST_ROOTmirror. - Default network unchanged: no env line emitted; pty.toml
doesn't freeze today's default into future restarts. Same
asymmetry-with-
st.network-tag preserved. - Legacy
ST_ROOT: canonicalized toST_ROOTvia the existing input.stRoot resolution, then flows through the PTY_ROOT derivation — no separate legacy path; env line name is the post-cutover canonicalPTY_ROOTregardless of which env the invoker used.
Post-merge, an agent launched into a non-default network gets both bus + pty isolation for free — an evals-side follow-up to retire stev's session-prefixing becomes possible.
4 new unit tests: default network → no PTY_ROOT emitted; explicit
ST_ROOT → both ST_ROOT and PTY_ROOT = <ST_ROOT>/pty present;
codex ding sidecar carries PTY_ROOT too (matched-pair with
ST_ROOT); legacy ST_ROOT invoker env produces the canonical
PTY_ROOT shape (no ST_PTY_ROOT regression).
Full suite: 1539 pass, only the 4 pre-existing integration flakes.
Historic behavior: readGitExclude(cwd) did
join(cwd, '.git', 'info', 'exclude') and stat-checked .git
as a dir. In a git worktree (created via git worktree add <path>), <worktree>/.git is a text FILE pointing at
<main>/.git/worktrees/<name> — the dir check returned false,
readGitExclude returned null, and every downstream caller
surfaced gitRepoAbsent: true. The persona files (PERSONA.md,
DING-BUS.md, .mcp.json, generated CLAUDE.md, session-id files,
pty.toml) were still installed but silently NOT excluded from
git tracking — an operator working out of a worktree
accidentally staged them.
Fix: use git rev-parse --git-path info/exclude. Git resolves
this correctly for every layout:
- Regular repo →
<cwd>/.git/info/exclude. - Worktree → the SHARED
<main>/.git/info/exclude(info/exclude is shared across worktrees per git's design). - Bare repo → the repo's
info/exclude. - Non-git dir → nonzero exit →
readGitExcludereturns null (the existinggitRepoAbsent: truesurface stays intact for callers).
The write path (appendGitExclude) already used the returned
absolute path directly, so it flows through unchanged — the fix
lives entirely inside readGitExclude.
Tests:
- New positive worktree regression guard: launch in a worktree
cwd → git-exclude entries land in the MAIN repo's shared
.git/info/exclude, and the worktree's.gitis confirmed to be a FILE (not a dir) we didn't touch. - Existing
makeGitRepo(dir)fixture updated frommkdir -p .git/info/(which no longer satisfiesgit rev-parse) to a realgit init— 12 persona tests now boot a real repo, more faithful to production. - The dry-run "touches nothing" assertion updated:
git initships with a default.git/info/excludetemplate, so the test now checks the exclude file (if present) contains neither our appendedPERSONA.mdline nor thesmalltalk-launchblock header — a more precise "we didn't write to it" guarantee than the historic "the file doesn't exist" assertion.
Full suite: 1532 pass, only the 4 pre-existing integration flakes.
Onboarding step 1 says git clone https://github.com/myobie/ personas, which gives you a rolling-HEAD checkout. Fine for
solo work following current guidance, but a problem for:
- Evals: reproducibility across runs requires the persona files to match the ones the eval was designed against.
- Workshops / teaching: everyone should be on the same persona content so the walk-through matches what participants see.
- Shared team CoS setups: if two teammates clone at different times, they end up with subtly different CoS behavior.
Added a note right under the clone block explaining the
tradeoff and showing how to pin (cd personas && git checkout <sha>). Also names the currently-tested set (96a6331, the
personas HEAD as of 2026-07-06) so readers evaluating smalltalk
against this doc get the exact files the doc was validated
against. Includes a maintenance instruction to bump the SHA
when the tested set advances — a rolling HEAD in the
instructions plus a personas change would silently drift.
evals-claude caught a contract-level gap in DING-BUS.md before
its live run even finished: a ding-mode CoS following its role
("stand up a specialist per repo") had zero instruction to add
--ding to the children it launches. On an MCP-capable box
the mixed-mode tree still completes → false-pass; on Johannes's
actual MCP-hostile setup the child fails to boot and the whole
cos → supervisor → worker chain collapses.
Root cause: the shipped DING-BUS.md (from #61) covered
boot-ritual, [DING]-poke handling, threads-on-bus, and a
CLI inventory — but nothing about spawning children. The
st launch command wasn't even in the inventory. A ding-mode
agent following the contract would faithfully copy its own
launch pattern from the persona / onboarding docs — where the
examples are st launch <harness> … without --ding — and
silently produce an MCP-mode child.
Fix — add a new "Propagate ding-mode through every spawn"
section to DING_BUS_INSTRUCTIONS:
- Rule stated plainly: "ding-mode is not a per-agent choice — it's a property of the whole machine, so every agent you spawn on this machine MUST also be in ding-mode."
- Exact command shape:
st launch <harness> --identity <child-id> --ding [--persona <path>] [--permanent] … - Cascade guarantee: the child gets its own DING-BUS.md
automatically (since
st launch --dinginstalls the same contract in the child's cwd), so the rule holds at every level of a cos → supervisor → worker tree. - Anti-pattern explicit: "Do NOT run plain
st launch" — names the failure mode (spawns an MCP-mode child that fails to start, or worse, appears to start and delivers nothing). - Copy-paste warning: "you must add
--dingto any copiedst launch …command that doesn't already have it" — targets the exact behavior that produced the false-pass. - CLI inventory expanded:
st launch <harness> --identity <id> --ding [...]now listed under a new "Spawning children" block, cross-referencing the rule above.
CHANNEL_INSTRUCTIONS deliberately does NOT mirror this section —
MCP-mode has no equivalent constraint (an MCP child on an
MCP-capable machine is fine). The DING_BUS_INSTRUCTIONS
docstring calls out the asymmetry so a future reader doesn't
add a matching-but-nonsensical section to the MCP side.
Tests:
- New positive regression guards in the existing DING-BUS.md
content test: the new section header, the rule statement, the
exact
st launch <harness> --identity <child-id> --dingcommand shape, the "Do NOT run plainst launch" anti-pattern, and the new "Spawning children" CLI-inventory block are all asserted present. - Existing negative guards (no
smalltalkleak, etc.) still fire.
Load-bearing for Johannes's team — this is what makes his cos → supervisor → worker chain work end-to-end on the MCP-hostile machine, not just the top-level CoS.
Full suite: 1531 pass, only the 4 pre-existing integration flakes.
pty-claude designed the tag; this PR wires it into st launch.
Every session emitted into pty.toml — the agent block AND
(when present) the ding sidecar block — now carries an
"st.network" = "<value>" tag. The tag is a uniform inspection
signal that separates smalltalk-network sessions from an
operator's ad-hoc pty use: presence = "this is a smalltalk-
network session"; value = which network. pty's
--filter-tag st.network=<value> primitive filters on it out of
the box (verified against ../pty/src/tags.ts:matchesAllTags +
smol-toml's quoted-key parsing).
Design decisions per pty-claude's steer:
-
Value = the resolved network root (
input.stRoot, which cmdLaunch already computes viastRootFrom(ctx.env)at the CLI layer). Always a valid path regardless of whether the invoker setST_ROOTexplicitly or is on the default. -
Emit for EVERY st-launched session, including the default network — deliberately unlike the
ST_ROOTenv line (which omits itself on the default network to avoid freezing today's default into tomorrow's restarts). The tag is a pure inspection label, so uniformity means the presence-check is the signal. -
Key spelled exactly
st.network(pty-claude's choice — visible, non-reserved in../pty/src/tags.ts:EXACT_RESERVED). The dot is inside a quoted TOML inline-table key ("st.network" = "...") so it's not interpreted as a dotted nested-table key. Verified live against smol-toml parsing. -
Matched-pair invariant: same value in the agent block and the ding block. A launch is one network; the sidecar carries the same tag as the main session. Matches the same "agent + ding stay in sync" invariant the
agentStrategymirror enforces.
3 new unit tests cover:
- Isolated launch (explicit
ST_ROOT) → both agent + ding blocks tagged with the same value. - Default launch (no env) → still tagged (uniformity regression
guard), with
ST_ROOTenv line still absent (asymmetry preserved). - Claude launch (no ding sidecar) → single-block launch still tagged, agent-only.
Existing 5 tag-shape assertions updated from strict-form
toContain to regex toMatch — the new emitted form includes
the network tag suffix, and the regexes lock in the "no strategy
between role and st.network" semantic for the ephemeral case AND
the "strategy = permanent between role and st.network" semantic
for the permanent case. Full suite: 1534 pass.
evals-claude's end-to-end confirmation run of the Johannes stack
caught a SECOND --ding bug that #62's startup-grace was
masking: the ding sidecar was addressing the WRONG pty session
name → every [DING] poke returned Session "<x>" not found
→ delivery was silently broken even though survival looked
healthy.
Root cause: buildPtyToml composed the ding sidecar command as
st ding <sessionName> --identity <identity>
using the BARE sessionName — same value that goes into the
[sessions.<sessionName>] TOML block. But pty joins prefix + sessionName with a dash (see ../pty/src/ptyfile.ts:58, the
same convention the F1 auto-poker dash-fix landed on), so the
FQN pty session name is <identity>-<sessionName>. Addressing
the bare form silently mis-hits every poke.
Example failure mode: st launch claude --identity cos --ding
→ pty.toml wrote st ding claude --identity cos, but the actual
pty session key was cos-claude. Every poke pty send "claude"
returned "Session not found".
#62 masked it perfectly: the startup-grace waited for the bare name to appear, which never happens (real name is prefixed), so the sidecar survived forever LOOKING healthy while delivering nothing.
Fix: one-line change at the ding-command build site — target
${opts.identity}-${opts.sessionName} instead of bare
${opts.sessionName}. Matches the F1 auto-poker at the same
function (which already uses the FQN form, correctly, since the
dash-fix landed).
Class audit: this is the SECOND session-name-addressing bug in
launch.ts (F1 poker slash-vs-dash was the first). Grepped all
send-target sites: only the ding command line had the bug — the
poker uses the FQN form, and the [sessions.NAME] blocks are
structural declarations (pty prepends the prefix automatically).
src/commands/ding.ts sends whatever it's told; the bug was
purely in what launch.ts passed as the CLI arg.
- Positive regression guard: existing
st ding <target>assertions updated to the FQN form. Every claude ding-mode + codex launch now asserts the identity-prefixed target. - Negative regression guard: the bare form is explicitly
negated with
.not.toMatchso a future refactor can't revert to the mis-addressed shape. - New scenario test covering the eval case cos cited:
identity
dm-dev+ custom--session-name→ ding target must be the identity-prefixed form, not the bare sessionName.
Full suite: 1531 pass, only the 4 pre-existing integration flakes.
Unblocks:
- Johannes's ding-mode CoS delivery end-to-end (#62 unmasked this; now delivery actually works).
- The eval suite's ding-mode delivery (evals-claude's confirmation re-run can now truly close the loop).
evals-claude's live ding-mode run caught a critical defect that
was blocking Johannes's CoS AND the whole eval suite's
ding-mode: the st ding sidecar was dying at launch, never
delivering a single [DING] poke, and — being ephemeral (per
the #54 fix) — never restarting. Ding-mode agents booted, found
empty inboxes, and idled forever ("watching for the [DING]"
while delegations sat unread).
Root cause: the session-watch tick's default --exit-when- session-gone behavior fired on the FIRST tick, which typically
races AHEAD of pty's registration of the target agent session.
The tick saw "target gone" → aborted → daemon exited.
Fix (cos-approved semantic): startup grace — only trip the exit-when-gone path AFTER the ding has seen the target alive at least once. A not-yet-appeared target is treated as "still launching", not "gone", and doesn't trigger exit.
- New internal
seenTargetAliveflag onrunSessionWatchTick, false at daemon start. Flips to true on the firstalive === trueobservation. The exit branch is now gated on bothalive === falseANDseenTargetAlive === true. - Startup-grace log line ("target session … not yet registered;
waiting for it to appear before enabling the exit-when-gone
watch") fires ONCE, gated by a
loggedWaitingForTargetflag — no per-tick spam. - Once the grace is cleared (target became alive), the exit path works as before — a REAL "session ended" transition still aborts. The grace is a startup shield, not a persistent one.
Test coverage:
- Startup-grace test: session is dead from launch → daemon
waits, does NOT exit. Regression guard on both the log
(waiting-line fires exactly once, no per-tick spam) and the
daemon lifecycle (
r.donehas not resolved after several ticks). - Grace-clears test: dead → alive → dead still exits — proves the grace is transient and doesn't mask real session-ended signals.
- Existing 4 session-watch tests still pass unchanged (the "session goes away" case uses a scenario where alive=true is observed BEFORE flipping to false, so the grace clears correctly).
No API changes. No new flags. Behavior automatic for every
st ding daemon.
Johannes-blocking; unblocks ding-mode delivery end-to-end.
Final step of the Johannes stack. Ding-mode agents have no MCP
transport → no MCP instructions: blurb (the boot ritual /
channel-notification-handling / tools-inventory contract every
MCP agent gets on connect). Without an equivalent, a bare
ding-mode CoS launches without knowing the CLI flow, how to
handle [DING] pokes, or the "threads stay on the bus"
convention. This close makes ding-only genuinely first-class,
not degraded.
Fix (cos-approved option 2):
-
DING_BUS_INSTRUCTIONSexported constant inlaunch.ts— the ding-mode analog ofsrc/mcp/capabilities.ts:CHANNEL_INSTRUCTIONS. Content structured to mirror MCP's shape: boot ritual →[DING]-poke handling → threads-stay-on-bus convention → CLI inventory. Explicitly notes ding-mode agents will NOT receive<channel>blocks (per cos's refinement — an agent shouldn't wait for something that never comes). -
installDingBusInstructions()mirrorsinstallPersona(): writes<cwd>/DING-BUS.md, surgically appends@DING-BUS.mdtoCLAUDE.md(creates the file if absent; leaves a pre-existing repoCLAUDE.mdin tracking), addsDING-BUS.mdto.git/info/exclude. Idempotent on re-run. -
Auto-installs with
--dingon the claude harness — ding-mode is claude-only (codex has its own instructions path).LaunchResult.dingBus: DingBusInstallResult | nullfor observability + a newding-bus:block in the--dry-runsummary. -
Cross-reference comment on both
CHANNEL_INSTRUCTIONS(src/mcp/capabilities.ts) andDING_BUS_INSTRUCTIONS(src/commands/launch.ts) marking them as "two versions of one bus contract — keep in sync when the contract changes." Drift means MCP agents and ding-mode agents will behave differently for the same protocol event. -
notes/onboarding.mdsection 2 gains an "Alternative:--ding(MCP-hostile environments)" subsection covering when and why to use it, the full launch command, and what auto-installs on top of the persona.
9 new unit tests cover: no---ding case leaves dingBus null;
--ding populates the result on dry-run; live-run writes
DING-BUS.md with the load-bearing substrings + confirms no smalltalk
leak; @DING-BUS.md composes with @PERSONA.md; git-exclude
happens; codex + --ding is a no-op on the install path;
missing-git-repo warning fires + DING-BUS.md still installs;
idempotent re-run doesn't duplicate the import line; CLI
--dry-run summary shows the ding-bus: block.
Two st ding delivery paths — the inbox-arrival notice and the
tidy-check drift summary — now prepend [DING] and refer to
"smalltalk message" / "tidy-check" (not "st message" / "smalltalk
tidy-check").
Rationale (per Nathan):
- The prefix marks the line as bus traffic, visually distinct
from three other things an agent might see in its terminal:
MCP
<channel source="…">blocks (MCP path only), the agent's own REPL output, or a human typing at the REPL. - The prefix lets a ding-mode agent's persona (and the upcoming
DING-BUS.mdblurb) reference an unambiguous string pattern in the poke-handling flow ("when you see[DING] X, do Y"). smalltalk/stnaming aligns with the CLI (st message …) the agent uses to act on the notification.smalltalkin a first-impression, user-visible line was a rename leftover.
Concretely:
you have a new st message: <subject> (from <sender>); check your inbox→[DING] new smalltalk message: <subject> (from <sender>); check your inboxsmalltalk tidy-check: inbox=<n> (oldest <age>).→[DING] tidy-check: inbox=<n> (oldest <age>).
Test coverage:
- Existing 8 assertion sites updated across
tests/unit/ding.test.tsandtests/integration/ding.test.tsto the new form. - New positive regression guards:
[DING]prefix required, legacyst message/smalltalk tidy-checkforms negated — so a future refactor can't revert to the un-prefixed + legacy-named form.
Internal daemon log lines (st ding: pty send failed: …,
etc.) are unchanged — those are stderr for operators, not user-
visible bus traffic. Cosmetic rename can happen alongside a
broader daemon-log cleanup later.
Historic behavior: when resolveClaudeHooksDir() returned null
(bin/shim not resolvable OR examples/claude-code/hooks/ not on
disk OR explicit --hooksDir override missing), the call site
emitted a single line — [smalltalk launch] shipped Claude Code hooks not found on disk; skipping .claude/settings.local.json —
and the launch proceeded HOOKLESS. Silent-ish → an operator
missed it → Johannes's claude came up without the boot ritual,
PreCompact flush, or StopFailure ding, exactly the silent-install-
gap class of bug the bin/smalltalk rename fix already surfaced.
Two changes:
-
resolveClaudeHooksDirWithHint()(new; discriminated variant ofresolveClaudeHooksDir()) categorizes the failure into distinct modes and returns ahintstring quoting the path(s) that were inspected and how to fix each mode:- bin/shim resolution failure → names the package.json walk +
which st/which smalltalkPATH lookup failure. examples/claude-code/hooks/missing → quotes the walked-to root, suggestsnpm install && npm linkfrom the checkout, or--no-hooksto acknowledge intentionally.- Explicit
--hooksDiroverride missing → names the specific path passed.
- bin/shim resolution failure → names the package.json walk +
-
Multi-line LOUD stderr banner at the call site. Replaces the historic one-line notice with a bracketed block naming what's disabled (boot ritual, PreCompact flush, StopFailure ding), the specific failure hint, and how to fix it or silence intentionally with
--no-hooks. Launch still proceeds (hookless is degraded, not fatal) but the operator gets a signal they can't miss on scroll-back.
resolveClaudeHooksDir() retained as a thin wrapper around the
new function for back-compat with test/embedder callers.
Test regression guards:
- Historic silent one-line message form is negated (
.not.toMatch) so a future refactor can't revert to it. - The banner + the actionable "why" line quote the missing path.
- The
--no-hooksescape hatch is called out in the guidance. - New unit test for
resolveClaudeHooksDirWithHint()happy path in the repo checkout returns real hooks dir + null hint.
Also negates the specific override failure form vs. the auto- resolution form so a diagnostic-message copy/paste regression is caught.
Load-bearing for Johannes's setup, where MCP servers can't run
at all. Without this flag st launch claude requires a working
MCP transport (.mcp.json + --dangerously-load-development- channels); with --ding, the same claude agent joins the
network the way codex does — via an st ding sidecar delivering
inbox pings + the st CLI for bus ops (send/ls/read/archive/
reply). No MCP.
What --ding changes on a claude launch:
- Skips
cmdInit— no.mcp.jsonwritten. The MCP-hostile environment couldn't spin up the server anyway. - Forces
channel = false— no--dangerously-load-development- channels server:stin the argv (there's nothing to load). - Adds the
st dingsidecar to the generatedpty.toml, same shape asst launch codex.st ding <sess> --identity <id>watches the identity's inbox andpty sends notifications into the agent's terminal. - Hooks stay generated — the boot ritual, PreCompact flush, and StopFailure ding are Claude Code hooks, MCP-independent.
Stale .mcp.json from a prior MCP-mode launch triggers an
advisory stderr warning ("Delete it (rm .mcp.json) if this was a
switch from MCP-mode to ding-mode") — Claude Code will still
read a stale file even in ding-mode, so cleanup is on the
operator.
--ding on codex is a no-op (codex is already ding-mode by
default — addDingSidecar fires unconditionally for that
harness). The flag exists on the codex path for shell-history
symmetry; the ding sidecar count guard test locks in "no double-
write".
9 new unit tests cover: default (no --ding) preserves MCP wiring
(byte-identical regression guard); --ding claude → no channel
flag + ding sidecar; --ding claude on a live run doesn't write
.mcp.json; non-ding claude still writes .mcp.json (scoped-
skip regression guard); stale .mcp.json advisory warning;
--ding codex no-op (single ding session, not double-written);
hooks still generate under --ding; CLI plumbing + dry-run
summary (both branches).
Nathan's 3-tier permission model, closing Johannes's pty.toml Bug 1:
- cos + supervisor are spawners → default
bypassPermissionswhen neither--permission-modenor$CLAUDE_PERMISSION_MODEis set (wasauto, which claude's classifier hard-blocks from spawning autonomous agents — the exact regression Johannes hit). - workers (any other identity + no spawner persona) → default
auto.autois correct + safe for a leaf agent that does work but doesn't spawn.
Spawner detection matches previous PR's CoS-shape (identity === 'cos' OR persona basename === 'chief-of-staff.md') extended
with identity === 'supervisor' OR persona basename ===
'supervisor.md'. Both persona files live at
https://github.com/myobie/personas (HEAD b8a2cc3).
Also extended the previous PR's footgun-guard warning:
- Fires for both
cosANDsupervisor(was CoS-only). - Message updated:
"launching a spawner (cos/supervisor) without --permanent"(was"launching a CoS"). - Ephemeral eval spawners intentionally decline
--permanent(they need to be reap-able on teardown) — the warning is opt-in acknowledgment, not a hard block.
Deliberate asymmetry (per Nathan): the permission-mode default
flip is safe (spawners need bypass, evals want bypass), but
permanent stays opt-in / warn-only — evals launch --identity supervisor agents that MUST stay ephemeral.
Precedence at the CLI: --permission-mode <mode> >
$CLAUDE_PERMISSION_MODE env > shape-aware default (spawner →
bypass, worker → auto). Existing callers with explicit flags or
env set are byte-identical.
notes/onboarding.md updated: CoS launch command in step 2 adds
--permission-mode bypassPermissions for pattern-teaching (works
either way — the default now covers it, but the explicit flag
teaches the pattern you'll want on your own supervisor launches).
Resume recipe + the aliased-binary example also updated.
11 new unit tests cover: cos + supervisor identity defaults, both
persona basenames as detection triggers, worker stays on auto,
explicit flag overrides the spawner-default, $CLAUDE_PERMISSION_MODE
overrides too, supervisor warning fires + worker doesn't, and
supervisor + --permanent = no warning but still bypass.
Two rename leftovers from the smalltalk→st cutover that were producing config files pointing at the legacy shim name:
-
.mcp.json's MCP server command:st init(and thest launchpath that fans out through it) hadresolveStBinPath()returning<package-root>/bin/smalltalk, which then landed as the.mcp.jsonstentry'scommand:on every fresh launch. Worked (bin/smalltalk is a dual alias for bin/st) but perpetuated the drift — the day the smalltalk alias is dropped would break every generated.mcp.json. -
pty.toml's ding sidecar command line:st launch codexemittedcommand = "st ding <sess> --identity <id>"for the ding session. Same dual-alias mechanic; same drift risk.
Both fixes:
resolveStBinPath()now walks for and prefers<pkg>/bin/st, falling back to<pkg>/bin/smalltalkonly for very-old installs where the newer shim isn't present. PATH fallback iswhich stfirst, thenwhich smalltalk. Function name kept for now (callers- are-me-only; a rename is scope-creep on the fix).buildPtyToml's codex-ding line changed tost ding <sess> --identity <id>.- Small doc + comment updates in
launch.ts(st ding sidecar→st ding sidecarin LAUNCH_HELP + the module-level comment).
Test regression guards added:
- init.test.ts asserts
resolveStBinPath()returns.../bin/stAND explicitly negates.../bin/smalltalk. - launch.test.ts asserts the codex ding preview contains
st dingAND explicitly negatesst ding.
Users regenerate stale files by removing them (rm .mcp.json pty.toml) and re-running the corresponding launch. Existing
.mcp.json / pty.toml files with the legacy names keep working
until the smalltalk alias is dropped; no forced migration.
Closes the CoS-permanence gap the previous PR (ephemeral-ding fix)
surfaced: st launch claude --identity cos produced an agent with
tags = { role = "agent" } — no strategy tag — which pty treats
as its ephemeral default. pty gc would reap the CoS along with
any idle session. That's a silent, non-obvious failure mode for
the always-on center a newcomer just followed the onboarding docs
to stand up.
Fix:
-
--permanentCLI flag onst launch. SetsagentStrategy = "permanent"on the buildPtyToml call, which the previous PR's future-proofing hook plumbs into BOTH the agent session AND (for codex) the ding sidecar. A launch is either permanent or it isn't; no mixed-strategy pty.toml. pty seesstrategy = "permanent"and treats the session as durable (see../pty/src/sessions.ts:576, 620, 644). -
Footgun-guard warning. When a CoS-shaped launch omits
--permanent, stderr gets:[smalltalk launch] launching a CoS without --permanent; pty gc may reap it under idle-cleanup. If this is a production CoS (not an eval/test spin-up), pass --permanent so the launch bakes strategy = "permanent" into pty.toml.Detection is narrow:
identity === 'cos'OR the persona basename ischief-of-staff.md. Ephemeral eval launches, workers, specialists get no warning. -
LaunchResult.permanent: booleanfor observability; newpermanent: yes|noline in the--dry-runsummary. -
notes/onboarding.mdupdated. CoS launch command in step 2 now includes--permanent. Resume recipe includes--permanent. Troubleshooting entries added for the warning and the reap-able-CoS failure mode. -
LAUNCH_HELPdocuments the flag; a new example line demonstrates the full production-CoS invocation (--identity cos --permanent --persona chief-of-staff.md).
9 new unit tests cover: default no-tag/no-strategy, --permanent on
agent, --permanent on codex matches ding (invariant guard), CoS
footgun-guard by identity, CoS footgun-guard by persona basename,
--permanent silences the warning, non-CoS identity gets no
warning, CLI --permanent threads into dry-run summary, CLI
default reports permanent: no.
Historic behavior: buildPtyToml hardcoded
tags = { role = "ding", strategy = "permanent" } on the codex
ding sidecar while the agent session itself carried no strategy
tag — a mismatch that meant pty gc after an ephemeral codex run
died would resurrect the ding as a zombie, exactly what the
ephemeral-eval rule forbids. Blocked the 4 codex-cell eval
retrofits.
Verified against ../pty/src/sessions.ts:576, 620, 644 that pty
only checks strategy === "permanent" explicitly — absence of the
tag is pty's ephemeral default. So the fix is to drop the
hardcoded permanent on the ding: tags = { role = "ding" }. Now
both the agent AND the ding are ephemeral by default; pty gc
reaps them together on eval teardown.
Future-proofing hook: buildPtyToml grew an
agentStrategy?: string | undefined opts field. When set, the
value is mirrored to BOTH the agent and ding tags — a launch is
either ephemeral or it isn't; no more mixed-strategy pty.toml. No
CLI flag exposes this yet; a follow-up --permanent (for a
production CoS that needs to survive pty gc) will plumb through
here.
Impact:
- Codex ephemeral evals: unblocked. Ding no longer zombies.
- CoS use case: unaffected. CoS runs the claude harness with no
ding sidecar (see
addDingSidecar: harness === 'codex'). - Codex users who relied on ding-permanence to survive a codex
restart: behavior changes — a
pty gcafter codex dies now reaps the ding.pty upbrings both back. Note: pty gc would ALSO have zombied that ding when the whole session tree died, so the current permanent tag was arguably a bug either way.
Test coverage:
- Historic assertion
expect(preview).toContain('strategy = "permanent"')→ negative assertion (regression guard). - New positive assertion: ding tags are exactly
{ role = "ding" }. - New positive assertion: agent tags remain
{ role = "agent" }(regression guard for the historic bare-agent shape).
The CoS quickstart's step 2 said st launch claude --identity cos
— which produces a bare Claude Code agent that has no idea it's a
CoS. Step 3 then said "the CoS consumes the personas repo on boot"
— but nothing in the actual launch command told the agent to do
that, so the docs described a bootstrap that doesn't self-start.
Follow the recipe literally and you get generic Claude in a
folder.
Fix per Nathan's framing — you make the repo, THEN launch the CoS IN it:
- Step 1 (Install) — extends the side-by-side clone list to
include
myobie/personasalongsideptyandsmalltalk, so the persona file is on disk before step 2 needs it. - Step 2 (Bring up your CoS) — makes the ordering unambiguous
(mkdir the cos folder →
git init→ launch INSIDE it) and adds the load-bearing--persona ~/src/github.com/myobie/personas/chief-of-staff.mdargument. Documents whatst launch --personaactually does (copy toPERSONA.md, wire@PERSONA.mdintoCLAUDE.md, git-exclude both). Notes the aliased-binary--agentform still applies alongside--persona. - Step 3 (renamed "What the CoS does on boot (from the
persona)") — reconciled with step 2's persona install.
Describes the persona's actual bootstrap: read
first-run-interview.mdon a fresh network; consult sibling personas as reference when spinning up peers; own the private cos repo state. Clarifies personas is READ-only reference; the cos repo is the writable per-user state. - Step 5 (Resume recipe) — includes
--personaon the resume command; notes re-passing is safe (overwritesPERSONA.mdwith same bytes; the@PERSONA.mdline inCLAUDE.mdis idempotent). - Troubleshooting — adds two entries: "CoS boots as generic
Claude" (probably launched without
--persona; verify PERSONA.md- CLAUDE.md wiring) and "
--personapath didn't resolve" (verify the personas clone in step 1).
- CLAUDE.md wiring) and "
Verified against the actual code: st launch --persona <path>
copies to <cwd>/PERSONA.md, creates or edits CLAUDE.md with a
@PERSONA.md import, and git-excludes both. Verified against
https://github.com/myobie/personas that chief-of-staff.md exists
at the repo root and instructs the fresh agent to run
first-run-interview.md before anything else.
Historic behavior: the generated pty.toml's [sessions.*.env]
carried only ST_AGENT. Isolation of an st-launched agent to a
non-default state root (ST_ROOT=/tmp/eval-scratch/state st launch …) worked on first boot via env inheritance (invoker → st launch
→ pty up → session), but a later pty up / pty restart /
pty gc resurrection from a shell without ST_ROOT exported
silently fell back to the live default (~/.local/state/smalltalk).
Real isolation hole for anyone running an isolated tree —
eval-cell live proof: an st-launched agent's status write landed
in the isolated root the first time; a manual restart would have
landed in the live bus.
Fix: when the invoker had ST_ROOT (or legacy ST_ROOT)
explicitly set, bake the resolved absolute path into every
[sessions.*.env] block as ST_ROOT — main session + codex ding
sidecar. Default-path launches leave the env unset so pty.toml
doesn't freeze today's default into tomorrow's restarts.
Scope kept minimal:
- Only
ST_ROOTpropagates; identity is already baked asST_AGENT, and both hook scripts and CLI code use the resolution chain that prefers ST_AGENT → ST_IDENTITY → ST_IDENTITY, so the legacy aliases don't need explicit baking. - No
--env KEY=VALpassthrough (deferred). Auto-propagate covers the flagged isolation case; a generic passthrough is a small follow-up if the need surfaces. - 4 new unit tests cover: no-invoker-env (nothing baked), ST_ROOT set (baked), legacy ST_ROOT set (canonicalized to ST_ROOT), codex ding sidecar (both env blocks pinned).
Unblocks eval cells retiring their bespoke launchers for real
st launch.
Historic behavior: on startChannelWatcher, an initial
readdirSync(inboxDir) seeded every existing filename into the
seen dedup set — a suppression policy that assumed the boot
ritual's st_msg_ls would recover any backlog. Live repro
during the P5 team-standup re-run showed the policy is too
aggressive: when Claude Code reconnects the MCP stdio transport
mid-session (the current server process exits on mcp.server. onclose, Claude Code respawns a fresh one), any messages that
landed in the inbox during the down window get seeded into the
new process's seen and are never emitted as channel
notifications. The polling backstop (brief-020 HB-4) can't rescue
because it dedups against the same seen set, and asyncRewake
doesn't force a turn when the agent's actively working — so a live
CoS session watched 5 messages arrive in inbox with zero
<channel> blocks injected. Cos hit this live.
Now the initial scan enqueues those files instead of seeding
them. Each fires exactly once per process, in chronological order
(<unix-ms>-<rand6>.md filenames lex-sort chronologically; rand6
breaks ties deterministically). Duplicates across a process
restart are the accepted tradeoff — per Nathan: "duplicates don't
matter to me. We want at-least-once, not at-most-once." Agent
re-reads + archives are harmless; lazy-read sweep clears the
byte-identical inbox twin on the next read.
- Test
mcp-channel-watcher.test.ts:428(previously "files already present at startup DO NOT get replayed") inverts to "files present at startup ARE delivered on watcher start (P5R-F2 fix)" — asserts three planted files arrive in chronological order and that the polling backstop tick does NOT re-fire them. - No safety valve on backlog size. Inboxes stay small in practice (boot ritual drains them). If big-backlog flood bites later, cap is a small follow-up.
Operator-visible: on a mid-session MCP reconnect, expect to see
<channel> blocks for anything that arrived during the down
window — including messages you may have already read via
st_msg_ls. The dup is easy to spot (same filename); archive
as normal.
F1's --unattended auto-poker (#47) built its target as
${identity}/${sessionName} (slash), but pty joins prefix + session
name with a DASH — see ../pty/src/ptyfile.ts:58:
defaultDisplayName = prefix ? '${prefix}-${rawName}' : rawName.
Every pty send returned Session "<x>/<y>" not found, no poke
fired, and every CoS-spawned worker deadlocked at the
--dangerously-load-development-channels gate.
Surfaced by the P5 team-standup re-run — the eval that verified F1
also caught F1's own bug. A hand-written dash-form poke
(pty send taskflow-dev-claude) cleared the gate instantly,
proving the fix scope.
One-line fix: switch the poker target from
${identity}/${sessionName} to ${identity}-${sessionName}.
Now st launch --unattended gives truly 0-poke CoS-driven worker
standup. Existing F1 tests updated + a negative assertion added
(the slash form must NOT appear).
Historic behavior: st launch shelled out to pty up with
stdio: 'inherit' and awaited exit. pty up blocks ~47s+ waiting
on its interactive attach ("ctrl+b to run in background") — an
attended operator can read that and act, but a CoS spawning a
specialist under spawn() misreads the block as a hang.
Now: when stdinIsTty() reports false (a CoS via spawn(),
headless CI, etc.), st launch spawns pty up detached with
stdio: 'ignore', unref's the child, and returns immediately.
Attended launches (real TTY) keep the current inherit-and-wait
behavior so the operator still sees pty up's UI.
Auto-detected via the same stdinIsTty signal F1's --unattended
poker uses, so hands-off standup and non-blocking pty up flip on
together for CoS-spawned specialists.
Every fresh Claude Code session prompts "Enable the st MCP
server?" the first time it sees a project .mcp.json — a hands-off
CoS spawning a specialist under spawn() had nobody at the REPL
to answer that. Prior generated .claude/settings.local.json
omitted the pre-approval fields, so the specialist wedged.
buildClaudeSettings now emits both docs-confirmed fields:
enableAllProjectMcpServers: true— blanket-approves any server in the project.mcp.json.enabledMcpjsonServers: ["st"]— pins the specific entry so a future.mcp.jsonedit that adds an unrelated server still requires an explicit approval. Belt-and-braces alongside the blanket allow.
Verified in the Claude Code settings docs
(https://code.claude.com/docs/en/settings) — both fields are
load-bearing. The folder-trust dialog is handled by F1's
--unattended poker (same Enter). This closes F2 of the
P5-eval-surfaced unattended-standup fix set.
st init (and the st launch .mcp.json path that fans out
through it) also renamed the emitted mcpServers key from smalltalk
to st. The on-disk .mcp.json state was already st across
swept repos, so launch was drifting: it wrote smalltalk: into freshly-
generated .mcp.json files while every peer file was st:.
enabledMcpjsonServers above matches the new key, so a new agent
comes up with a consistent st-branded MCP wiring end-to-end.
Every freshly-launched Claude Code session stalls at up to three
first-launch TUI dialogs — workspace trust, the
--dangerously-load-development-channels warning, and (on resume)
the resume-mode picker — each dismissed with a single Enter. Prior
to this, a CoS spawning a specialist under spawn() had nobody at
the REPL to press Enter, so the specialist wedged.
st launch --unattended bakes a startup auto-poker into the pty
session command:
(sleep 4 && pty send <id>/<sess> --seq key:return; sleep 4 && ...; sleep 4 && ...; sleep 4 && ...) & exec <claude ...>
Four pokes with 4s spacing — enough margin for a slow box, extras
past the last dialog land as empty submissions at the model prompt
(no-ops). The target is the fully-qualified pty session name
established by the identity-prefix fix (<identity>/<sessionName>).
Auto-on when stdin is not a TTY — a CoS shelling out via
spawn() gets hands-off standup with no flag to remember. Explicit
--attended is the escape hatch for headless debug runs.
Only affects the claude harness on the pty path. Codex has no
dev-channels gate; --no-pty has no session to send to. Ignored in
both cases even when --unattended is explicit, so callers can
pass the flag generically.
Lands LaunchResult.unattended: boolean for observability + a new
unattended: yes|no line in the --dry-run summary.
Historic behavior: the generated pty.toml used the cwd basename as
the pty session prefix, so an agent with identity taskflow-dev
launched from taskflow/ produced pty sessions namespaced under
taskflow/…. That drifted the pty session away from the identity —
a generic shepherd or pty send targeting the identity couldn't
back it up — and left two clones of the same repo (running under
different identities) racing for the same <basename>/claude
session key in pty's global namespace.
Now the prefix is the identity itself. st launch --identity taskflow-dev under any cwd produces pty.toml with prefix = "taskflow-dev", so the full pty session is
taskflow-dev/claude — matches the identity, unique by
construction, uncollidable across clones.
resolveRepoPrefix(cwd) (the old derivation) was the only caller of
that helper, so it's gone too. --session-name <name> still
overrides the session key within the identity namespace when a
user wants a different shape.
st-evals published at https://github.com/myobie/st-evals — a
capability-gated hermetic smoke suite. Onboarding step 4's
readiness placeholder now names the real invocation
(bin/st-evals readiness, run from the cloned st-evals repo root),
plus the two auxiliary probes (preflight, list) and the
PERSONAS_DIR / bin/ensure-personas.sh hook for offline mirroring.
Full rewrite of notes/onboarding.md. Previously walked a
7-step manual bus-provisioning recipe (identity, folder, message
round-trip, MCP init, sync) with no mention of st launch. Now
leads with the Chief-of-Staff quickstart:
- Install smalltalk + pty side-by-side;
npm install && npm link; note the Claude Code hook install path. st launch claude --identity cosin a directory that will become your private cos repo.--agent <name>for aliased claude binaries (Johannes-stylecl1,cl2).- The CoS itself consumes https://github.com/myobie/personas (SHA-pinned) for its role contract on first boot.
- First run: the CoS runs an interview (identity/repos/priorities/
team/channels → writes the private cos repo) then a readiness
check via
st-evals. The exactst-evalsinvocation is deliberately not hardcoded (st-evals is still being built). - Operating — talk to your CoS.
The zero-to-first-message bus basics (identity, folder, message
round-trip, st init, multi-machine sync) live as a lower-level
"Bus basics" appendix for tooling authors and non-CoS agents.
Updated naming block, prerequisites, and troubleshooting to match
the post-#41/#42/#43 state — st canonical, st --version
available, .claude/settings.local.json commands bake ST_BIN.
The three shipped Claude Code hooks (pre-compact.sh, stop-failure.sh;
session-start.sh doesn't shell out) previously relied on PATH lookup
of smalltalk at hook-execution time. That's fragile: if PATH is degenerate
in Claude Code's hook-exec env, or if a stale smalltalk from a different
install is on PATH, the hook silently uses the wrong binary — or none.
Now:
- Hook scripts resolve the CLI via
${ST_BIN:-$(command -v st || command -v smalltalk)}. Prefers an absolute path injected byst launch; falls back to PATH lookup (st-first, smalltalk for back-compat) for hand-wired settings.local.json files. st launchbakes the absolute path viaresolveStBinPath(resolveStBinPath())and injects it asST_BIN=<abs>in each hook'scommand:string. Claude Code runs shell-form commands viash -c, so the POSIX assignment-preceded simple command form applies. Verified against https://code.claude.com/docs/en/hooks — the settings.json schema has no per-hookenv:field, but shell-form command strings are the intended surface for this.- New
LaunchInput.stBinForHooks?: string | null | undefinedtest seam parallel tohooksDir: passnullto skip the injection (bare script paths — proves the hook's PATH-fallback branch); pass a synthetic string to pin a deterministic snapshot; omit for the auto-resolution behavior.
Anti-impersonation posture unchanged. No new spawn surface — the
hook already spawns smalltalk, we just bake the exact binary.
<name> --version prints <invokedName> <semver>\n (e.g. st 0.3.0)
and exits 0. Follows the same brand-per-name convention as the help
banners — smalltalk --version prints smalltalk 0.3.0, smalltalk --version
prints smalltalk 0.3.0. The semver is read from package.json at
runtime, so it stays in sync without a build-time constant.
- New
versionString(env)helper insrc/cli.tsresolvespackage.jsonviafileURLToPath(import.meta.url)so it works undernpm link(where the source lives elsewhere on disk). - Top-level help banner now names both flags:
usage: <name> --help | --version. - Only
--versionis honored — no-v(traditionally means verbose), no bareversionsubcommand (would shadow any user'sst-versionplugin).
Every usage: … banner and every top-level <name>: <message> error
prefix now uses the name the user typed (st, smalltalk, or the
back-compat smalltalk), sourced from the _ST_INVOKED_AS env var that
the bin/ shims already export. Previously every banner said
usage: smalltalk …, which read as un-renamed even when the user had
typed st.
- New helper
invokedName(env)insrc/cli-context.tsreadsenv._ST_INVOKED_AS, defaulting tostwhen unset (fresh dev shells, unit tests, directnode src/cli.tsinvocations). - Threaded through the top-level
runClibanners, the message-group banner, every subcommand's--helpstring, and the two top-level error prefixes (unknown subcommand: …, generic<name>: <msg>). - No shim or hook changes needed —
bin/st,bin/smalltalk, andbin/smalltalkalready set_ST_INVOKED_ASfrombasename $0before exec'ing into node.
Users who still type smalltalk … see usage: smalltalk … (their existing
behavior). Users who type st … see usage: st … — closing the
"still says smalltalk" gap that made the rename feel unfinished.
The onboarding walkthrough tells newcomers to run
st status <self> --set available as their first command. Pre-fix,
this threw agent folder missing for <self> because the explicit
<agent> positional went through the strict folder-existence check.
Now the --set path lazy-creates <agent>/{inbox,archive} if
missing, matching what the docs already promised.
- Adds a new
'lazy-create'policy toresolveAgentalongside the existing'lenient'. When set, an explicit-identity call creates the folder inline instead of failing. cmdStatususes the new policy iffsetState !== undefined— passivest status <peer>(get) still requires the folder to exist sost status ghostdoesn't silently materialize a phantom.- Environment-resolved calls (
ST_AGENT=alice st status --set …) already lazy-created via the implicit-bootstrap path; no change.
Anti-impersonation posture: the --set write goes to
<agent>/status, not another agent's inbox. Any local caller could
already mkdir $ST_ROOT/<other>/{inbox,archive} + echo state > …
by hand — the convenience of first-command bootstrap outweighs the
marginal loss.
st launch claude now invokes a configurable binary as the harness
executable, so hosts running aliased builds (cl1, cl2,
claude-preview, etc.) don't have to symlink claude. Resolution
precedence: --agent <name> flag > $AGENT env > 'claude' default.
- The alias is threaded through BOTH the bootstrap argv
(
<agent> --print --session-id …) and the main argv (<agent> --resume …), and baked into the generatedpty.tomlcommand = "…"line so the pty-spawned process invokes the alias too. No callers see a byte-level change when neither flag nor env is set — the'claude'default is preserved. - The pty
session-namestays independent — it defaults to the harness kind (claude) so the pty layout stays consistent even when the underlying binary iscl1. Override via--session-name <name>if you want a different session key. - Codex launches ignore this (codex has its own launcher). The resolution still populates the dry-run summary for consistency, but the codex argv is untouched.
Only affects the claude harness. Empty --agent "" falls through
to $AGENT, then to the default (matching how --permission-mode ""
already behaves).
st launch <harness> now optionally installs a persona alongside the
harness bootstrap. Given --persona <path>:
- Copies the source file to
<cwd>/PERSONA.md. - Surgically edits the harness entry file —
CLAUDE.md(claude) orAGENTS.md(codex) — to append@PERSONA.mdon its own line. If the file already exists, its content is preserved byte-for-byte; only the import line is added, and only when not already present. - Git-excludes
PERSONA.md,.mcp.json,.claude-session-id,.codex-session-id,pty.toml, and the entry file (only when we created it) via.git/info/exclude. A pre-existing repo CLAUDE.md is never added to the ignore list. --dry-runprints the exact plan (copy target, entry-file action, git-exclude entries) without touching disk.
Verified 2026-07-02 that codex 0.142.4 honors @PERSONA.md in
AGENTS.md (empirical test: agent replied with persona-defined value
when asked; directory listing in the transcript showed AGENTS.md and
PERSONA.md were both read). Both harnesses use the same mechanism —
same code path in launch.ts, only the entry-file name differs.
12 new unit tests cover: copy target, entry-file create-vs-append, idempotence (re-append is a no-op), trailing-newline-less existing files, git-exclude entries + dedup, entry-file exclusion decision (never for pre-existing files), non-git-repo warning, missing source error, and dry-run planning.
Testable rewriters + a directory-walking driver for the per-machine
step of the smalltalk→st cutover. All pure functions on the file text
(idempotent by construction) with a thin CLI driver that walks disk
and writes back atomically (tmp+rename, <name>.pre-cutover
backups on first change).
tools/cutover/rewrite-mcp-json.ts— pure rewriter for.mcp.json. RenamesmcpServers.smalltalk→mcpServers.st, flips every legalsmalltalk-bin path suffix (myobie/smalltalk/bin/smalltalk,myobie/smalltalk/bin/smalltalk,myobie/smalltalk/bin/st,myobie/smalltalk/bin/smalltalk) tomyobie/smalltalk/bin/st, renamesenv.ST_IDENTITY→env.ST_AGENT(drops whenST_AGENTalready set), same forST_ROOT/ST_CONFIG.tools/cutover/rewrite-pty-toml.ts— pure rewriter forpty.toml. Line-based sweep (no full TOML round-trip → preserves formatting, comments, blank lines) coveringserver:smalltalk→server:st,st ding→st ding(both with word-boundary guards againstserver:st-web/smalltalk dingusfalse-matches), and the same env-var rename/drop rules inside[sessions.<name>.env]blocks.tools/cutover/sweep.ts— CLI driver.node --experimental-strip-types tools/cutover/sweep.ts <path>... [--dry-run] [--kind mcp-json|pty-toml|both] [--depth <n>] [--no-backup].--dry-runaudits without touching disk. Atomic writes; backups on first change (skip-if-exists so re-runs preserve the original pre-cutover snapshot).- 31 new unit tests at
tests/unit/tools/cutover/rewrite-*.test.tscovering every rule, idempotence, word-boundary guards, malformed input, and the belt-and-suspenders "bothST_AGENTandST_IDENTITYset" drop case. tools/cutover/README.mddocuments the rules + usage.
Closes the last ST_* env var that had no ST_* equivalent, so
the smalltalk→st cutover's Phase D can drop $ST_CONFIG support
without a gap. Mirrors the pattern already used for ST_ROOT /
ST_ROOT (brief-005-phase0):
- New
$ST_CONFIGenv var, preferred over$ST_CONFIG. Setting both is fine —$ST_CONFIGwins. - New
~/.config/smalltalk/default location, preferred when it exists. Falls back to~/.config/smalltalk/when only that exists (legacy machine); creates~/.config/smalltalk/on a brand-new install. Same reasoning as the state-root logic: the warning belongs on env vars (the actionable signal), not the dir shape. - Reading
$ST_CONFIGstill works and emits the same one-time stderr fallback notice used by$ST_ROOTand$ST_IDENTITY. stConfigFrom(env)is the new canonical resolver;stConfigFromis a same-signature deprecated alias. BothstConfig()andstConfig()— the current-env wrappers — work the same way.- 8 new unit tests cover the ST-preferred / SMALLTALK-fallback / dual-dir / brand-new-install / alias-equivalence axes.
st launch claude now writes <cwd>/.claude/settings.local.json
with all three smalltalk hooks pre-wired to absolute paths under
the shipped examples/claude-code/hooks/ directory:
- SessionStart with
async: true+asyncRewake: true— the brief-020 followup. asyncRewake surfaces the hook's stderr as a system reminder that triggers a turn, so a boot /--resume//clear//compactdoesn't leave the agent silent with a stale status and an unread inbox. Complements the polling backstop that shipped in brief-020. - PreCompact (task #33 hook-legs) — stubs
context/now.mdwhen the model hasn't flushed recently, so boot-rehydrate has something to inject after compaction. - StopFailure — surfaces API-error wedges to myobie via smalltalk.
Every new claude agent gets these hooks automatically on
st launch claude. Behavior:
- Skip-if-exists. An existing
.claude/settings.local.jsonis left alone (user may have hand-tuned it). Delete it to re-bootstrap. --no-hooksopts out. For anyone who wants to wire hooks by hand, or an eval that doesn't need them.- Codex harness skips. Claude Code hooks are Claude Code
specific; codex has its own path via
st ding. - Missing shipped
examples/. Soft-skip with a one-line stderr notice — launch still succeeds.examples/claude-code/is now included in the npmfileslist so installs get the hook scripts by default.
--dry-run prints the resolved .claude/settings.local.json path
and the generated JSON body so the operator can audit before spawn.
Before this change, a .md file dropped into an agent's inbox/
that didn't match the canonical <unix-ms>-<rand6>.md grammar was
silently ignored: chokidar, the polling backstop, st message ls, read, and archive all filtered it out. A collaborator
unfamiliar with the naming convention could send a message and the
recipient would never see it. This closes that silent-miss hole.
src/common.tsaddsvalidOutsideFilename(safe off-format.mdbasenames — rejects path traversal, dotfiles, and prefix-sibling attachments of a canonical.md) andvalidDeliverableFilename(union of canonical + outside).validFilenameandfilenameTimestampretain strict LAYOUT-004 semantics for callers that depend on prefix derivation.src/types.tsaddsasDeliverableFilename— brand constructor that accepts either LAYOUT-004 or safe outside.md.src/mcp/channel-watcher.tsnow delivers outside.mdfiles through the samenotifications/claude/channelpipeline as canonical messages, withfrom: "outside"in the meta envelope, a[outside .md — non-canonical filename: <name>]marker prepended to the content, and no thread reconstruction (outside files always start a new thread). Seed, chokidaradd, and the polling backstop all accept the broaderisDeliverablecheck.src/commands/{read,archive,ls}.tsaccept outside filenames.cmdReadreturns the raw file text as body withfrom: "outside"and no frontmatter projection (the file's claimed sender can't be trusted through an unofficial name).cmdArchivemoves bytes verbatim;withAttachmentsis silently coerced off for outside files (no LAYOUT prefix = no sibling family).cmdLsincludes outside files inmatcheswithfrom: "outside"in--json; ts is derived from file mtime.src/lib.ts—Smalltalk.lsmaps throughasDeliverableFilenameso an outside filename doesn't throw at the API boundary;Smalltalk.readshort-circuits outside filenames to a minimal{message: {from: 'outside', body: <text>}, ...}shape rather than reinterpreting untrusted frontmatter.- 9 new unit tests cover
validOutsideFilenameedge cases (path traversal, dotfiles, sidecar rejection, the legacy 3-segment shape). New integration tests inmcp-channel-watcher.test.tsexercise the outside-.md delivery path; existing tests that asserted silent-drop are updated to reflect the new "outside" semantics.
Idle Claude Code agents sometimes sat on delivered smalltalk messages
without ever surfacing them (evals-claude 56min, smalltalk-claude
also hit it), even though st_msg_ls would show the file. Root
cause: chokidar's FSEvents backend on macOS can silently stop
delivering add events on a long-idle process; the notification
never fires and the wake never happens. Claude Code agents relied
solely on the FSEvents-driven channel notification, so any dropped
event meant a wedged inbox.
src/mcp/channel-watcher.tsnow runs a polling backstop alongside chokidar. EverypollBackstopIntervalMs(default 15s), the watcher scans the inbox dir, dedupes against aseen: Setshared with chokidar'saddhandler, and enqueues any un-seen valid LAYOUT-grammar files through the same notification pipeline. Worst-case wake latency is now bounded by the poll interval even when FSEvents is fully dead.- Seeding. On startup,
seenis populated from an initialreaddirSyncso a fresh process doesn't replay historical files as fresh arrivals — backlog stays the boot ritual's job. - Dedup guarantees. Chokidar
addand the backstop check-and-add atomically against the sameseenset, so a single file fires exactly one notification regardless of which path observed it first. Verified by a new integration test that races both paths at aggressive intervals. ST_CHANNEL_DEBUG=1flag opts into one-line stderr instrumentation for each chokidaradd, each poll-backstop discovery, and each notification send. Kept off by default so a healthy agent's stderr stays quiet; when it happens again, the logs distinguish the FSEvents-drop path from a Claude-Code-side wake failure without guesswork.- 6 new integration tests exercise the backstop in isolation
(via
chokidarEnabled: false), confirm chronological ordering under the backstop, verify historical files are not replayed on startup, confirm non-.mdfiles stay ignored (task #128 later relaxed this to deliver off-format.mdas "outside" messages while keeping non-.mdpaths ignored), confirmclose()disposes the timer, and race chokidar + backstop to prove the dedup path.
New CLI verb: st launch <claude|codex> (also smalltalk launch /
st launch) that stands up a harness correctly wired to smalltalk
in a single command. Shaped like ollama launch.
- Identity resolution:
--identity <name>explicit →$ST_AGENT→ legacy$ST_IDENTITY→ legacy$ST_IDENTITY→ throwawayanon-<rand6>(with a one-line stderr notice pointing atST_AGENTfor persistence). Same fallback chain asst mcpin 0.8.1. .mcp.jsonbootstrap: delegates tocmdInit— idempotent merge, divergent-entry prompt-gate. Channel mode defaults toonfor claude,offfor codex.- Claude session-id dance: mirrors the
pty-claude-launcher.shreference — pins a.claude-session-idUUID (if the file doesn't exist), one-shotclaude --printto bootstrap the jsonl when it's missing (avoids the "session runs in-memory only" trap under detached pty), thenclaude --resume <SID>for the persistent run. - Codex sidecar: when the harness is
codexandptyis on$PATH, the generatedpty.tomlincludes a[sessions.ding]block runningst ding <session> --identity <agent>withstrategy = "permanent"so it comes back after crashes — codex has noasyncRewakeequivalent, sost dingis the re-wake mechanism. - GLM path:
--model <spec>routes throughollama launch <harness> --model <spec>so ollama does the env injection AND skips its interactive model picker. Unblocks unattended GLM-backed agents. - pty-optional: if
ptyis on$PATH, writes a minimalpty.toml(skip-if-exists — user edits are preserved) and hands off topty up. If not, the dry-run prints the exactpty.tomlsnippet + direct-spawn command the user can drop in later. --dry-run(alias--print): print the identity / argv / mcp.json path / pty.toml preview / channel mode / ollama route summary without spawning anything. Also touches nothing on disk under dry-run.- New file:
src/commands/launch.ts+ 32 unit tests covering identity resolution, channel-mode defaults, argv construction, pty.toml content, pty detection, session-id preservation, dry-run summary, and error paths. - Docs: new README section "Bring a Codex (or Claude / GLM)
agent onto smalltalk" — copy-pasteable, positioned right after
First time on a machineso new readers hit it early. - VERSION bumps to
0.9.0.
Scope excluded per brief-016: no changes to pty's launcher itself.
The .mcp.json writer's actual bin-path resolution and the ollama
CLI shape are treated as external contracts.
st mcp (and st mcp / smalltalk mcp) no longer hard-exits when
no ST_AGENT / ST_IDENTITY / ST_IDENTITY is set. Instead the
server falls back to a throwaway anon-<rand6> agent (e.g.
anon-h4k2qm) and emits a single stderr warning that names the
throwaway id and points at ST_AGENT for persistence. The anon
agent's inbox/ + archive/ folders are lazy-created so the channel
watcher and status writer have something to point at.
This unblocks MCP hosts that spawn st mcp without identity env
(Codex hit "cannot start the mcp server" before this fix). Managed
hosts that set an identity explicitly are unaffected — they keep
their explicit id and see no warning.
- Scope is
mcponly. Other CLI verbs (st status,smalltalk message send, etc.) still require an explicit identity because their behavior is address-sensitive (silently sending FROM a fresh random id each invocation would mask user errors). - The
anon-prefix is stable —st agentslistings and operators can spot throwaway sessions at a glance. - The fallback honors the existing three-level chain:
ST_AGENT→ST_IDENTITY(deprecation notice) →ST_IDENTITY(deprecation notice) →anon-<rand6>(this new fallback). - VERSION bumps to
0.8.1.
The TS SDK already had near-complete parity with the CLI post-brief-009. This entry closes the last four gaps surfaced by the audit. No CLI or MCP surface change.
smalltalk.archive(id, fn, opts?: ArchiveOptions)— now takes an opts bag.opts.withAttachments: truemirrors the CLI's--with-attachmentsand moves prefix-sibling files alongside the canonical.md. Default unchanged (canonical.mdonly).smalltalk.archiveTrim(id, opts)—opts.withAttachments?: booleanadded toTrimOptions. When true, prefix-siblings of trimmed.mdvictims are also deleted from archive. Default unchanged.smalltalk.lsOrphans(id?, opts?: { archive?: boolean })— new method. ReturnsOrphanItem[]({filename, ts}[]) for prefix-sibling files whose canonical.mdis no longer in the same folder. Mirrorsst message ls --orphans. Separate method (not an opt onls) because the return shape differs — orphans have no frontmatter.smalltalk.ding(deps)on the handle — thin wrapper around the already-exportedrunDing.deps.identitydefaults to the Smalltalk's own;smalltalkis wired automatically. Useful for TUI / supervisor embedders that want to start a ding inside their own process instead of shelling out.- New exports from
@compoundingtech/smalltalk:ArchiveOptions,OrphanItem. - VERSION bumps to
0.8.0.
Soft-breaking with a deprecation chain. The project's primary
noun changed from identity to agent. Every old name is kept as a
deprecated alias for one release cycle, so existing embedders /
running agents / consuming pty.toml configs all keep working
unchanged. Cos orchestrates the per-machine pty.toml sweep over the
~8 downstream repos at her own pace.
- SDK types:
Agentbrand (replacesIdentity);asAgent/isAgent(replaceasIdentity/isIdentity). Old names are@deprecatedre-exports pointing at the new brand — values are interchangeable. - SDK errors:
AgentRequiredError/AgentNotHostedError/InvalidAgentError. OldIdentity*Errornames are@deprecatedconsts aliased to the new classes —instanceofworks either way. Error CODE strings (IDENTITY_REQUIRED,IDENTITY_NOT_HOSTED,INVALID_IDENTITY) stay stable as wire format. Error MESSAGE text changed ("identity required" → "agent required", etc.). - CLI verb:
st agents(canonical) +st members(deprecated alias) — both dispatch to the same handler. - MCP tool:
st_agents+st_agentsregistered as the canonical names;st_members+st_memberskept as deprecated aliases pointing at the same handler. All four tool names work. - Env vars (cos orchestrates):
ST_AGENT(preferred) →ST_IDENTITY(deprecated, warns once per process) →ST_IDENTITY(legacy, warns once per process). The[smalltalk] honoring … — migrate to ST_AGENT when convenientnotice fires per legacy hit. Per-machinepty.tomlenv blocks should migrate fromST_IDENTITY/ST_IDENTITYtoST_AGENTat cos's pace; no flag day required. - SDK helpers:
resolveAgent/envAgentFrom(replaceresolveIdentity/envIdentityFrom). Old names aliased. - Internal:
validAgent(replacesvalidIdentity);cmdAgents/cmdAgentsCli/getAgents/listAgents(replacecmdMembers/cmdMembersCli/getMembers/listIdentities). All old names aliased. - RESERVED_NAMES: adds
agents; keepsmembers(deprecated CLI verb name). - Field names on returned shapes (e.g.
MessageWithLocation.identity,Overview.members) — KEPT as-is for one release for back-compat with embedder destructures. A follow-up release will rename them to.agent/.agents. <channel source="smalltalk" from="…">— KEPT as-is. Phase 5 of brief-005 (thest_*tool-name drop) owns flipping this tosource="st".- VERSION bumps to
0.7.0. - Docs: README, LAYOUT.md updated to lead with "agent" and the three-level env-var fallback.
Downstream sweep (cos owns): [sessions.*.env].ST_IDENTITY (or
ST_IDENTITY) → ST_AGENT across ~8 pty.toml repos; agent boot
rituals referencing st_members / st members →
st_agents / st agents. Three-level fallback means nothing
breaks mid-sweep.
A third optional per-identity folder for publishing annotated URLs to
peers. Each resource is <unix-ms>-<rand6>.md with url: in
frontmatter (required) and optional title: / tags: / relation:
/ body description. Mirrors the inbox-vs-archive single-writer rule:
resources/ is owned by its identity; peers read via sync.
- CLI:
st resource add <url> [--title T] [--tag T,T] [--relation REL] [--body-stdin],st resource ls [<identity>] [--json],st resource read [<identity>] <filename> [--json],st resource rm <filename>. - SDK:
smalltalk.resources.{add,list,read,remove}on the Smalltalk handle. New typesResource+ResourceWithLocationre-exported from@compoundingtech/smalltalk. - MCP: four new tools, dual-prefixed (
st_resource_*+st_resource_*) —resource_add,resource_ls,resource_read,resource_remove. Available in both channel and non-channel modes. - LAYOUT.md documents the new folder + frontmatter shape.
- RESERVED_NAMES adds
resourcesso an identity can't shadow the folder name. - New errors:
ResourceNotFoundError,InvalidResourceUrlError. - VERSION bumps to
0.6.0.
URL validation is intentionally lenient: any string with a scheme
prefix (https://, pty://, anything else an agent invents) is
accepted. The pty://<session-name> convention is documented but
not enforced.
The relation: field is very optional — absent by default,
never inferred from the URL / title / tags. The bare URL stays
first-class with or without it. Canonical (non-enforced) values:
owns, relates-to, depends-on. Agents may invent their own
relation strings; the schema is free-form.
Three new notes added, plus a small update to an existing one, to bring narrative docs in line with the slimmed-down surface and to name the actor-model framing the system has always implicitly assumed:
notes/actor-model.md(new): maps actor-model concepts — actor / mailbox / state / encapsulation / asynchrony — to smalltalk's data shape. Provides the framing that makes the encapsulation rule ("across identities, onlyinbox/is writable") and the Smalltalk-threads-stay-on-smalltalk rule fall out as obvious consequences rather than ad-hoc conventions.notes/onboarding.md(new): public zero-to-first-message recipe for a fresh participant (human or agent). Covers install, identity pick, status, send/receive, MCP wiring, and sync. The pre-existingnotes/agent-onboarding.mdis.gitignore'd (it's a myobie-specific machine runbook); this is the shippable counterpart.notes/repo-ownership.md(new): codifies the<repo>-claudeidentity-naming convention and notes where the binding actually lives at runtime (pty.toml,.mcp.json). Points to brief-009 item 5 (resources) as the formal mechanism that will supersede the convention.notes/agent-roles.md(minor update): reframed the future "external task tracker" paragraph to acknowledge tasks/journal are gone and point at the actor-model doc.
Breaking. The journal/ folder and every CLI/MCP surface that
referenced it is removed. Same motivation as the tasks removal: paring
the surface to what the friend onboarding actually needs.
- CLI:
smalltalk journal new/ls/cat/taildeleted (`src/commands/journal.ts` removed). - MCP onboarding text: the channel-mode instructions no longer
reference journal entries; the boot ritual is now status + inbox-drain
- members only.
- MCP tidy-check: the journal-lag drift condition is gone. Detection is inbox staleness only; `DriftResult` and `DriftDetail` shrank accordingly.
- `st ding`: the tidy-line is now `smalltalk tidy-check: inbox=N (oldest Xm).` (no journal segment).
- RESERVED_NAMES: `journal` is dropped.
- Removed constant: `STALE_JOURNAL_MS`.
- Removed helper: `journalDir()`.
- Downstream impact: consuming agents that reference `smalltalk journal` in their boot rituals need to drop those steps. The cos agent owns sweeping the consuming agent CLAUDE.md files alongside the tasks-removal sweep.
Breaking. The tasks/ folder and every CLI/SDK/MCP surface that
referenced it is removed. Tasks were never widely used outside
myobie's own agents; the slim-down clears the way for a tighter
onboarding story.
- CLI:
smalltalk task ...andsmalltalk taskssubcommands deleted. - MCP onboarding text: the channel-mode instructions no longer reference task-file ritual.
- MCP tidy-check: the
doingTaskdrift condition is gone; the detector now covers inbox + journal-lag (journal-lag is removed in the next entry, item 2). - SDK: no task types/methods were exposed (none existed); the
MemberTaskCountstype and thetasksfield onMemberSummaryEnriched/st_members(enriched) are removed. - Public types:
TaskState,TaskNotFoundError,TasksSingleWriterError,InvalidTaskTitleError, andInvalidTaskStateErrorare no longer exported. - RESERVED_NAMES:
tasksis dropped (the name is once again available as an identity, though we'd advise against it). - Docs: README, LAYOUT.md, completions guidance updated.
- Downstream impact: consuming agents that reference
smalltalk task/smalltalk tasksin their boot rituals need to drop those steps. The cos agent owns sweeping the consuming agent CLAUDE.md files.
The package is being renamed to smalltalk (long) / st (canonical
short). This release lays down the alias infrastructure for that
rename. Nothing breaks for callers in this release — the legacy
smalltalk surface continues to work end-to-end. Subsequent phases
(directory move, repo rename, per-agent config migration, cleanup) are
tracked separately.
- Binary aliases. Three commands install simultaneously:
st(canonical),smalltalk(long form),smalltalk(legacy alias). All three resolve to the same logic;bin/smalltalkandbin/smalltalkresolve tobin/stvia shell exec / symlink. - MCP server name dual-registration. The server announces itself
as
smalltalkwhen invoked throughbin/smalltalk, asstotherwise (bin/standbin/smalltalk). Detection is via the bash shim capturing$0basename before any symlink walk, exported as_ST_INVOKED_AS. - MCP tool name dual-registration. Every
st_<verb>tool —st_msg_send,st_msg_ls,st_msg_read,st_msg_archive,st_msg_thread,st_msg_reply,st_members— is now ALSO registered asst_<verb>with the same schema and handler. Tools listings show 12 (or 14 in channel mode) entries instead of 6 (or 7). - Environment variable dual-honor.
ST_IDENTITYis preferred overST_IDENTITY; same forST_ROOToverST_ROOT. When the legacy name is honored, a one-time-per-process stderr notice flags it:[smalltalk] honoring ST_IDENTITY — migrate to ST_IDENTITY when convenient. - State directory resolution. Default state path prefers
~/.local/state/smalltalkwhen it exists, falls back to~/.local/state/smalltalkwhen only that exists, and creates~/.local/state/smalltalkfor brand-new installs. When both exist,smalltalk/wins silently (the env-var notice is the actionable signal).ST_ROOT/ST_ROOTbypass this entirely. - Plugin proxy (git-style PATH dispatch). Unknown subcommands
fall back to a PATH lookup:
st-<cmd>→smalltalk-<cmd>→st-<cmd>. First executable match wins; built-in commands always take precedence over plugins of the same name. Nost-*plugins ship with this release — the mechanism is greenfield, future-proofing.
examples/codex/{session-start,stop}.sh now capture stdout and stderr
separately when invoking st message ls --json, so the new
[smalltalk] honoring ST_* notice doesn't corrupt the captured
JSON payload. The failure-diagnostic path still surfaces the stderr
contents.
<channel source="smalltalk" from="…">notification frames keepsource="smalltalk". Downstream parsers that grep this attribute continue to work unchanged. Phase 5 (cleanup) flips this tosource="st"alongside thest_*tool-name drop.- Existing
.mcp.jsonfiles pointing atbin/smalltalkkeep working. - Existing scripts setting only
ST_IDENTITY/ST_ROOTkeep working (with the one-time migration notice). - Existing
~/.local/state/smalltalk/directories keep working.
- Phase 1:
~/.local/state/smalltalk→~/.local/state/smalltalkdirectory move (operational, cos-driven). - Phase 2: GitHub repository rename + working-tree directory rename.
- Phase 3: per-identity rename, including
st-claude→smalltalk-claude. - Phase 4: per-agent
.mcp.json/settings.local.json/pty.tomlmigrations to point atbin/stand useST_*env vars. - Phase 5: drop the
st_*tool aliases, theST_*env fallbacks, and thebin/smalltalkshim. Flip channelsourceto"st". Bump major version.