@@ -51,6 +51,8 @@ import {
5151 type SessionStatusState ,
5252} from "./harness-status.js" ;
5353import { makeContext } from "./context.js" ;
54+ import { parseHarnessControlFrame } from "./harness-control.js" ;
55+ import { createMachineBus , type MachineBus } from "./machine-bus.js" ;
5456import { makePathLookup } from "./daemon/providers.js" ;
5557import {
5658 OpenCodeEventSource ,
@@ -83,9 +85,8 @@ export interface HarnessWrapperConfig {
8385 captureSession : boolean ;
8486 dmRecipient ?: string ;
8587 dryRun : boolean ;
86- // Opt-in: also emit the v2 typed-event stream (tool_call/tool_result/status/
87- // thinking) alongside v1. Default off so current v1 consumers are untouched
88- // until the OpenHuman receiver understands v2.
88+ // The v2 typed-event stream (tool_call/tool_result/status/thinking) is
89+ // always emitted alongside v1; the field remains so tests can silence it.
8990 emitV2 : boolean ;
9091 outDir : string ;
9192 provider : HarnessProvider ;
@@ -234,10 +235,29 @@ export async function runHarnessCommand(
234235 stderr : options . stderr ?? process . stderr ,
235236 } ;
236237 const writer = new TerminalEnvelopeWriter ( config , cwd , stdio . stderr ) ;
237- const publisher = new SessionEnvelopePublisher ( config , options , stdio . stderr ) ;
238+ // The machine session bus shares this machine's ONE wallet across every
239+ // concurrent wrapper session: a cross-process lock serializes Signal I/O and
240+ // a spool routes inbound DMs to their addressed session. Only engaged when
241+ // the wrapper actually touches the wallet (an owner is configured).
242+ const bus =
243+ ! config . dryRun && Boolean ( config . dmRecipient ?? config . receiveFrom )
244+ ? createMachineBus ( {
245+ env,
246+ wrapperSessionId : config . wrapperSessionId ,
247+ provider,
248+ cwd,
249+ } )
250+ : undefined ;
251+ const publisher = new SessionEnvelopePublisher (
252+ config ,
253+ options ,
254+ stdio . stderr ,
255+ bus ,
256+ ) ;
238257 const receiver = config . receiveEnabled
239- ? new InboundMessageReceiver ( config , publisher , stdio . stderr )
258+ ? new InboundMessageReceiver ( config , publisher , stdio . stderr , bus )
240259 : undefined ;
260+ bus ?. registerSession ( ) ;
241261
242262 const usePty = config . usePty && options . spawn === undefined ;
243263 // opencode has no per-session transcript files — it is observed over its
@@ -315,6 +335,9 @@ export async function runHarnessCommand(
315335
316336 let exitCode = 1 ;
317337 let dmFailures = 0 ;
338+ const busHeartbeat = bus
339+ ? setInterval ( ( ) => bus . heartbeatSession ( ) , 10_000 )
340+ : undefined ;
318341 try {
319342 exitCode = usePty
320343 ? await runPtyAgent ( launch , config , cwd , env , writer , stdio , onInputSink )
@@ -329,6 +352,10 @@ export async function runHarnessCommand(
329352 onInputSink ,
330353 ) ;
331354 } finally {
355+ if ( busHeartbeat ) {
356+ clearInterval ( busHeartbeat ) ;
357+ }
358+ bus ?. endSession ( ) ;
332359 // Teardown order: stop the session observer (flushes the publisher) → stop
333360 // inbound → kill the opencode server. In a `finally` so a spawn failure
334361 // still tears the server down instead of leaking a `serve` process.
@@ -539,11 +566,9 @@ export function parseHarnessWrapperArgs(
539566 captureSession : true ,
540567 ...( owner ? { dmRecipient : owner } : { } ) ,
541568 dryRun : false ,
542- emitV2 :
543- firstEnv ( env , [
544- `TINYPLACE_${ provider . toUpperCase ( ) } _V2` ,
545- "TINYPLACE_HARNESS_V2" ,
546- ] ) === "1" ,
569+ // v2 is the protocol: the typed-event stream is always emitted (alongside
570+ // v1 for older consumers). The former TINYPLACE_*_V2 opt-in is retired.
571+ emitV2 : true ,
547572 outDir,
548573 provider,
549574 statusHeartbeatMs : numberEnvOr (
@@ -1239,8 +1264,14 @@ export class SessionEnvelopePublisher {
12391264 private readonly config : HarnessWrapperConfig ,
12401265 private readonly options : TinyPlaceCliOptions ,
12411266 private readonly stderr : Writable ,
1267+ private readonly bus ?: MachineBus ,
12421268 ) { }
12431269
1270+ /** Serialize a wallet-touching call across this machine's processes. */
1271+ private withWalletLock < T > ( fn : ( ) => Promise < T > ) : Promise < T > {
1272+ return this . bus ? this . bus . withLock ( fn ) : fn ( ) ;
1273+ }
1274+
12441275 /** Record an owner→agent injection so its echoed-back `user` line is dropped. */
12451276 public markInjected ( text : string ) : void {
12461277 const key = echoKey ( text ) ;
@@ -1290,6 +1321,11 @@ export class SessionEnvelopePublisher {
12901321 bridgeLog ( "publish.echoSkip" , { id } ) ;
12911322 return ;
12921323 }
1324+ // Record the harness's own session id on the machine registry as soon as
1325+ // an envelope reveals it — owners address control frames by this id.
1326+ this . bus ?. heartbeatSession ( {
1327+ harnessSessionId : envelope . scope . harness_session_id ,
1328+ } ) ;
12931329 bridgeLog ( "publish.enqueue" , {
12941330 id,
12951331 role : envelopeRole ( envelope ) ,
@@ -1330,24 +1366,24 @@ export class SessionEnvelopePublisher {
13301366 throw new Error ( "DM forwarding requires a tiny.place signer" ) ;
13311367 }
13321368 const recipient = await this . ensureContact ( ctx ) ;
1369+ const signer = ctx . signer ;
13331370 try {
1334- await sendMessage (
1335- ctx . client ,
1336- ctx . signer ,
1337- recipient ,
1338- JSON . stringify ( envelope ) ,
1371+ await this . withWalletLock ( ( ) =>
1372+ sendMessage ( ctx . client , signer , recipient , JSON . stringify ( envelope ) ) ,
13391373 ) ;
13401374 } catch ( error ) {
13411375 if ( ! isNotAContactError ( error ) ) {
13421376 throw error ;
13431377 }
13441378 this . contactPromise = undefined ;
13451379 const refreshedRecipient = await this . ensureContact ( ctx ) ;
1346- await sendMessage (
1347- ctx . client ,
1348- ctx . signer ,
1349- refreshedRecipient ,
1350- JSON . stringify ( envelope ) ,
1380+ await this . withWalletLock ( ( ) =>
1381+ sendMessage (
1382+ ctx . client ,
1383+ signer ,
1384+ refreshedRecipient ,
1385+ JSON . stringify ( envelope ) ,
1386+ ) ,
13511387 ) ;
13521388 }
13531389 }
@@ -1496,8 +1532,14 @@ export class InboundMessageReceiver {
14961532 private readonly config : HarnessWrapperConfig ,
14971533 private readonly publisher : SessionEnvelopePublisher ,
14981534 private readonly stderr : Writable ,
1535+ private readonly bus ?: MachineBus ,
14991536 ) { }
15001537
1538+ /** Serialize a wallet-touching call across this machine's processes. */
1539+ private withWalletLock < T > ( fn : ( ) => Promise < T > ) : Promise < T > {
1540+ return this . bus ? this . bus . withLock ( fn ) : fn ( ) ;
1541+ }
1542+
15011543 /** Register the agent-input writer once the child is spawned. Injection is a
15021544 * no-op until this is set, so the poll loop can start before the child. */
15031545 public setSink ( write : ( text : string ) => void ) : void {
@@ -1517,8 +1559,11 @@ export class InboundMessageReceiver {
15171559 }
15181560 // Publish our Signal key bundle so the owner can open a session to us — the
15191561 // wrapper never did this before, which is why it was un-messageable.
1562+ // Under the machine bus this is wallet-locked: prekey publication mutates
1563+ // the shared Signal store, and N sessions start concurrently.
1564+ const signer = ctx . signer ;
15201565 try {
1521- await publishKeys ( ctx . client , ctx . signer ) ;
1566+ await this . withWalletLock ( ( ) => publishKeys ( ctx . client , signer ) ) ;
15221567 } catch ( error ) {
15231568 this . stderr . write (
15241569 `tinyplace ${ this . config . provider } : failed to publish Signal keys: ${ describeError ( error ) } \n` ,
@@ -1610,30 +1655,51 @@ export class InboundMessageReceiver {
16101655 if ( ! ctx . signer ) {
16111656 return ;
16121657 }
1613- const messages = await readMessages ( ctx . client , ctx . signer , {
1614- limit : 50 ,
1615- } ) ;
1658+ const signer = ctx . signer ;
1659+ this . bus ?. heartbeatSession ( ) ;
1660+ const messages = await this . withWalletLock ( ( ) =>
1661+ readMessages ( ctx . client , signer , { limit : 50 } ) ,
1662+ ) ;
16161663 let injected = 0 ;
16171664 let dropped = 0 ;
16181665 for ( const message of messages ) {
16191666 const text = this . filterAndParse ( message ) ;
1620- // `this.sink` is guaranteed defined here — poll() returns early when it
1621- // is not. injectOne re-guards it before each write regardless.
1622- if ( text !== undefined ) {
1623- injected += 1 ;
1624- // Record before injecting so the tailer's echoed-back `user` line
1625- // (the agent logs the injected prompt as its own user turn) is dropped
1626- // by the publisher instead of bouncing to the owner.
1627- this . publisher . markInjected ( text ) ;
1628- // Type the body now, then submit with a DISTINCT carriage return after
1629- // a short debounce (see injectOne). Enqueued so batched inbound turns
1630- // stay serialized (body → Enter → next body) and never interleave.
1631- this . enqueueInjection ( text ) ;
1632- } else {
1667+ if ( text === undefined ) {
16331668 dropped += 1 ;
1669+ continue ;
1670+ }
1671+ if ( this . bus ) {
1672+ // Machine bus: the inbox is shared by every session on this wallet,
1673+ // so a message we happened to read may be addressed to ANOTHER
1674+ // session — spool it; our own spool is drained below.
1675+ this . bus . routeInbound ( message . from , text ) ;
1676+ continue ;
1677+ }
1678+ // No bus (single session, no shared wallet): inject directly. A
1679+ // control frame still resolves to its text so an owner can use one
1680+ // frame shape everywhere.
1681+ const frame = parseHarnessControlFrame ( text ) ;
1682+ const body = frame ? frame . text : text ;
1683+ injected += 1 ;
1684+ // Record before injecting so the tailer's echoed-back `user` line
1685+ // (the agent logs the injected prompt as its own user turn) is dropped
1686+ // by the publisher instead of bouncing to the owner.
1687+ this . publisher . markInjected ( body ) ;
1688+ // Type the body now, then submit with a DISTINCT carriage return after
1689+ // a short debounce (see injectOne). Enqueued so batched inbound turns
1690+ // stay serialized (body → Enter → next body) and never interleave.
1691+ this . enqueueInjection ( body ) ;
1692+ }
1693+ if ( this . bus ) {
1694+ // Drain what other pollers (or this one) spooled for this session —
1695+ // plus the default spool when this session is the machine's primary.
1696+ for ( const item of this . bus . consumeInbound ( ) ) {
1697+ injected += 1 ;
1698+ this . publisher . markInjected ( item . text ) ;
1699+ this . enqueueInjection ( item . text ) ;
16341700 }
16351701 }
1636- if ( messages . length > 0 ) {
1702+ if ( messages . length > 0 || injected > 0 ) {
16371703 bridgeLog ( "receiver.poll" , {
16381704 read : messages . length ,
16391705 injected,
0 commit comments