Skip to content

Commit 6a324b5

Browse files
authored
feat(sdk): machine session bus — one wallet per machine, v2 always on (#256)
* feat(sdk): machine session bus — one wallet per machine, v2 always on - Wrapper sessions on one machine now share a single tiny.place wallet safely: a cross-process wallet lock serializes every Signal operation (send, destructive inbox read, prekey publish) and a filesystem spool routes inbound DMs to their addressed session. - New tinyplace.harness.control.v1 frame lets an owner address input to a specific session by wrapper or harness session id; plaintext DMs keep their meaning (injected into the machine's primary session). - Machine session registry (<config-dir>/harness-bus/sessions) records each wrapper session with PID-probed live/inactive state and the harness's own session id (e.g. the codex rollout id) once discovered. - v2 typed-event envelopes are now ALWAYS emitted alongside v1; the TINYPLACE_*_V2 opt-in env flag is retired. Claude-Session: https://claude.ai/code/session_013xjQ7FUumJaRzS9Hv6B5yu * fix(sdk): wire the machine session bus into the interactive TUI wrapper The default `tinyplace codex`/`claude` path builds its bridge in tui.ts (not runHarnessCommand), so it never engaged the bus: no wallet lock, no spool routing, no session registry entry. startBridge now creates/registers the bus, threads it into the publisher and receiver, heartbeats it, and stopBridge marks the session ended. Claude-Session: https://claude.ai/code/session_013xjQ7FUumJaRzS9Hv6B5yu * fix(sdk): make FileSessionStore coherent across processes sharing one wallet load() cached the whole store on first read and never re-read, so two wrapper sessions on one machine wallet held divergent ratchet copies and last-writer-wins clobbered each other's chains — the owner then failed to decrypt every interleaved send. The cache is now validated against the file's stat (mtime+size) on every load and refreshed after each flush, so under the machine bus lock every operation starts from the sibling's latest state. Claude-Session: https://claude.ai/code/session_013xjQ7FUumJaRzS9Hv6B5yu
1 parent 2eabade commit 6a324b5

9 files changed

Lines changed: 967 additions & 60 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Owner → machine control frames for the harness session bus.
2+
//
3+
// A machine runs ONE tiny.place wallet shared by every wrapped harness session
4+
// on it (see machine-bus.ts). Plaintext DMs keep their historical meaning —
5+
// they are injected into the machine's primary session — while a control frame
6+
// lets the owner address a SPECIFIC session by id. The session id is the one
7+
// the machine advertises on its envelope scope: the harness's own session id
8+
// (`scope.harness_session_id`, e.g. the codex rollout id) or the wrapper
9+
// session id (`scope.wrapper_session_id`) — a frame may name either.
10+
11+
export const HARNESS_CONTROL_VERSION = "tinyplace.harness.control.v1";
12+
13+
export interface HarnessControlFrame {
14+
control_version: typeof HARNESS_CONTROL_VERSION;
15+
/** `input` types the text into the addressed session's agent as a prompt. */
16+
kind: "input";
17+
/** Target session (wrapper or harness session id). Absent → primary session. */
18+
session_id?: string;
19+
text: string;
20+
}
21+
22+
export interface EncodeHarnessControlOptions {
23+
sessionId?: string;
24+
}
25+
26+
/** Serialize an `input` control frame for the encrypted DM body. */
27+
export function encodeHarnessControlFrame(
28+
text: string,
29+
options: EncodeHarnessControlOptions = {},
30+
): string {
31+
const frame: HarnessControlFrame = {
32+
control_version: HARNESS_CONTROL_VERSION,
33+
kind: "input",
34+
...(options.sessionId ? { session_id: options.sessionId } : {}),
35+
text,
36+
};
37+
return JSON.stringify(frame);
38+
}
39+
40+
/**
41+
* Decode a DM body into a {@link HarnessControlFrame}, or undefined when the
42+
* body is not one of ours (plaintext, a session envelope, another protocol, or
43+
* a malformed frame). Never throws — inbound Signal DMs are untrusted.
44+
*/
45+
export function parseHarnessControlFrame(
46+
body: string,
47+
): HarnessControlFrame | undefined {
48+
if (!body.trimStart().startsWith("{")) {
49+
return undefined;
50+
}
51+
let parsed: unknown;
52+
try {
53+
parsed = JSON.parse(body);
54+
} catch {
55+
return undefined;
56+
}
57+
if (parsed === null || typeof parsed !== "object") {
58+
return undefined;
59+
}
60+
const record = parsed as Record<string, unknown>;
61+
if (record.control_version !== HARNESS_CONTROL_VERSION) {
62+
return undefined;
63+
}
64+
if (record.kind !== "input" || typeof record.text !== "string") {
65+
return undefined;
66+
}
67+
const sessionId =
68+
typeof record.session_id === "string" && record.session_id.length > 0
69+
? record.session_id
70+
: undefined;
71+
return {
72+
control_version: HARNESS_CONTROL_VERSION,
73+
kind: "input",
74+
...(sessionId ? { session_id: sessionId } : {}),
75+
text: record.text,
76+
};
77+
}

sdk/typescript/src/cli/harness-wrapper.ts

Lines changed: 104 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ import {
5151
type SessionStatusState,
5252
} from "./harness-status.js";
5353
import { makeContext } from "./context.js";
54+
import { parseHarnessControlFrame } from "./harness-control.js";
55+
import { createMachineBus, type MachineBus } from "./machine-bus.js";
5456
import { makePathLookup } from "./daemon/providers.js";
5557
import {
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

Comments
 (0)