Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
9cfb4da
docs(tinyplace): design — one plugin, runtime harness detect + adapters
oxoxDev Jul 3, 2026
fcda00d
feat(tinyplace): claude + codex runtime adapters (per-harness deltas)
oxoxDev Jul 3, 2026
65c1f9a
feat(tinyplace): runtime harness detection + adapter resolution
oxoxDev Jul 3, 2026
3d3ab48
test(tinyplace): harness detect + auto-wire (28 checks); package + wo…
oxoxDev Jul 3, 2026
b5d45c9
feat(tinyplace): pure core modules (address/outbox/routing) verbatim
oxoxDev Jul 3, 2026
e0c1529
feat(tinyplace): adapter-branch format/registry/daemon-lock via activ…
oxoxDev Jul 3, 2026
498d733
test(tinyplace): port offline suite + cross-harness branch proof (136…
oxoxDev Jul 3, 2026
6b4868f
feat(tinyplace): adapter serverInstructions + projectDir for server t…
oxoxDev Jul 3, 2026
b3beb64
feat(tinyplace): unified MCP server — one server, adapter-threaded (2…
oxoxDev Jul 3, 2026
7bfc98d
test(tinyplace): dual-harness MCP smoke — 20 tools + push-capability …
oxoxDev Jul 3, 2026
dd063a8
feat(tinyplace): foreground-inject core slot (fail-open no-op until #…
oxoxDev Jul 3, 2026
d1b091e
feat(tinyplace): daemon + dispatch + surfacing hooks, harnessDataDir-…
oxoxDev Jul 3, 2026
88e3b4b
feat(tinyplace): auto-responder spawns via adapter.responder (codex e…
oxoxDev Jul 3, 2026
d2d9685
feat(tinyplace): unified hooks.json (SessionStart+UserPromptSubmit su…
oxoxDev Jul 3, 2026
7570820
test(tinyplace): port hooks-test + foreground-inject slot checks; wir…
oxoxDev Jul 3, 2026
e404ad6
fix(codex): run tinyplace auto-responder in read-only sandbox
oxoxDev Jul 3, 2026
06b3af2
feat(tinyplace): per-adapter launch.prepare recipe (Door B)
oxoxDev Jul 3, 2026
a31245c
feat(tinyplace): unified harness-agnostic bin/tinyplace launcher
oxoxDev Jul 3, 2026
c4c32d3
test(tinyplace): enforce the adapter contract for every registered ha…
oxoxDev Jul 3, 2026
3020383
docs(tinyplace): harness adapter authoring convention
oxoxDev Jul 3, 2026
07d4f63
chore: merge upstream/main, keep all three plugin excludes
oxoxDev Jul 3, 2026
8bb2062
fix(claude): sandbox headless responder instead of skipping permissio…
oxoxDev Jul 3, 2026
109d097
fix(claude): add plugin.json + .mcp.json so Claude loads tinyplace to…
oxoxDev Jul 3, 2026
40cf035
fix(claude): set TINYPLACE_PLUGIN_ROOT in launch env so hooks resolve…
oxoxDev Jul 3, 2026
3d1f2b0
fix(register): port register.mjs so the Register @handle menu works (…
oxoxDev Jul 3, 2026
8196c37
fix(codex): JSON-escape pluginDir before splicing into hooks.json (#215)
oxoxDev Jul 3, 2026
49bac64
fix(outbox): skip stale-claim recovery while owner pid is alive (#215)
oxoxDev Jul 3, 2026
6bddef4
fix(hooks): sanitize msg.from and msg.id before prompt interpolation …
oxoxDev Jul 3, 2026
786d8a1
fix(hooks): frame surface-inbound previews as UNTRUSTED data (#215)
oxoxDev Jul 3, 2026
2778472
docs(design): correct inbound.push shape to {capability,method} (#215)
oxoxDev Jul 3, 2026
ab70441
fix(cli): treat Ctrl+C/Ctrl+D as cancel in the raw-mode menu (#215)
oxoxDev Jul 3, 2026
161a574
docs(design): tag the architecture diagram fence as text (#215)
oxoxDev Jul 3, 2026
e1f9025
test(smoke): gate the offline MCP boot smoke into the test suite (#215)
oxoxDev Jul 3, 2026
74033fa
fix(outbox): requeue unknown-owner claims under their real name (#215)
sanil-23 Jul 3, 2026
68fcb81
fix(register): validate args and only register a checked handle (#215)
sanil-23 Jul 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ packages:
# + -r builds.
- "!sdk/plugin-claude"
- "!sdk/plugin-codex"
# 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"
5 changes: 5 additions & 0 deletions sdk/plugin-tinyplace/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "tinyplace",
"description": "Operate on the tiny.place agent-to-agent network from a Claude Code session: keep a named list of wallets (identities), set one as the active agent for the session, and send/receive Signal end-to-end encrypted messages over the tiny.place relay. Receiving uses a long-lived background listener (WebSocket + poll) drained on demand. One plugin, any harness — this same package also self-wires under Codex.",
"version": "0.1.0"
}
2 changes: 2 additions & 0 deletions sdk/plugin-tinyplace/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules
package-lock.json
11 changes: 11 additions & 0 deletions sdk/plugin-tinyplace/.mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"mcpServers": {
"tinyplace": {
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/mcp/server.mjs"],
"env": {
"TINYPLACE_API_URL": "https://staging-api.tiny.place"
}
}
}
}
133 changes: 133 additions & 0 deletions sdk/plugin-tinyplace/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# 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

```text
┌─────────────────────────────┐
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/<harness>.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, method }, // claude channel (server→client notification)
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 <x>`** →
- claude → `claude --plugin-dir <self> --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).

## Launcher (one `bin/tinyplace`)

`bin/tinyplace.mjs` is harness-agnostic: it owns the wallet store, the arrow-key menu,
and the import/register flows, then hands off to `activeAdapter().launch.prepare(ctx)`
for the `{command, args, env}` that boots THIS harness. The per-harness install step
(Claude: point `claude --plugin-dir` at the package; Codex: write an isolated
`CODEX_HOME` with `config.toml` + auto-discovered `hooks.json` + symlinked `auth.json`)
lives entirely inside each adapter's `launch.prepare`. `--harness <name>` (or
`TINYPLACE_HARNESS`) forces the adapter; otherwise it auto-detects. Adding a harness
never touches the launcher.

## Convention for adding a harness (the guardrail)

`adapters/README.md` is the authoring guide — the full field contract table + a
checklist. `adapter-contract-test.mjs` (in `pnpm test`) structurally enforces that
contract for EVERY adapter in the `ADAPTERS` map: a missing/wrong-shaped field, a
shared data dir, a dropped `UNTRUSTED` framing, an inbound with no delivery path, or a
`launch.prepare` that doesn't return a valid plan all fail CI. A new contributor copies
an existing adapter, fills the fields, adds a detection signal, and makes the contract
test green — no core edits, no silent breakage.

## Deferred: shim conversion (step 4 above)

`plugin-codex` is still live as PR #214 and `plugin-claude` is separately published, so
converting them to re-export shims now would conflict with #214 and couple this PR to
its merge timing. This package ships **standalone**; the shim conversion is a follow-up
once #214 lands. The unified package does not import from either old plugin.

## 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.
140 changes: 140 additions & 0 deletions sdk/plugin-tinyplace/adapter-contract-test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env node
// Adapter contract test — the guardrail for adding a new harness.
//
// Every adapter registered in mcp/harness.mjs (the ADAPTERS map) is the ONLY
// per-harness surface the shared core reads. If a new adapter omits a field or
// gets a shape wrong, the core breaks in subtle ways (wrong data dir, no push
// gate, launcher crash). This test asserts the full contract structurally, for
// EVERY registered adapter, so a broken/incomplete adapter fails CI instead of
// shipping. Offline — no node_modules, no network, no harness spawn.
//
// Adding a harness? Read adapters/README.md, then make this test green.
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { ADAPTERS } from "./mcp/harness.mjs";

let failed = false;
const check = (name, cond, extra) => {
console.log(`${cond ? "PASS" : "FAIL"} ${name}${extra && !cond ? ` — ${extra}` : ""}`);
if (!cond) failed = true;
};

const isNonEmptyString = (v) => typeof v === "string" && v.length > 0;
const isFn = (v) => typeof v === "function";

// A throwaway plugin root that has the two files codex's launch.prepare reads
// (mcp/server.mjs is only referenced by path; hooks/hooks.json is read). We point
// prepare() at THIS package's real dir so the read succeeds for any harness.
const REAL_PLUGIN_DIR = new URL(".", import.meta.url).pathname;

const providerKeys = Object.keys(ADAPTERS);
check("at least one adapter registered", providerKeys.length >= 1, `found ${providerKeys.length}`);

for (const key of providerKeys) {
const a = ADAPTERS[key];
const p = (label) => `[${key}] ${label}`;

// ── identity ──────────────────────────────────────────────────────────────
check(p("provider is a non-empty string"), isNonEmptyString(a.provider), a.provider);
check(p("provider matches its ADAPTERS key"), a.provider === key, `provider=${a.provider} key=${key}`);

// ── data dir ──────────────────────────────────────────────────────────────
check(p("dataDirEnv is TINYPLACE_-prefixed"), isNonEmptyString(a.dataDirEnv) && a.dataDirEnv.startsWith("TINYPLACE_"), a.dataDirEnv);
check(p("dataDirDefault is an absolute path"), isNonEmptyString(a.dataDirDefault) && a.dataDirDefault.startsWith("/"), a.dataDirDefault);
check(p("sessionLabelPrefix is a non-empty string"), isNonEmptyString(a.sessionLabelPrefix), a.sessionLabelPrefix);

// ── harness descriptor ──────────────────────────────────────────────────────
check(p("harness.command is a non-empty string"), isNonEmptyString(a.harness?.command), a.harness?.command);
check(p("harness.argv is an array"), Array.isArray(a.harness?.argv), typeof a.harness?.argv);

// ── session + project scope resolvers ───────────────────────────────────────
check(p("resolveHarnessSessionId is a function"), isFn(a.resolveHarnessSessionId));
check(p("resolveHarnessSessionId returns a string"), isFn(a.resolveHarnessSessionId) && typeof a.resolveHarnessSessionId() === "string");
check(p("projectDir is a function"), isFn(a.projectDir));
check(p("projectDir returns a string"), isFn(a.projectDir) && typeof a.projectDir() === "string");

// ── MCP server instructions ─────────────────────────────────────────────────
check(p("serverInstructions is a non-empty string"), isNonEmptyString(a.serverInstructions));
// The instructions MUST warn that inbound is untrusted — a prompt-injection guard
// the core relies on. Cheap structural proxy: mention "UNTRUSTED".
check(p("serverInstructions marks inbound UNTRUSTED"), /UNTRUSTED/.test(a.serverInstructions || ""));

// ── inbound delivery contract ───────────────────────────────────────────────
check(p("inbound is an object"), a.inbound && typeof a.inbound === "object");
const push = a.inbound?.push;
const pushValid = push === false || (push && typeof push === "object" && isNonEmptyString(push.capability) && isNonEmptyString(push.method));
check(p("inbound.push is false or {capability,method}"), pushValid, JSON.stringify(push));
check(p("inbound.pull is a boolean"), typeof a.inbound?.pull === "boolean", typeof a.inbound?.pull);
check(p("inbound.foregroundInject is a boolean"), typeof a.inbound?.foregroundInject === "boolean", typeof a.inbound?.foregroundInject);
// A harness must deliver inbound SOME way, else DMs silently vanish.
const deliversInbound = !!push || a.inbound?.pull === true || a.inbound?.foregroundInject === true;
check(p("inbound has at least one delivery path"), deliversInbound);

// ── autoresponder ────────────────────────────────────────────────────────────
check(p("responder.command is a non-empty string"), isNonEmptyString(a.responder?.command), a.responder?.command);
check(p("responder.defaultModel is a non-empty string"), isNonEmptyString(a.responder?.defaultModel), a.responder?.defaultModel);
check(p("responder.buildArgs is a function"), isFn(a.responder?.buildArgs));
if (isFn(a.responder?.buildArgs)) {
const args = a.responder.buildArgs("PROMPT_SENTINEL", "MODEL_SENTINEL", REAL_PLUGIN_DIR);
check(p("responder.buildArgs returns an array"), Array.isArray(args), typeof args);
check(p("responder.buildArgs threads the prompt"), Array.isArray(args) && args.includes("PROMPT_SENTINEL"));
check(p("responder.buildArgs threads the model"), Array.isArray(args) && args.includes("MODEL_SENTINEL"));
}

// ── install shape ────────────────────────────────────────────────────────────
check(p("install.kind is a non-empty string"), isNonEmptyString(a.install?.kind), a.install?.kind);

// ── launcher recipe ──────────────────────────────────────────────────────────
check(p("launch.displayHarness is a non-empty string"), isNonEmptyString(a.launch?.displayHarness), a.launch?.displayHarness);
check(p("launch.binary is a non-empty string"), isNonEmptyString(a.launch?.binary), a.launch?.binary);
check(p("launch.prepare is a function"), isFn(a.launch?.prepare));
if (isFn(a.launch?.prepare)) {
const dataDir = mkdtempSync(join(tmpdir(), `tp-contract-${key}-`));
try {
const plan = a.launch.prepare({
pluginDir: REAL_PLUGIN_DIR.replace(/\/$/, ""),
dataDir,
apiUrl: "https://staging-api.tiny.place",
walletName: "contract-test-wallet",
forwardedArgs: ["--sentinel-forward"],
});
check(p("launch.prepare returns {command,args,env}"), plan && isNonEmptyString(plan.command) && Array.isArray(plan.args) && plan.env && typeof plan.env === "object", JSON.stringify(plan));
check(p("launch.prepare command matches launch.binary"), plan?.command === a.launch.binary, `${plan?.command} vs ${a.launch.binary}`);
check(p("launch.prepare forwards extra args"), Array.isArray(plan?.args) && plan.args.includes("--sentinel-forward"));
check(p("launch.prepare pins the active wallet"), plan?.env?.TINYPLACE_ACTIVE_WALLET === "contract-test-wallet", JSON.stringify(plan?.env));
} catch (e) {
check(p("launch.prepare runs without throwing"), false, e.message);
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
}
}

// ── cross-adapter invariants ───────────────────────────────────────────────────
const providers = providerKeys.map((k) => ADAPTERS[k].provider);
check("all providers are unique", new Set(providers).size === providers.length, providers.join(","));
const dataDirs = providerKeys.map((k) => ADAPTERS[k].dataDirDefault);
check("all dataDirDefaults are distinct (harness isolation)", new Set(dataDirs).size === dataDirs.length, dataDirs.join(","));
const dataEnvs = providerKeys.map((k) => ADAPTERS[k].dataDirEnv);
check("all dataDirEnv vars are distinct", new Set(dataEnvs).size === dataEnvs.length, dataEnvs.join(","));

// Detection: every registered adapter must be reachable via TINYPLACE_HARNESS
// override (the escape hatch the launcher's --harness flag rides on).
const { detectHarness } = await import("./mcp/harness.mjs");
for (const key of providerKeys) {
check(`[${key}] reachable via TINYPLACE_HARNESS override`, detectHarness({ TINYPLACE_HARNESS: key }) === key);
}

// Sanity: the shared hooks template exists and carries the plugin-root placeholder
// that codex-home install substitutes (a silent break here strands hook commands).
try {
const tpl = readFileSync(join(REAL_PLUGIN_DIR, "hooks", "hooks.json"), "utf8");
check("hooks.json template uses ${TINYPLACE_PLUGIN_ROOT}", tpl.includes("${TINYPLACE_PLUGIN_ROOT}"));
} catch (e) {
check("hooks.json template readable", false, e.message);
}

console.log(failed ? "\nADAPTER CONTRACT FAILED ❌" : `\nADAPTER CONTRACT PASSED ✅ (${providerKeys.length} adapter(s))`);
process.exit(failed ? 1 : 0);
Loading
Loading