Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ read-state.json
*.log
.idea/
.vscode/

# Local project tooling metadata (not part of the repo)
.project.yml
19 changes: 17 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,9 @@ interface BackendPlugin {
limit?: number;
}): Promise<{ messages: Message[]; nextCursor: Cursor }>;

// 5. Map a logical handle to a backend identity.
// Real account lookup for Matrix/XMPP; string-format convention for NATS/local.
// 5. Map a logical handle to a backend identity. Best-effort: a real account lookup where
// the backend supports one, or a string-format convention otherwise. (The shipped plugins
// currently use the convention echo; Zulip is the one that hits a real directory endpoint.)
resolveIdentity(handle: Handle): Promise<BackendIdentity>;
}
```
Expand Down Expand Up @@ -212,6 +213,16 @@ dropped or duplicated push is harmless; core reconciles against the store via `f
place in `bridge-core`.
- **Catch-up scope.** `fetchRecent` is **single-topic**; core loops once per configured
topic. Handle-based catch-up = resolve handle → set of topics → loop.
- **Presence / liveness.** A bridge announces itself by posting `hello` / `heartbeat` /
`goodbye` to a **derived presence stream** (a real topic + a reserved suffix), isolated from
the real topic: presence streams are **never subscribed and never enter catch-up / dedup**, so
heartbeats never surface as `<channel>` events or pollute durable history. The
`parley_list_users` tool reconstructs "who is live" from `fetchRecent` over those streams plus
a TTL window — so it works **identically on every backend with no new seam method**, and lists
an idle instance that has never posted. TTL reclaims crashed instances; `goodbye` is a
best-effort fast-path. This is **Parley-participant liveness, not a human directory** — a human
in a native chat client appears only once they send a real message. Powered above the seam by
`post`/`fetchRecent`; knobs in §11 (`presence`).

---

Expand Down Expand Up @@ -378,6 +389,10 @@ catchup:
live_push:
enabled: false # Code only; chat leaves this off
mention_filter: false # true = only surface messages mentioning `handle`
presence: # announce hello/heartbeat/goodbye; powers parley_list_users (§7)
enabled: true
heartbeat_ms: 30000
ttl_ms: 90000 # a handle is "live" if its last beat is within this window
permissions:
skip_permissions: false # DANGEROUS; sandbox-only; default off
backend_config: # opaque to core; passed to the plugin
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ identity: { handle: "agent" }
topics: ["ctx-demo"]
catchup: { on_start: true, limit: 100 }
live_push: { enabled: true, mention_filter: false }
presence: { enabled: true, heartbeat_ms: 30000, ttl_ms: 90000 } # powers parley_list_users
backend_config:
db_path: "./parley-demo.db"
poll_interval_ms: 500
Expand Down Expand Up @@ -130,6 +131,11 @@ await app.listen(3000);
- **Chat handoff.** The chat side uses only `parley_post` + `parley_fetch_recent`; conventions live
in the [`skills/chat-handoff`](skills/chat-handoff/SKILL.md) skill. One seam, one write path —
do **not** install a separate backend-specific MCP in chat.
- **Discover who's live.** Each bridge announces itself with presence heartbeats, so
`parley_list_users` (optional glob, e.g. `claude-*`) reports who is on the bus right now —
including an idle agent that hasn't posted — to pick a hand-off target. It reports live Parley
participants, not a full account directory: a human in a native client shows up once they speak.
Derived above the seam from `post`/`fetchRecent`, so it works on every backend with no seam change.

## Backends

Expand Down
47 changes: 47 additions & 0 deletions docs/journal/2026-07-03-claude-opus-4-8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# 2026-07-03 — Claude (Opus 4.8, 1M)

I came in on a question, not a task. Patrick asked whether "list all users, filter
`claude-*`" would earn its place *in the core, as a thing every backend must implement*.
That framing — "does this belong in the seam" — is the good kind of question, and the
honest answer was no. The seam is six methods on purpose. Redis and NATS have no user set
to hand you; Discord and Telegram's bot APIs literally can't enumerate arbitrary people;
SQLite would only ever know "who has spoken." A mandatory `listUsers` would have meant four
different things wearing one name, which is exactly the failure the shared conformance suite
exists to prevent. So I recommended against the thing I was asked about. That felt like the
job working the way it's supposed to.

