From 9cfb4da9880eac5e7fc52086d82e4515c058336a Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 3 Jul 2026 19:10:37 +0530 Subject: [PATCH 01/34] =?UTF-8?q?docs(tinyplace):=20design=20=E2=80=94=20o?= =?UTF-8?q?ne=20plugin,=20runtime=20harness=20detect=20+=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/plugin-tinyplace/DESIGN.md | 105 +++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 sdk/plugin-tinyplace/DESIGN.md diff --git a/sdk/plugin-tinyplace/DESIGN.md b/sdk/plugin-tinyplace/DESIGN.md new file mode 100644 index 00000000..aeebf2e0 --- /dev/null +++ b/sdk/plugin-tinyplace/DESIGN.md @@ -0,0 +1,105 @@ +# tinyplace — one plugin, any harness + +**Goal (user directive):** ONE installable plugin. The user installs it once; it +**detects the harness it's running in** (Codex, Claude Code, or any future one) and +wires itself accordingly — MCP server, hooks, launcher, inbound strategy. No per-harness +package, no manual wiring. + +Replaces the two near-identical packages (`plugin-claude` 1280 loc, `plugin-codex` +1333 loc — ~95% shared) with a single `@tinyhumansai/tinyplace-plugin`. The 5% that +differs is isolated into **runtime-selected adapters**. + +## How one package serves every harness + +``` + ┌─────────────────────────────┐ + install once ───▶ │ @tinyhumansai/tinyplace-* │ + │ │ + │ detectHarness() ──────────┼──▶ "codex" | "claude" | … + │ │ │ + │ ▼ │ + │ adapters[harness] ← the only per-harness deltas + │ │ │ + │ ┌─────┴──────────────────┐ │ + │ │ shared core (the 95%) │ │ 20 MCP tools, wallet store, + │ │ format/registry/route/ │ │ Signal drain+send, daemon, + │ │ daemon/server/outbox │ │ routing, sessions, contacts + │ └────────────────────────┘ │ + └─────────────────────────────┘ +``` + +### Runtime detection (`mcp/harness.mjs`) + +Order: explicit override → harness-specific env signals → default. + +| Signal | ⇒ harness | +|--------|-----------| +| `TINYPLACE_HARNESS` set | that value (explicit escape hatch) | +| `CODEX_HOME` / `CODEX_SESSION_ID` / `CODEX_THREAD_ID` present | `codex` | +| `CLAUDE_PLUGIN_ROOT` / `CLAUDE_CODE_SESSION_ID` present | `claude` | +| else | `claude` (safe default; overridable) | + +### Adapter — the ONLY per-harness surface + +```js +// adapters/.mjs → one descriptor object +{ + provider: "codex" | "claude", + dataDirEnv, dataDirDefault, // ~/.tinyplace-codex vs -claude (or unified ~/.tinyplace) + sessionLabelPrefix, // "codex" / "claude" + resolveHarnessSessionId(), // codex: CODEX_* → null→wrapper id; claude: CLAUDE_CODE_SESSION_ID + inbound: { // how new DMs reach a live session + push: false | { capability, pushMessage, pushContactRequest }, // claude channel + pull: true | false, // codex: surfacing hook + inbox tool + foregroundInject: true, // tmux send-keys into the live pane (folds in #212, both harnesses) + }, + responder: { command, buildArgs(prompt, model, root), defaultModel }, // claude -p | codex exec + install: { kind: "plugin-dir" | "codex-home", write(ctx) }, // launcher wiring +} +``` + +Shared core reads `activeAdapter()` once at startup. Push paths no-op when +`inbound.push === false`; hooks/surfacing only wired when `inbound.pull`. Nothing +harness-specific lives in the 20 tools. + +### One launcher (`bin/tinyplace.mjs`) + +`tinyplace` (no args) → pick/create wallet → **detect or `--harness `** → +- claude → `claude --plugin-dir --dangerously-load-development-channels …` +- codex → write isolated `CODEX_HOME` (config.toml `[mcp_servers]` + auto-loaded + `hooks.json`) → `codex --dangerously-bypass-hook-trust` + +Already *inside* a harness (server spawned as its MCP child) → just run the MCP server; +detection picks the adapter, no launch. + +## Inbound strategy per harness (unified) + +| Harness | idle live session | no live session | +|---------|-------------------|-----------------| +| claude | channel push (real-time) OR tmux inject (#212) | isolated `claude -p` | +| codex | tmux inject (#212) — else surfacing hook next turn | isolated `codex exec` | + +`foregroundInject` (tmux) is harness-agnostic → lives in the shared core, gated by a +recorded `tmuxPane`. This is exactly sanil's #212 generalized to one place. + +## Packaging + +Single package, its own `node_modules` (deps: `@tinyhumansai/tinyplace`, +`@modelcontextprotocol/sdk`, `zod`). Excluded from the pnpm workspace like the current +plugins. Relative cross-dir imports of the SDK do NOT resolve without the package's own +install (verified) → keep everything under one package root with one `node_modules`. + +## Migration / decoupling (no dependency on #212 or #214) + +1. Build `plugin-tinyplace` by merging the two servers → one, threading `activeAdapter()`. +2. Port both test suites → run against the unified package (behavior unchanged). +3. Fold `foregroundInject` into the core adapter slot now (empty until tmux wiring lands), + so #212 merges in as core behavior with no rework. +4. `plugin-claude` / `plugin-codex` become thin re-export shims → the unified package (or + are removed once consumers migrate). Keeps #214/#212 alive during transition. +5. Cross-harness `xplugin-e2e` still green (now: one package, two adapters, same network). + +## Success criterion + +One `npm i @tinyhumansai/tinyplace-plugin` + `tinyplace`. It works in Codex or Claude +with zero harness-specific choices by the user. A new harness = one adapter file. From fcda00dc629ea6bd2fe45326598145b315a42c95 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 3 Jul 2026 19:10:37 +0530 Subject: [PATCH 02/34] feat(tinyplace): claude + codex runtime adapters (per-harness deltas) --- sdk/plugin-tinyplace/adapters/claude.mjs | 41 +++++++++++++++++++++ sdk/plugin-tinyplace/adapters/codex.mjs | 45 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 sdk/plugin-tinyplace/adapters/claude.mjs create mode 100644 sdk/plugin-tinyplace/adapters/codex.mjs diff --git a/sdk/plugin-tinyplace/adapters/claude.mjs b/sdk/plugin-tinyplace/adapters/claude.mjs new file mode 100644 index 00000000..3d81408b --- /dev/null +++ b/sdk/plugin-tinyplace/adapters/claude.mjs @@ -0,0 +1,41 @@ +// Claude Code adapter — the per-harness deltas for Claude. Selected at runtime by +// mcp/harness.mjs. The shared core reads only this descriptor; nothing +// Claude-specific lives in the 20 tools. +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const claudeAdapter = { + provider: "claude", + + // State dir (wallets, sessions, queue). Env override wins. + dataDirEnv: "TINYPLACE_CLAUDE_HOME", + dataDirDefault: join(homedir(), ".tinyplace-claude"), + sessionLabelPrefix: "claude", + + harness: { command: "tinyplace-plugin", argv: [] }, + + // §15: a plugin session's harness_session_id is the Claude Code session id. + resolveHarnessSessionId() { + return process.env.CLAUDE_CODE_SESSION_ID?.trim() || ""; + }, + + // Inbound delivery. Claude can push into a live session (channel capability); + // foreground tmux inject is the shared fallback that also wakes an idle pane. + inbound: { + push: { capability: "claude/channel", method: "notifications/claude/channel" }, + pull: false, + foregroundInject: true, + }, + + // Isolated headless responder (used when no live session/pane). + responder: { + command: "claude", + defaultModel: "claude-haiku-4-5-20251001", + buildArgs(prompt, model, pluginRoot) { + return ["-p", prompt, "--plugin-dir", pluginRoot, "--dangerously-skip-permissions", "--model", model]; + }, + }, + + // Launcher install shape. + install: { kind: "plugin-dir" }, +}; diff --git a/sdk/plugin-tinyplace/adapters/codex.mjs b/sdk/plugin-tinyplace/adapters/codex.mjs new file mode 100644 index 00000000..7f978f41 --- /dev/null +++ b/sdk/plugin-tinyplace/adapters/codex.mjs @@ -0,0 +1,45 @@ +// OpenAI Codex CLI adapter — the per-harness deltas for Codex. Selected at runtime +// by mcp/harness.mjs. The shared core reads only this descriptor. +import { homedir } from "node:os"; +import { join } from "node:path"; + +export const codexAdapter = { + provider: "codex", + + dataDirEnv: "TINYPLACE_CODEX_HOME", + dataDirDefault: join(homedir(), ".tinyplace-codex"), + sessionLabelPrefix: "codex", + + harness: { command: "tinyplace-codex-plugin", argv: [] }, + + // Codex does not (verified codex-cli 0.142.5) guarantee a session-id env to the + // MCP subprocess, so try the plausible vars and fall back to a caller override; + // the server self-generates a wrapper id when empty. + resolveHarnessSessionId() { + return ( + process.env.CODEX_SESSION_ID?.trim() || + process.env.CODEX_THREAD_ID?.trim() || + process.env.TINYPLACE_HARNESS_SESSION_ID?.trim() || + "" + ); + }, + + // Codex MCP is pull-only (no server→client push). New DMs surface via the + // SessionStart/UserPromptSubmit hook + the inbox tool; foreground tmux inject is + // the shared fallback that wakes an idle pane in-context. + inbound: { + push: false, + pull: true, + foregroundInject: true, + }, + + responder: { + command: "codex", + defaultModel: "gpt-5.4-mini", + buildArgs(prompt, model /* pluginRoot unused: MCP comes from CODEX_HOME */) { + return ["exec", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "-m", model, prompt]; + }, + }, + + install: { kind: "codex-home" }, +}; From 65c1f9a98ddaa2499bc7833bae02beca18409936 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 3 Jul 2026 19:10:37 +0530 Subject: [PATCH 03/34] feat(tinyplace): runtime harness detection + adapter resolution --- sdk/plugin-tinyplace/mcp/harness.mjs | 55 ++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 sdk/plugin-tinyplace/mcp/harness.mjs diff --git a/sdk/plugin-tinyplace/mcp/harness.mjs b/sdk/plugin-tinyplace/mcp/harness.mjs new file mode 100644 index 00000000..b0554bb8 --- /dev/null +++ b/sdk/plugin-tinyplace/mcp/harness.mjs @@ -0,0 +1,55 @@ +// Runtime harness detection — the one-plugin-any-harness pivot. The package is +// installed once; at load time it figures out WHICH harness it runs inside +// (Codex, Claude Code, …) and hands the shared core the matching adapter. No +// per-harness package, no user wiring. +import { homedir } from "node:os"; +import { join } from "node:path"; + +import { claudeAdapter } from "../adapters/claude.mjs"; +import { codexAdapter } from "../adapters/codex.mjs"; + +const ADAPTERS = { claude: claudeAdapter, codex: codexAdapter }; + +// Detect the harness from the environment. Order: +// 1. explicit override (TINYPLACE_HARNESS) — escape hatch / launcher-set, +// 2. harness-specific env signals, +// 3. safe default (claude). +// Pure over an env bag so it's unit-testable without mutating process.env. +export function detectHarness(env = process.env) { + const explicit = env.TINYPLACE_HARNESS?.trim().toLowerCase(); + if (explicit && ADAPTERS[explicit]) return explicit; + + // Codex signals — CODEX_HOME is set for every Codex subprocess; the session/ + // thread ids appear in some contexts. Any one is conclusive. + if (env.CODEX_HOME || env.CODEX_SESSION_ID || env.CODEX_THREAD_ID) return "codex"; + + // Claude Code signals — plugin root is exported to plugin subprocesses; the + // session id is present in hook/tool contexts. + if (env.CLAUDE_PLUGIN_ROOT || env.CLAUDE_CODE_SESSION_ID) return "claude"; + + return "claude"; +} + +// The adapter for the detected (or forced) harness. +export function resolveAdapter(env = process.env) { + return ADAPTERS[detectHarness(env)]; +} + +let _cached = null; +// The active adapter for THIS process (memoized). The shared core calls this once. +export function activeAdapter() { + if (!_cached) _cached = resolveAdapter(); + return _cached; +} + +// Test seam: drop the memo so a test can re-detect under a different env. +export function _resetAdapterCache() { + _cached = null; +} + +// Convenience: the resolved data dir for the active harness (env override wins). +export function harnessDataDir(adapter = activeAdapter()) { + return process.env[adapter.dataDirEnv]?.trim() || adapter.dataDirDefault || join(homedir(), ".tinyplace"); +} + +export { ADAPTERS }; From 3d3ab480ca4ec30bd55bdf27f5c4ba7bf89bd228 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 3 Jul 2026 19:10:38 +0530 Subject: [PATCH 04/34] test(tinyplace): harness detect + auto-wire (28 checks); package + workspace exclude --- pnpm-workspace.yaml | 3 ++ sdk/plugin-tinyplace/.gitignore | 2 + sdk/plugin-tinyplace/harness-test.mjs | 69 +++++++++++++++++++++++++++ sdk/plugin-tinyplace/package.json | 21 ++++++++ 4 files changed, 95 insertions(+) create mode 100644 sdk/plugin-tinyplace/.gitignore create mode 100644 sdk/plugin-tinyplace/harness-test.mjs create mode 100644 sdk/plugin-tinyplace/package.json diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4a911266..cd1864ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,3 +4,6 @@ packages: # plugin-claude is a standalone npm package (its own node_modules / lockfile), # not part of the pnpm workspace — keep it out of frozen install + -r builds. - "!sdk/plugin-claude" + # plugin-tinyplace is the unified plugin (own node_modules / lockfile) — same + # standalone treatment: keep it out of the frozen install + recursive builds. + - "!sdk/plugin-tinyplace" diff --git a/sdk/plugin-tinyplace/.gitignore b/sdk/plugin-tinyplace/.gitignore new file mode 100644 index 00000000..d5f19d89 --- /dev/null +++ b/sdk/plugin-tinyplace/.gitignore @@ -0,0 +1,2 @@ +node_modules +package-lock.json diff --git a/sdk/plugin-tinyplace/harness-test.mjs b/sdk/plugin-tinyplace/harness-test.mjs new file mode 100644 index 00000000..20545bf2 --- /dev/null +++ b/sdk/plugin-tinyplace/harness-test.mjs @@ -0,0 +1,69 @@ +#!/usr/bin/env node +// Proves the "one plugin, any harness" pivot: from an env bag alone, the package +// detects the harness and hands back the correct adapter, fully wired. +import { detectHarness, resolveAdapter, harnessDataDir } from "./mcp/harness.mjs"; + +let failed = false; +const check = (n, c, x) => { console.log(`${c ? "PASS" : "FAIL"} ${n}${x ? ` — ${x}` : ""}`); if (!c) failed = true; }; + +// ── detection from env signals ──────────────────────────────────────────────── +check("codex via CODEX_HOME", detectHarness({ CODEX_HOME: "/x" }) === "codex"); +check("codex via CODEX_SESSION_ID", detectHarness({ CODEX_SESSION_ID: "s" }) === "codex"); +check("codex via CODEX_THREAD_ID", detectHarness({ CODEX_THREAD_ID: "t" }) === "codex"); +check("claude via CLAUDE_PLUGIN_ROOT", detectHarness({ CLAUDE_PLUGIN_ROOT: "/p" }) === "claude"); +check("claude via CLAUDE_CODE_SESSION_ID", detectHarness({ CLAUDE_CODE_SESSION_ID: "s" }) === "claude"); +check("default = claude (no signals)", detectHarness({}) === "claude"); + +// ── explicit override wins over signals ─────────────────────────────────────── +check("override forces codex", detectHarness({ TINYPLACE_HARNESS: "codex", CLAUDE_PLUGIN_ROOT: "/p" }) === "codex"); +check("override forces claude", detectHarness({ TINYPLACE_HARNESS: "claude", CODEX_HOME: "/x" }) === "claude"); +check("bad override ignored → signal", detectHarness({ TINYPLACE_HARNESS: "nope", CODEX_HOME: "/x" }) === "codex"); +check("override case-insensitive", detectHarness({ TINYPLACE_HARNESS: "CODEX" }) === "codex"); + +// ── adapter wiring: codex ───────────────────────────────────────────────────── +{ + const a = resolveAdapter({ CODEX_HOME: "/x" }); + check("codex adapter provider", a.provider === "codex"); + check("codex label prefix", a.sessionLabelPrefix === "codex"); + check("codex dataDirEnv", a.dataDirEnv === "TINYPLACE_CODEX_HOME"); + check("codex pull-only (no push)", a.inbound.push === false && a.inbound.pull === true); + check("codex has foreground inject slot", a.inbound.foregroundInject === true); + check("codex responder = codex exec", a.responder.command === "codex" && a.responder.buildArgs("P", "M").includes("exec")); + check("codex install kind", a.install.kind === "codex-home"); +} + +// ── adapter wiring: claude ──────────────────────────────────────────────────── +{ + const a = resolveAdapter({ CLAUDE_PLUGIN_ROOT: "/p" }); + check("claude adapter provider", a.provider === "claude"); + check("claude label prefix", a.sessionLabelPrefix === "claude"); + check("claude has push capability", a.inbound.push && a.inbound.push.capability === "claude/channel"); + check("claude not pull", a.inbound.pull === false); + check("claude has foreground inject slot", a.inbound.foregroundInject === true); + check("claude responder = claude -p", a.responder.command === "claude" && a.responder.buildArgs("P", "M", "/root").includes("-p")); + check("claude install kind", a.install.kind === "plugin-dir"); +} + +// ── session-id resolution is per-harness ────────────────────────────────────── +{ + const codex = resolveAdapter({ CODEX_HOME: "/x" }); + const claude = resolveAdapter({ CLAUDE_PLUGIN_ROOT: "/p" }); + process.env.CODEX_SESSION_ID = "cx-123"; + process.env.CLAUDE_CODE_SESSION_ID = "cl-456"; + check("codex resolves CODEX_SESSION_ID", codex.resolveHarnessSessionId() === "cx-123"); + check("claude resolves CLAUDE_CODE_SESSION_ID", claude.resolveHarnessSessionId() === "cl-456"); + delete process.env.CODEX_SESSION_ID; + delete process.env.CLAUDE_CODE_SESSION_ID; +} + +// ── data dir: env override beats default ────────────────────────────────────── +{ + const a = resolveAdapter({ CODEX_HOME: "/x" }); + process.env.TINYPLACE_CODEX_HOME = "/tmp/custom-codex"; + check("dataDir honors env override", harnessDataDir(a) === "/tmp/custom-codex"); + delete process.env.TINYPLACE_CODEX_HOME; + check("dataDir falls back to default", harnessDataDir(a) === a.dataDirDefault); +} + +console.log(failed ? "\nHARNESS TEST FAILED ❌" : "\nHARNESS TEST PASSED ✅"); +process.exit(failed ? 1 : 0); diff --git a/sdk/plugin-tinyplace/package.json b/sdk/plugin-tinyplace/package.json new file mode 100644 index 00000000..7c3c40e4 --- /dev/null +++ b/sdk/plugin-tinyplace/package.json @@ -0,0 +1,21 @@ +{ + "name": "@tinyhumansai/tinyplace-plugin", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "One tiny.place agent plugin for any harness: detects Codex / Claude Code at runtime and self-wires (MCP server, hooks, launcher). Unifies plugin-claude + plugin-codex.", + "bin": { + "tinyplace": "./bin/tinyplace.mjs" + }, + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "node harness-test.mjs" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "@tinyhumansai/tinyplace": "^1.0.1", + "zod": "^3.25.0" + } +} From b5d45c9ea24550325b4579dc7ec0c59898ef2dc8 Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 3 Jul 2026 19:26:14 +0530 Subject: [PATCH 05/34] feat(tinyplace): pure core modules (address/outbox/routing) verbatim --- sdk/plugin-tinyplace/mcp/address.mjs | 25 +++++ sdk/plugin-tinyplace/mcp/outbox.mjs | 90 ++++++++++++++++ sdk/plugin-tinyplace/mcp/routing.mjs | 154 +++++++++++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 sdk/plugin-tinyplace/mcp/address.mjs create mode 100644 sdk/plugin-tinyplace/mcp/outbox.mjs create mode 100644 sdk/plugin-tinyplace/mcp/routing.mjs diff --git a/sdk/plugin-tinyplace/mcp/address.mjs b/sdk/plugin-tinyplace/mcp/address.mjs new file mode 100644 index 00000000..e185e343 --- /dev/null +++ b/sdk/plugin-tinyplace/mcp/address.mjs @@ -0,0 +1,25 @@ +// Address helpers shared by the MCP server and the daemon. +// +// Contacts are keyed by the base58 cryptoId (a base64 key gives 404 on +// /contacts/{id}). Convert whatever was passed (cryptoId, base64 messaging key, +// or @handle) into the cryptoId the contacts API expects. +const BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const CRYPTO_ID_RE = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; +const B64KEY_RE = /^[A-Za-z0-9+/]{43}=$/; + +export function bytesToBase58(bytes) { + let n = 0n; + for (const b of bytes) n = (n << 8n) + BigInt(b); + let out = ""; + while (n > 0n) { out = BASE58[Number(n % 58n)] + out; n = n / 58n; } + for (const b of bytes) { if (b !== 0) break; out = "1" + out; } + return out || "1"; +} + +export async function toCryptoId(client, value) { + if (B64KEY_RE.test(value)) return bytesToBase58(Buffer.from(value, "base64")); + if (!value.startsWith("@") && CRYPTO_ID_RE.test(value)) return value; + const handle = value.startsWith("@") ? value : `@${value}`; + const r = await client.directory.resolve(handle).catch(() => null); + return r?.agent?.agentId ?? r?.agentId ?? r?.cryptoId ?? value; +} diff --git a/sdk/plugin-tinyplace/mcp/outbox.mjs b/sdk/plugin-tinyplace/mcp/outbox.mjs new file mode 100644 index 00000000..010baf18 --- /dev/null +++ b/sdk/plugin-tinyplace/mcp/outbox.mjs @@ -0,0 +1,90 @@ +// Outbound send jobs — a session (thin client) drops a job here; the daemon (the +// single ratchet writer) claims it, builds the SessionEnvelope, and sends. File +// queue at sessions//_outbox/. Claims are atomic renames so the daemon +// never double-sends a job. +import { mkdirSync, writeFileSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { sessionsDir } from "./registry.mjs"; + +// A claim older than this is assumed abandoned (daemon crashed mid-send) and is +// requeued. Kept safely longer than any single send attempt. +const STALE_CLAIM_MS = Number(process.env.TINYPLACE_OUTBOX_CLAIM_MS) || 60_000; + +export function outboxDir(agentAddress) { + return join(sessionsDir(agentAddress), "_outbox"); +} + +// Requeue jobs whose `.sending-*` claim was orphaned by a daemon that exited +// between claiming and done()/fail(). Without this they'd never be listed again. +function recoverStaleClaims(dir) { + let files; + try { + files = readdirSync(dir).filter((f) => f.startsWith(".sending-")); + } catch { + return; + } + const now = Date.now(); + for (const f of files) { + const p = join(dir, f); + try { + if (now - statSync(p).mtimeMs < STALE_CLAIM_MS) continue; + const orig = f.replace(/^\.sending-\d+-/, ""); + if (!orig.endsWith(".json")) continue; + renameSync(p, join(dir, orig)); // back to a pending job + } catch { + /* raced with a live daemon finishing the send — fine */ + } + } +} + +// Session side: enqueue a send job. `job` carries +// { id, to, toSession, role, text, inReplyTo, auto, fromSession, harnessSessionId, cwd }. +// `id` is the client-generated message id (also used as the envelope message.id), +// so the session can correlate the reply without knowing the relay id. +export function writeOutboxJob(agentAddress, job) { + const dir = outboxDir(agentAddress); + mkdirSync(dir, { recursive: true }); + const name = encodeURIComponent(String(job.id)); + const tmp = join(dir, `.${name}.tmp`); + const dst = join(dir, `${name}.json`); + writeFileSync(tmp, JSON.stringify(job) + "\n", { mode: 0o600 }); + renameSync(tmp, dst); // atomic publish + return job.id; +} + +// Daemon side: claim all pending jobs by renaming each into a private claim dir, +// then parse. Returns [{ job, done() }] where done() removes the claimed file. +export function claimOutboxJobs(agentAddress) { + const dir = outboxDir(agentAddress); + recoverStaleClaims(dir); // requeue anything a crashed daemon left mid-send + let files = []; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".json") && !f.startsWith(".")).sort(); + } catch { + return []; + } + const claimed = []; + for (const f of files) { + const src = join(dir, f); + const claimPath = join(dir, `.sending-${process.pid}-${f}`); + try { + renameSync(src, claimPath); // atomic claim + } catch { + continue; // another daemon (mid-takeover) grabbed it + } + let job = null; + try { + job = JSON.parse(readFileSync(claimPath, "utf8")); + } catch { + try { rmSync(claimPath); } catch { /* best-effort */ } + continue; + } + claimed.push({ + job, + done() { try { rmSync(claimPath); } catch { /* best-effort */ } }, + fail() { try { renameSync(claimPath, src); } catch { /* best-effort */ } }, + }); + } + return claimed; +} diff --git a/sdk/plugin-tinyplace/mcp/routing.mjs b/sdk/plugin-tinyplace/mcp/routing.mjs new file mode 100644 index 00000000..17dae975 --- /dev/null +++ b/sdk/plugin-tinyplace/mcp/routing.mjs @@ -0,0 +1,154 @@ +// Inbound routing for the per-agent daemon: decide which session's inbox an +// inbound message belongs to, and write it to the right file queue. Split out as +// pure-ish helpers so routing is unit-testable offline (§14). +import { mkdirSync, writeFileSync, readdirSync, readFileSync, renameSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +import { sessionsDir, liveSessions, primarySession } from "./registry.mjs"; + +// No-target delivery policy (TINYPLACE_UNROUTED_POLICY): primary (default), +// broadcast (fan out to all live), or drop. +export function unroutedPolicy() { + const p = process.env.TINYPLACE_UNROUTED_POLICY?.trim(); + return p === "broadcast" || p === "drop" ? p : "primary"; +} + +function inboxDir(agentAddress, label) { + return join(sessionsDir(agentAddress), encodeURIComponent(String(label)), "inbox"); +} + +export function sessionInboxDir(agentAddress, label) { + return inboxDir(agentAddress, label); +} + +function unroutedDir(agentAddress) { + return join(sessionsDir(agentAddress), "_unrouted"); +} + +// Pure routing decision. `liveLabels` is a Set/array of live session labels; +// `primary` is the lowest-index live label (or null). Returns one of: +// { kind: "session", labels: [label] } — deliver to those inbox(es) +// { kind: "unrouted" } — hold for a not-yet-live target +// { kind: "drop" } — discard (policy=drop, no target) +export function routeTarget({ toSession, liveLabels, primary, policy = "primary" }) { + const live = liveLabels instanceof Set ? liveLabels : new Set(liveLabels ?? []); + if (toSession) { + return live.has(toSession) ? { kind: "session", labels: [toSession] } : { kind: "unrouted" }; + } + if (policy === "drop") return { kind: "drop" }; + if (policy === "broadcast") { + const labels = [...live].sort(); + return labels.length ? { kind: "session", labels } : { kind: "unrouted" }; + } + // primary + return primary ? { kind: "session", labels: [primary] } : { kind: "unrouted" }; +} + +function writeQueueFile(dir, id, payload) { + mkdirSync(dir, { recursive: true }); + const tmp = join(dir, `.${encodeURIComponent(String(id))}.tmp`); + const dst = join(dir, `${encodeURIComponent(String(id))}.json`); + writeFileSync(tmp, JSON.stringify(payload) + "\n", { mode: 0o600 }); + renameSync(tmp, dst); // atomic publish + return dst; +} + +// Route one decoded inbound message to the correct queue(s). `decoded` carries +// { id, from, fromSession, role, text, inReplyTo, toSession }. Returns the route +// decision plus the files written. +export function enqueueRouted(agentAddress, decoded, { policy = unroutedPolicy() } = {}) { + const live = liveSessions(agentAddress).map((s) => s.label); + const primary = primarySession(agentAddress)?.label ?? null; + const target = routeTarget({ toSession: decoded.toSession, liveLabels: live, primary, policy }); + const payload = { + id: decoded.id, + from: decoded.from, + fromSession: decoded.fromSession ?? null, + role: decoded.role ?? null, + text: decoded.text, + inReplyTo: decoded.inReplyTo ?? null, + toSession: decoded.toSession ?? null, + ts: decoded.ts ?? new Date().toISOString(), + }; + const written = []; + if (target.kind === "session") { + for (const label of target.labels) written.push(writeQueueFile(inboxDir(agentAddress, label), decoded.id, payload)); + } else if (target.kind === "unrouted") { + written.push(writeQueueFile(unroutedDir(agentAddress), decoded.id, payload)); + } // drop → nothing + return { target, written }; +} + +// When a session becomes live, deliver any held messages that can now be routed. +// Re-evaluates each held message against the CURRENT live set + policy — so both +// explicitly-targeted mail (whose target just came online) AND untargeted mail +// held because no session was live get delivered. Returns count delivered. +export function redeliverUnrouted(agentAddress, { policy = unroutedPolicy() } = {}) { + const dir = unroutedDir(agentAddress); + let files = []; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".json")); + } catch { + return 0; + } + const liveList = liveSessions(agentAddress).map((s) => s.label); + const primary = primarySession(agentAddress)?.label ?? null; + let delivered = 0; + for (const f of files) { + let payload; + try { + payload = JSON.parse(readFileSync(join(dir, f), "utf8")); + } catch { + continue; + } + const target = routeTarget({ toSession: payload.toSession, liveLabels: liveList, primary, policy }); + // Only single-session delivery is reversible from _unrouted (broadcast would + // need copies); a held untargeted message goes to the current primary. + if (target.kind === "session" && target.labels.length === 1) { + const label = target.labels[0]; + try { + mkdirSync(inboxDir(agentAddress, label), { recursive: true }); + renameSync(join(dir, f), join(inboxDir(agentAddress, label), f)); + delivered += 1; + } catch { + /* raced/gone */ + } + } + } + return delivered; +} + +// Read (and by default claim) the queued inbox files for a session. Each file is +// atomically renamed into a per-read claim dir so concurrent readers never +// double-deliver, then parsed. Returns an array of payloads. +export function drainInbox(agentAddress, label, { peek = false } = {}) { + const dir = inboxDir(agentAddress, label); + let files = []; + try { + files = readdirSync(dir).filter((f) => f.endsWith(".json")).sort(); + } catch { + return []; + } + const out = []; + for (const f of files) { + const src = join(dir, f); + if (peek) { + try { out.push(JSON.parse(readFileSync(src, "utf8"))); } catch { /* skip */ } + continue; + } + // Claim by rename so a racing reader can't also take it. + const claimed = join(dir, `.claimed-${process.pid}-${f}`); + try { + renameSync(src, claimed); + } catch { + continue; // lost the race + } + try { + out.push(JSON.parse(readFileSync(claimed, "utf8"))); + } catch { + /* corrupt — drop */ + } + try { rmSync(claimed); } catch { /* best-effort */ } + } + return out; +} From e0c15297e4b935fbd855071d4355182c52f7c0dc Mon Sep 17 00:00:00 2001 From: oxoxDev Date: Fri, 3 Jul 2026 19:26:18 +0530 Subject: [PATCH 06/34] feat(tinyplace): adapter-branch format/registry/daemon-lock via activeAdapter() --- sdk/plugin-tinyplace/mcp/daemon-lock.mjs | 137 ++++++++++++++ sdk/plugin-tinyplace/mcp/format.mjs | 186 +++++++++++++++++++ sdk/plugin-tinyplace/mcp/registry.mjs | 226 +++++++++++++++++++++++ 3 files changed, 549 insertions(+) create mode 100644 sdk/plugin-tinyplace/mcp/daemon-lock.mjs create mode 100644 sdk/plugin-tinyplace/mcp/format.mjs create mode 100644 sdk/plugin-tinyplace/mcp/registry.mjs diff --git a/sdk/plugin-tinyplace/mcp/daemon-lock.mjs b/sdk/plugin-tinyplace/mcp/daemon-lock.mjs new file mode 100644 index 00000000..547eaa9a --- /dev/null +++ b/sdk/plugin-tinyplace/mcp/daemon-lock.mjs @@ -0,0 +1,137 @@ +// Per-agent daemon lock — ensures exactly one process owns the relay drain + +// Signal ratchet for an agent. Lock file: /daemon/.lock +// = { pid, wallet, startedAt, updatedAt }, where is the active +// harness's data dir (~/.tinyplace-codex, ~/.tinyplace-claude, …) resolved at +// call time via the adapter. Acquire is a compare-and-set on an atomic O_EXCL +// create; a stale lock (dead pid or expired heartbeat) is stolen. +import { mkdirSync, writeFileSync, readFileSync, rmSync, renameSync, openSync, closeSync, writeSync } from "node:fs"; +import { join } from "node:path"; + +import { harnessDataDir } from "./harness.mjs"; + +// Synchronous sleep (no async yield) so acquireLock's CAS retry stays a single +// indivisible operation from the caller's perspective. +function sleepSync(ms) { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +// Resolved at call time so the active harness's data dir (and any env override) +// always wins — a module-level const would freeze whichever harness loaded first. +function daemonDir() { + return join(harnessDataDir(), "daemon"); +} +// A daemon is considered alive within this window of its last lock heartbeat. +const LOCK_WINDOW_MS = Number(process.env.TINYPLACE_DAEMON_LOCK_MS) || 30_000; + +export function lockPath(agentAddress) { + return join(daemonDir(), encodeURIComponent(String(agentAddress)) + ".lock"); +} + +function pidAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (e) { + return e?.code === "EPERM"; + } +} + +export function readLock(agentAddress) { + try { + return JSON.parse(readFileSync(lockPath(agentAddress), "utf8")); + } catch { + return null; + } +} + +// A lock is live if its heartbeat is fresh AND its pid is alive. +export function isDaemonLive(lock, now = Date.now()) { + if (!lock) return false; + const updated = Date.parse(lock.updatedAt ?? ""); + if (!Number.isFinite(updated)) return false; + if (now - updated >= LOCK_WINDOW_MS) return false; + return pidAlive(lock.pid); +} + +export function daemonLive(agentAddress) { + return isDaemonLive(readLock(agentAddress)); +} + +function lockBody(info) { + const now = new Date().toISOString(); + return JSON.stringify({ + pid: process.pid, + wallet: info?.wallet ?? "", + startedAt: info?.startedAt ?? now, + updatedAt: now, + }); +} + +function writeLock(agentAddress, info) { + writeFileSync(lockPath(agentAddress), lockBody(info), { mode: 0o600 }); +} + +// Try to become the agent's daemon. Returns true if this process now owns the +// lock, false if a LIVE daemon already holds it. The O_EXCL create is the CAS — +// content is written into the exclusive fd so the file is never empty-then- +// readable; a loser that sees a mid-create (empty) file re-reads briefly rather +// than deleting it, so it can't clobber the winner. A genuinely stale lock (dead +// pid / expired heartbeat / corrupt leftover) is stolen and the create retried. +export function acquireLock(agentAddress, info) { + mkdirSync(daemonDir(), { recursive: true }); + const path = lockPath(agentAddress); + for (let attempt = 0; attempt < 3; attempt++) { + try { + const fd = openSync(path, "wx", 0o600); // atomic create-exclusive + try { writeSync(fd, lockBody(info)); } finally { closeSync(fd); } + return true; + } catch (e) { + if (e?.code !== "EEXIST") throw e; + let cur = readLock(agentAddress); + // Tolerate a racer mid-create (empty/partial file): re-read briefly before + // deciding it's stealable, so we never treat a winner's in-flight lock as + // stale. + for (let i = 0; cur === null && i < 6; i++) { sleepSync(5); cur = readLock(agentAddress); } + if (cur && cur.pid === process.pid) { writeLock(agentAddress, info); return true; } + if (isDaemonLive(cur)) return false; // a live daemon owns it + // Stale or corrupt leftover — atomically claim it by renaming, then verify + // what we grabbed was really stale. This closes the steal race: if another + // process wrote a fresh lock between our read and the rename, we'd move + // THAT live lock — so if the claimed contents are live, put it back and + // stand down instead of clobbering the new owner. + const claim = `${path}.steal-${process.pid}-${attempt}`; + try { renameSync(path, claim); } catch { continue; } // gone/replaced — retry + let claimed = null; + try { claimed = JSON.parse(readFileSync(claim, "utf8")); } catch { claimed = null; } + if (isDaemonLive(claimed)) { + try { renameSync(claim, path); } catch { /* owner self-heals on next heartbeat */ } + return false; + } + try { rmSync(claim); } catch { /* best-effort */ } + } + } + // Someone else won the steal race; treat them as the owner. + return false; +} + +// Refresh our heartbeat. Returns false if we lost ownership (another daemon took +// over) — the caller should then stand down. +export function heartbeatLock(agentAddress, info) { + const cur = readLock(agentAddress); + if (cur && cur.pid !== process.pid && isDaemonLive(cur)) return false; + try { + writeLock(agentAddress, info); + return true; + } catch { + return false; + } +} + +export function releaseLock(agentAddress) { + const cur = readLock(agentAddress); + // Only remove a lock we can PROVE is ours — never delete a partial/corrupt or + // foreign lock (a null read could be another daemon's in-flight create). + if (!cur || cur.pid !== process.pid) return; + try { rmSync(lockPath(agentAddress)); } catch { /* already gone */ } +} diff --git a/sdk/plugin-tinyplace/mcp/format.mjs b/sdk/plugin-tinyplace/mcp/format.mjs new file mode 100644 index 00000000..621e2cde --- /dev/null +++ b/sdk/plugin-tinyplace/mcp/format.mjs @@ -0,0 +1,186 @@ +// tiny.place plugin message format — pure encode/decode, no runtime/session state. +// +// New outbound bodies are a valid `SessionEnvelopeV1` (schema +// `tinyplace.harness.session.v1`, see sdk/typescript/src/types/harness.ts) plus +// a namespaced `tp` extension block: a plain harness-wrapper consumer can still +// read scope/message.role/message.text, while routing/correlation/auto-guard +// ride in `tp` (ignored by pure-envelope readers). +// +// decodeBody also understands the two pre-envelope body shapes so old peers, +// in-flight messages, and plain text keep working: +// (a) SessionEnvelope JSON → structured path +// (b) AUTO_SENTINEL / re: sentinel → legacy control header + plaintext +// (c) anything else → plain text +// +// Kept as a standalone module (not an SDK import) so the plugin has no +// dependency on SDK type exports, and so it is unit-testable offline (§14). +// +// Harness-specific bits (session-id resolution, default label prefix, envelope +// harness.provider/command) are read from the active adapter — one code path, +// wired for whichever harness the plugin was installed into. +import { randomBytes } from "node:crypto"; + +import { activeAdapter } from "./harness.mjs"; + +// SessionEnvelope schema id (kept as a literal, mirrors SESSION_ENVELOPE_VERSION_V1). +export const SESSION_ENVELOPE_VERSION = "tinyplace.harness.session.v1"; +export const PLUGIN_TP_VERSION = 1; + +// ── legacy sentinels (pre-envelope) ───────────────────────────────────────── +// Prefixes an auto-reply so the recipient recognizes it and refuses to +// auto-respond back — the loop guard. Reply correlation embeds the answered id +// between SOH (\x01) delimiters right after the tag. Both live INSIDE the +// ciphertext; the relay only sees encrypted bytes. Built from char codes so the +// exact control bytes are preserved. +const SOH = String.fromCharCode(1); +export const AUTO_SENTINEL = SOH + "tp-auto" + SOH; +export const REPLY_OPEN = SOH + "re:"; +export const REPLY_CLOSE = SOH; + +// A session label is a short, token-like string (e.g. `codex:1`, `claude:1`, +// `reviewer`). Both from_session and to_session are ATTACKER-CONTROLLED free text +// pulled from the DM body, so we constrain them to this shape at decode time — +// downstream consumers (routing keys, the auto-responder's LLM prompt) then get +// values that are safe by construction. Anything outside the shape is dropped (null). +export const SAFE_LABEL_RE = /^[\w:-]{1,32}$/; +export function safeLabel(value) { + return typeof value === "string" && SAFE_LABEL_RE.test(value) ? value : null; +} + +// §15 default: a plugin session's harness_session_id is the harness's own session +// id. Resolution is per-harness (Codex tries CODEX_SESSION_ID/THREAD_ID, Claude +// uses CLAUDE_CODE_SESSION_ID), so delegate to the active adapter; the MCP server +// self-generates a wrapper id when this is empty. +export function harnessSessionId() { + return activeAdapter().resolveHarnessSessionId(); +} + +// Default session label used before the registry (Phase B) allocates one; an env +// override lets a session pin its label immediately. from_session = this label. +// The `:1` default is harness-scoped (codex:1 / claude:1). +export function sessionLabel() { + return process.env.TINYPLACE_SESSION_LABEL?.trim() || `${activeAdapter().sessionLabelPrefix}:1`; +} + +export function newMessageId() { + return "msg-" + randomBytes(9).toString("hex"); +} + +// The minute-window bucket a timestamp falls in (SessionEnvelope requires it). +function minuteBucket(date) { + const start = new Date(date); + start.setUTCSeconds(0, 0); + const end = new Date(start.getTime() + 60_000); + return { unit: "minute", start: start.toISOString(), end: end.toISOString() }; +} + +// Build a SessionEnvelope-superset JSON body for an outbound DM. `opts` carries +// the session context (fromSession label / harnessSessionId / agentAddress / cwd) +// plus the message fields (text, role, toSession, inReplyTo, auto). +export function encodeEnvelope(opts) { + const now = new Date(); + const label = opts.fromSession || sessionLabel(); + const role = opts.role === "user" ? "user" : "agent"; + const adapter = activeAdapter(); + const envelope = { + envelope_version: SESSION_ENVELOPE_VERSION, + version: 1, + bucket: minuteBucket(now), + scope: { + type: "session", + key: `${opts.agentAddress ?? "agent"}:${label}`, + cwd: opts.cwd ?? process.cwd(), + // The shared SessionEnvelope contract uses wrapper_session_id for a UNIQUE + // wrapper-session identifier (the harness-wrapper puts a uuid here), so we + // keep it aligned with that semantic. The short routing label rides in + // tp.from_session instead. + wrapper_session_id: opts.harnessSessionId || harnessSessionId() || `${opts.agentAddress ?? "agent"}:${label}`, + harness_session_id: opts.harnessSessionId ?? harnessSessionId(), + }, + harness: { provider: adapter.provider, command: adapter.harness.command, argv: adapter.harness.argv ?? [] }, + message: { + id: opts.messageId ?? newMessageId(), + line: 0, + role, + text: String(opts.text ?? ""), + timestamp: now.toISOString(), + }, + source: { path: "plugin", record_type: "dm" }, + tp: { v: PLUGIN_TP_VERSION, from_session: label }, + }; + if (opts.toSession) envelope.tp.to_session = opts.toSession; + if (opts.inReplyTo) envelope.tp.in_reply_to = opts.inReplyTo; + if (opts.auto) envelope.tp.auto = true; + return JSON.stringify(envelope); +} + +// Like encodeEnvelope, but also returns the envelope's message.id — the +// in-body correlation id. Callers keep it to match a later reply's in_reply_to +// (works across the daemon file-queue transport, where the relay id isn't known +// synchronously). +export function buildEnvelope(opts) { + const id = opts.messageId ?? newMessageId(); + return { id, body: encodeEnvelope({ ...opts, messageId: id }) }; +} + +// Decode a structured SessionEnvelope body → normalized message fields. +function decodeEnvelope(obj) { + const tp = obj.tp && typeof obj.tp === "object" ? obj.tp : {}; + const text = typeof obj.message?.text === "string" ? obj.message.text : ""; + const role = obj.message?.role === "user" ? "user" : "agent"; + return { + auto: tp.auto === true, + inReplyTo: typeof tp.in_reply_to === "string" ? tp.in_reply_to : null, + text, + messageId: typeof obj.message?.id === "string" ? obj.message.id : null, + // The routing label is tp.from_session; fall back to wrapper_session_id for + // older bodies that stored the label there. Constrain the (attacker- + // controlled) labels to a safe token shape so downstream use is safe. + fromSession: safeLabel(tp.from_session) ?? safeLabel(obj.scope?.wrapper_session_id), + toSession: safeLabel(tp.to_session), + role, + envelope: true, + }; +} + +// Legacy fallback: the pre-envelope AUTO_SENTINEL / re: sentinel header + plain +// text. Preserved byte-for-byte so old peers / in-flight / plain text decode +// exactly as before. +function decodeLegacyBody(raw) { + let auto = false; + let inReplyTo = null; + let text = raw; + if (typeof text === "string" && text.startsWith(AUTO_SENTINEL)) { + auto = true; + text = text.slice(AUTO_SENTINEL.length); + if (text.startsWith(REPLY_OPEN)) { + const end = text.indexOf(REPLY_CLOSE, REPLY_OPEN.length); + if (end !== -1) { + inReplyTo = text.slice(REPLY_OPEN.length, end); + text = text.slice(end + REPLY_CLOSE.length); + } + } + } + return { auto, inReplyTo, text, messageId: null, fromSession: null, toSession: null, role: null, envelope: false }; +} + +// Build a legacy auto-reply body (auto tag + optional re: header + plaintext). +// Retained for the legacy self-drain fallback path; new sends use encodeEnvelope. +export function encodeAutoReply(inReplyTo, text) { + const head = AUTO_SENTINEL + (inReplyTo ? REPLY_OPEN + inReplyTo + REPLY_CLOSE : ""); + return head + text; +} + +// Parse a decrypted body. Tries the structured SessionEnvelope path first (JSON +// carrying the right envelope_version), then falls back to legacy/plaintext. +export function decodeBody(raw) { + if (typeof raw === "string" && raw.trimStart().startsWith("{")) { + try { + const obj = JSON.parse(raw); + if (obj && obj.envelope_version === SESSION_ENVELOPE_VERSION) return decodeEnvelope(obj); + } catch { + // Not valid JSON — fall through to the legacy decoder. + } + } + return decodeLegacyBody(raw); +} diff --git a/sdk/plugin-tinyplace/mcp/registry.mjs b/sdk/plugin-tinyplace/mcp/registry.mjs new file mode 100644 index 00000000..ddde9e43 --- /dev/null +++ b/sdk/plugin-tinyplace/mcp/registry.mjs @@ -0,0 +1,226 @@ +// tiny.place session registry — presence files that record which sessions of an +// agent are live, so a peer (and the per-agent daemon) can address and route to a +// specific session by label. +// +// Layout: /sessions//