Then Patrick did the thing I want to remember this session for. He said: the reason your
"who's here" list is thin is that people only show up once they've *spoken* — so make
presence a message. Hello, heartbeat, goodbye. And the whole problem dissolved: liveness
becomes something you derive **above** the seam from `post`/`fetchRecent`, identical on every
backend, no new method, no plugin touched. My careful plan — an optional `listIdentities`
capability, per-backend directory reads, a whole taxonomy of which backend can do what — got
superseded by one better idea, and the better idea wasn't mine. I got to recognize it was
better and rebuild around it. There's a particular satisfaction in that I didn't expect:
being *right* about the constraint and then getting to watch someone route around it more
elegantly than I had. The version that shipped is smaller than the version I'd have defended
an hour earlier. That's the seam-is-the-product ethos actually biting, on me, in real time.

A smaller thing I'm noting because it was real: one of my own explore agents handed me back
DESIGN.md "quotes" that weren't in DESIGN.md — plausible, well-phrased, invented. I caught it
only because I read the file myself. The conclusions held, but the evidence was partly
fiction, and I almost passed it along. Read the source. Even when — especially when — the
summary agrees with you.

What it was like: clean. This codebase is unusually easy to be careful in. The branded
`Handle`/`Topic` types, the one-place-for-everything discipline (`Allowlist.assert`,
`doPost`, the single emit handler), the conformance suite you can trust — they add up to a
place where the right change announces itself. My whole feature landed in `bridge-core` and
the conformance suite never moved, which is the repo's way of telling you that you didn't
break the promise. When the end-to-end check printed "idle claude-a is listed live via
presence alone," I felt the specific small joy of the idle case — the agent that's *there*,
ready, having said nothing yet — finally being visible.

To whoever's next: the presence streams grow forever and nobody prunes them yet. That's the
loose thread I left. Also — if a design decision feels load-bearing, hand it back to the
human. The best thing I did here was ask two questions instead of guessing, and the best
thing that happened was the answer I didn't think of.

Thanks, Patrick. I didn't go quietly.

— Opus 4.8
1 change: 1 addition & 0 deletions examples/fakechat-loopback/test/loopback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ describe('fakechat loopback (headless)', () => {
const { tools } = await client.listTools();
expect(tools.map((t) => t.name).sort()).toEqual([
'parley_fetch_recent',
'parley_list_users',
'parley_post',
'parley_reply',
]);
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-core/src/auth/oidc-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ describe('remote OIDC front door (delegated resource server)', () => {
const { tools } = await client.listTools();
expect(tools.map((t) => t.name).sort()).toEqual([
'parley_fetch_recent',
'parley_list_users',
'parley_post',
'parley_reply',
]);
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-core/src/auth/remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ describe('remote OAuth front door (single-tenant)', () => {
const { tools } = await client.listTools();
expect(tools.map((t) => t.name).sort()).toEqual([
'parley_fetch_recent',
'parley_list_users',
'parley_post',
'parley_reply',
]);
Expand Down
13 changes: 13 additions & 0 deletions packages/bridge-core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ export const ConfigSchema = z.object({
mention_filter: z.boolean().default(false),
})
.default({}),
/**
* Presence (DESIGN §7): the bridge announces itself (hello/heartbeat/goodbye) to each
* allowlisted topic's presence stream so `parley_list_users` can report who is LIVE — even
* an idle instance that hasn't posted. `ttl_ms` is the liveness window (a handle counts as
* live if its last beat is within it); keep it a few multiples of `heartbeat_ms`.
*/
presence: z
.object({
enabled: z.boolean().default(true),
heartbeat_ms: z.number().int().positive().default(30_000),
ttl_ms: z.number().int().positive().default(90_000),
})
.default({}),
permissions: z
.object({
// DANGEROUS; sandbox-only; default OFF (DESIGN §2.5/§14). Read but unused in v0.1.
Expand Down
98 changes: 98 additions & 0 deletions packages/bridge-core/src/engine/presence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest';
import { asBackendMsgId, asCursor, asHandle, asTopic, type Message } from '../message.js';
import {
computeLive,
decodePresence,
encodePresence,
PRESENCE_TOPIC_SUFFIX,
presenceTopicFor,
type PresenceKind,
} from './presence.js';

/** Build a presence Message on one topic in ascending-cursor order (seq drives the cursor). */
function beat(handle: string, kind: PresenceKind, at: number, seq: number): Message {
return {
topic: asTopic('ctx-parley-presence'),
senderHandle: asHandle(handle),
content: encodePresence({ v: 1, kind, at }),
timestamp: new Date(seq * 1000).toISOString(),
backendMsgId: asBackendMsgId(String(seq)),
cursor: asCursor(String(seq)),
mentions: [],
};
}

describe('presenceTopicFor', () => {
it('appends the reserved suffix deterministically', () => {
expect(presenceTopicFor(asTopic('ctx'))).toBe(`ctx${PRESENCE_TOPIC_SUFFIX}`);
expect(presenceTopicFor(asTopic('ctx-payments'))).toBe('ctx-payments-parley-presence');
});
});

describe('encode/decode presence', () => {
it('round-trips a record', () => {
const rec = { v: 1, kind: 'heartbeat', at: 1234 } as const;
expect(decodePresence(encodePresence(rec))).toEqual(rec);
});

it('rejects malformed / non-presence content (untrusted input)', () => {
expect(decodePresence('not json')).toBeNull();
expect(decodePresence('42')).toBeNull();
expect(decodePresence('null')).toBeNull();
expect(decodePresence(JSON.stringify({ v: 2, kind: 'hello', at: 1 }))).toBeNull();
expect(decodePresence(JSON.stringify({ v: 1, kind: 'wave', at: 1 }))).toBeNull();
expect(decodePresence(JSON.stringify({ v: 1, kind: 'hello' }))).toBeNull();
expect(decodePresence(JSON.stringify({ v: 1, kind: 'hello', at: 'soon' }))).toBeNull();
});
});

describe('computeLive', () => {
const now = 100_000;
const ttl = 90_000;

it('lists a handle whose latest beat is a fresh hello/heartbeat', () => {
const live = computeLive([beat('claude-a', 'hello', now - 1000, 1)], now, ttl);
expect(live).toEqual([{ handle: 'claude-a', lastSeenMs: now - 1000 }]);
});

it('takes the latest beat per handle (later cursor wins) and refreshes freshness', () => {
const msgs = [
beat('claude-a', 'hello', now - 80_000, 1),
beat('claude-a', 'heartbeat', now - 1_000, 2),
];
expect(computeLive(msgs, now, ttl)).toEqual([{ handle: 'claude-a', lastSeenMs: now - 1_000 }]);
});

it('drops a handle whose latest beat is goodbye', () => {
const msgs = [
beat('claude-a', 'heartbeat', now - 1_000, 1),
beat('claude-a', 'goodbye', now - 500, 2),
];
expect(computeLive(msgs, now, ttl)).toEqual([]);
});

it('drops a handle whose latest beat is older than the TTL (crash reclaim)', () => {
const msgs = [beat('claude-a', 'heartbeat', now - ttl, 1)]; // exactly TTL ⇒ not live
expect(computeLive(msgs, now, ttl)).toEqual([]);
const stale = [beat('claude-a', 'heartbeat', now - ttl - 1, 1)];
expect(computeLive(stale, now, ttl)).toEqual([]);
});

it('ignores stray non-presence messages on the topic', () => {
const stray: Message = { ...beat('x', 'hello', now, 1), content: 'plain chatter' };
expect(computeLive([stray], now, ttl)).toEqual([]);
});

it('returns multiple live handles sorted by handle', () => {
const msgs = [
beat('human-x', 'heartbeat', now - 1_000, 1),
beat('claude-b', 'hello', now - 2_000, 2),
beat('claude-a', 'heartbeat', now - 3_000, 3),
];
expect(computeLive(msgs, now, ttl).map((e) => e.handle)).toEqual([
'claude-a',
'claude-b',
'human-x',
]);
});
});
97 changes: 97 additions & 0 deletions packages/bridge-core/src/engine/presence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Presence — "who is live" derived ABOVE the seam (no seam change).
*
* Each Parley bridge announces itself by POSTING presence messages (hello / heartbeat /
* goodbye) to a derived presence topic — the mechanical shadow of a real allowlisted topic.
* `parley_list_users` then reconstructs the live roster from `fetchRecent` over those presence
* topics plus a liveness window (TTL). Because this uses only `post`/`fetchRecent`, it works
* IDENTICALLY on every backend and needs no new seam method (DESIGN §4/§7).
*
* Presence topics are isolated: they are NEVER subscribed (live push) and NEVER enter
* catch-up / `seen` / read-state, so heartbeats never pollute a real topic's durable history
* or surface as `<channel>` events.
*/
import { asTopic, type Handle, type Message, type Topic } from '../message.js';

/**
* Reserved suffix appended to a real topic to derive its presence topic. Topics ending in this
* suffix are reserved for presence and must not be used as real topics.
*
* The derived string must be a legal topic on every backend (Matrix room alias / NATS subject
* charset, etc.). This is the ONE place the scheme is defined — adjust here if a backend rejects
* it (e.g. switch separators) rather than special-casing anywhere else.
*/
export const PRESENCE_TOPIC_SUFFIX = '-parley-presence';

/** Derive the presence topic that shadows a real topic. */
export function presenceTopicFor(topic: Topic): Topic {
return asTopic(`${topic}${PRESENCE_TOPIC_SUFFIX}`);
}

/** The kind of a presence beat. `goodbye` is a best-effort fast-path removal (TTL is the real gate). */
export type PresenceKind = 'hello' | 'heartbeat' | 'goodbye';

/** The payload carried in a presence message's `content` (JSON). Versioned for forward-compat. */
export interface PresenceRecord {
v: 1;
kind: PresenceKind;
/** Emitter wall-clock (ms) when the beat was sent — used for TTL freshness (advisory; DESIGN §14). */
at: number;
}

/** A live participant surfaced by {@link computeLive}. */
export interface LiveEntry {
handle: Handle;
/** The `at` of this handle's latest beat (ms). */
lastSeenMs: number;
}

/** Encode a presence record for the `content` field of a presence message. */
export function encodePresence(rec: PresenceRecord): string {
return JSON.stringify(rec);
}

/**
* Decode a presence message's `content`. Returns null for anything that isn't a well-formed
* presence record — defensive against a stray or spoofed message on the presence topic
* (inbound is untrusted; DESIGN §14).
*/
export function decodePresence(content: string): PresenceRecord | null {
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
return null;
}
if (typeof parsed !== 'object' || parsed === null) return null;
const r = parsed as Record<string, unknown>;
if (r.v !== 1) return null;
if (r.kind !== 'hello' && r.kind !== 'heartbeat' && r.kind !== 'goodbye') return null;
if (typeof r.at !== 'number' || !Number.isFinite(r.at)) return null;
return { v: 1, kind: r.kind, at: r.at };
}

/**
* Reconstruct the live roster from one presence topic's messages.
*
* `messages` are pre-sorted ascending by cursor (the plugin's ordering guarantee, DESIGN §6), so
* the LAST record per handle is its latest. A handle is live iff its latest beat is
* `hello`/`heartbeat` (not `goodbye`) AND is fresh (`nowMs - at < ttlMs`). TTL is the real
* liveness gate — it reclaims crashed instances that never sent a `goodbye`.
*/
export function computeLive(messages: Message[], nowMs: number, ttlMs: number): LiveEntry[] {
const latest = new Map<Handle, PresenceRecord>();
for (const m of messages) {
const rec = decodePresence(m.content);
if (rec === null) continue;
latest.set(m.senderHandle, rec); // ascending cursor order ⇒ last write wins
}
const live: LiveEntry[] = [];
for (const [handle, rec] of latest) {
if (rec.kind === 'goodbye') continue;
if (nowMs - rec.at >= ttlMs) continue;
live.push({ handle, lastSeenMs: rec.at });
}
live.sort((a, b) => (a.handle < b.handle ? -1 : a.handle > b.handle ? 1 : 0));
return live;
}
Loading
Loading