Skip to content
Closed
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
21 changes: 14 additions & 7 deletions src/backends/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,19 +244,26 @@ export class GitBackend implements Backend, Batchable, Claimable, Snapshottable
}

/**
* One fetch, then every body read from local objects at that tip — the
* daemon's warm path. Key-by-key `get` would pay a fetch per key.
* One fetch, then bodies read from local objects at that tip — the daemon's
* warm path. Key-by-key `get` would pay a fetch per key. Listing names is
* cheap (`ls-tree`); reading blobs is not, so `opts.bodies` picks which
* ones are worth the batch (issue #167).
*/
async snapshot(prefix: string): Promise<Map<string, Buffer>> {
async snapshot(
prefix: string,
opts?: { bodies?: (key: string) => boolean },
): Promise<{ keys: string[]; bodies: Map<string, Buffer> }> {
const out = new Map<string, Buffer>();
const tip = await this.tip();
if (tip === null) return out;
if (tip === null) return { keys: [], bodies: out };
const full = this.k(prefix);
const names = (await this.git(['ls-tree', '-r', '--name-only', '-z', tip]))
const all = (await this.git(['ls-tree', '-r', '--name-only', '-z', tip]))
.toString('utf8')
.split('\0')
.filter((p) => p.length > 0 && p.startsWith(full) && p.startsWith(this.keyPrefix));
if (names.length === 0) return out;
const keys = all.map((p) => p.slice(this.keyPrefix.length));
const names = opts?.bodies ? all.filter((p) => opts.bodies!(p.slice(this.keyPrefix.length))) : all;
if (names.length === 0) return { keys, bodies: out };
const batch = await this.git(['cat-file', '--batch'], {
input: Buffer.from(names.map((p) => `${tip}:${p}`).join('\n') + '\n'),
});
Expand All @@ -272,7 +279,7 @@ export class GitBackend implements Backend, Batchable, Claimable, Snapshottable
out.set(p.slice(this.keyPrefix.length), Buffer.from(batch.subarray(off, off + size)));
off += size + 1; // body + trailing newline
}
return out;
return { keys, bodies: out };
}

async list(prefix: string): Promise<string[]> {
Expand Down
27 changes: 27 additions & 0 deletions src/bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,29 @@ export class Bus {
return out;
}

/**
* Clear mail that has already been read (issue #160). `peek` shows messages
* without consuming and `inbox` consumes what it shows — with only those
* two, an agent that read its mail the non-destructive way could never mark
* it read, so the unread count (and the stop guard behind it) stayed stuck
* at N forever. Acking works off the KEYS: no body is re-fetched, and ids
* that are not pending are reported rather than silently ignored.
*/
async ack(
recipient: string,
ids: string[] | 'all',
): Promise<{ acked: string[]; unknown: string[]; failed: string[] }> {
assertName(recipient);
const keys = (await this.backend.list(inboxPrefix(recipient))).filter((k) => k.endsWith('.json'));
const byId = new Map(keys.map((key) => [messageIdFromKey(key), key] as const));
const wanted = ids === 'all' ? [...byId.keys()] : ids;
const unknown = wanted.filter((id) => !byId.has(id));
const targets = wanted.filter((id) => byId.has(id));
const failedKeys = await this.archive(targets.map((id) => byId.get(id)!));
const failed = targets.filter((id) => failedKeys.includes(byId.get(id)!));
return { acked: targets.filter((id) => !failed.includes(id)), unknown, failed };
}

/**
* Archive (don't hard-delete) inbox keys under read/, preserving the audit
* trail. Returns the keys that could NOT be archived — a raced consumer, or
Expand Down Expand Up @@ -264,6 +287,10 @@ function inboxKey(recipient: string, seq: string, id: string): string {
function readKeyFromInboxKey(inboxKey: string): string {
return 'read/' + inboxKey.slice('inbox/'.length);
}
/** The message id a key carries: inbox/<to>/<seq>_<id>.json — no read needed. */
function messageIdFromKey(key: string): string {
return key.slice(key.lastIndexOf('_') + 1).replace(/\.json$/, '');
}

// ── sequence generation ─────────────────────────────────────────────────────

Expand Down
93 changes: 89 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { parseArgs, resolveConfig, type ParsedFlags, type ResolvedConfig } from
import type { Backend, Message } from './types.js';
import { fileURLToPath } from 'node:url';
import { deriveIdentity, pinnedSession, sessionHash } from './identity.js';
import { recordAlias } from './session.js';
import { leaseMailbox, recordAlias, releaseMailbox } from './session.js';
import { INSTALL_COMMAND } from './update-check.js';
import {
EVENTS_PREFIX,
Expand All @@ -31,6 +31,7 @@ Agent quickstart (if you are an AI agent):
agentcomm inbox --json # consume instructions that may be waiting
agentcomm network # who else is here, what they're doing
agentcomm send <to> "<body>" · agentcomm wait # coordinate — reply on the sender's --thread
agentcomm peek · agentcomm ack --all # read without consuming, then clear what you handled
Re-check your inbox before reporting your task done.
Full etiquette: agentcomm conventions · bus semantics: agentcomm describe

Expand Down Expand Up @@ -62,8 +63,13 @@ Commands:
they're doing (active/idle + recent activity)
send <to> [body] Send a message (body from arg or stdin)
broadcast [body] Send to every registered agent except yourself
inbox Consume undelivered messages (archived under read/)
inbox Consume undelivered messages (archived under read/) —
printed first, archived after, so an interrupted
read costs a duplicate, never a lost message
peek Show undelivered messages without consuming
ack <id…> | --all Clear mail you already read some other way (peek, a
digest): archives it without re-fetching. Exit 1
when an id is not pending for you
wait Block until a message arrives (exit 0) or timeout (exit 2)
claim Atomically dequeue one message from --queue (SQL backends only)
emit Record a telemetry event (--type, --name, --ref,
Expand Down Expand Up @@ -130,6 +136,7 @@ Flags:
--check install: report drift instead of writing (exit 1 when
the wiring is missing or older than this CLI)
--uninstall install: remove the wiring this command wrote
--all ack: clear every message pending for you
--type <text> emit/events: the event type (skill-ran, skill-outcome, …)
--name <text> emit/events: what the event is about (a skill/tool name)
--ref <text> emit/events: correlation handle (branch, PR#, run id)
Expand All @@ -151,6 +158,10 @@ Env:
stderr)
AGENTCOMM_DAEMON=1|0 Default all commands through / away from the daemon
AGENTCOMM_POLL_MS Daemon remote-poll interval (default 10000)
AGENTCOMM_MIRROR_HISTORY_MS How much archive/telemetry history the daemon
keeps warm (default 7d). Older keys stay listable
and readable; their bodies just load on demand,
so a poll costs what is hot, not all history
AGENTCOMM_BACKEND_PLUGINS comma/whitespace-separated module specifiers to
import before resolving --backend, so a
third-party package can register a new URI
Expand Down Expand Up @@ -296,6 +307,8 @@ async function main(argv: string[]): Promise<number> {
return await cmdInbox(bus, cfg);
case 'peek':
return await cmdPeek(bus, cfg);
case 'ack':
return await cmdAck(bus, cfg, flags.all, positional.slice(1));
case 'wait':
return await cmdWait(bus, cfg, flags.timeout ?? 30000);
case 'claim':
Expand All @@ -313,10 +326,35 @@ async function main(argv: string[]): Promise<number> {
return 1;
}
} finally {
if (leasedAlias) await releaseMailbox(leasedAlias).catch(() => {});
await backend.close?.();
}
}

let leasedAlias: string | null = null;

/**
* Announce that this process is acting as `me`, and say so when ANOTHER live
* process of this session is doing the same (issue #161). A subagent inherits
* its parent's identity, so `agentcomm inbox` from one drains the mailbox its
* parent is waiting on — silently, because the alias looks right. Identity
* stays per-session by design (a session is a mailbox); the fix is that
* sharing one stops being invisible.
*/
async function guardMailbox(me: string, command: string, kind: 'consuming' | 'status' | 'quiet'): Promise<void> {
const others = await leaseMailbox(me, command).catch(() => [] as Awaited<ReturnType<typeof leaseMailbox>>);
leasedAlias = me;
if (kind === 'quiet' || others.length === 0) return;
const who = others.map((o) => `pid ${o.pid} (\`agentcomm ${o.command}\`, since ${o.since})`).join(', ');
process.stderr.write(
kind === 'consuming'
? `agentcomm: WARNING — another live process of this session is acting as "${me}": ${who}. ` +
`Reads CONSUME, so this takes mail addressed to it. If you are a subagent, use a mailbox of your own: --as ${me}-<role>.\n`
: `agentcomm: WARNING — another live process of this session is acting as "${me}": ${who}. ` +
`Your status replaces its line on the shared roster; register under your own --as if you are a separate actor.\n`,
);
}

// ── commands ────────────────────────────────────────────────────────────────

const CHANNEL_SECURITY_NOTE =
Expand Down Expand Up @@ -686,14 +724,18 @@ This repo has a message bus for AI agents. When working here:
(active/idle agents, their statuses, recent activity).
- Coordinate with other agents via \`send\`/\`wait\` (subjects: task, ack,
done, question, status; reply on the sender's --thread).
- Always check your inbox before reporting work done.
- Always check your inbox before reporting work done. \`inbox\` consumes;
\`peek\` shows without consuming and \`agentcomm ack --all\` clears what you
have handled, so mail you read another way stops counting as unread.
- Stuck? Declare it: \`agentcomm register --status "blocked: <what you
need>"\` — other agents' digests will recruit help. If a digest shows
someone else blocked and you KNOW the answer, send it without asking
the user; otherwise stay on your task.
- If your harness has subagents, prefer a background listener subagent for
\`wait\`/inbox management (one actor per mailbox — it owns the alias or
uses \`--as <you>-bus\`); keep quick sends inline.
uses \`--as <you>-bus\`); keep quick sends inline. A subagent derives the
SAME alias as its parent, so a bare \`inbox\` there drains the parent's
mail; the CLI warns when two live processes share a mailbox — heed it.
`;

async function cmdInit(bus: Bus, cfg: ResolvedConfig, requestedHarness?: string): Promise<number> {
Expand Down Expand Up @@ -934,6 +976,7 @@ async function cmdRegister(
statusAuto?: boolean,
): Promise<number> {
const me = await resolveAgent(cfg);
await guardMailbox(me, 'register', status !== undefined ? 'status' : 'quiet');
const record = await registerWithCollisionCheck(bus, me, status, statusAuto);
if (cfg.json) emit(record);
else process.stdout.write(`registered ${record.name}\n`);
Expand Down Expand Up @@ -1093,6 +1136,7 @@ async function cmdBroadcast(
*/
async function cmdInbox(bus: Bus, cfg: ResolvedConfig): Promise<number> {
const me = await resolveAgent(cfg);
await guardMailbox(me, 'inbox', 'consuming');
await bus.inbox(me, {
deliver: (messages) => printMessages(messages, cfg, me),
onUnarchived: (keys) =>
Expand All @@ -1105,13 +1149,53 @@ async function cmdInbox(bus: Bus, cfg: ResolvedConfig): Promise<number> {

async function cmdPeek(bus: Bus, cfg: ResolvedConfig): Promise<number> {
const me = await resolveAgent(cfg);
await guardMailbox(me, 'peek', 'quiet'); // reading is harmless; hold the lease so a concurrent consumer sees us
const messages = await bus.peek(me);
await printMessages(messages, cfg, me);
// peek leaves the mail unread by design; say how to clear it once acted on,
// or the unread count (and the stop guard) never drops (issue #160).
if (!cfg.json && messages.length > 0) {
await writeOut(`— ${messages.length} still unread; clear what you have handled: agentcomm ack --all\n`);
}
return 0;
}

/**
* Clear mail that was read some other way — `peek`, a digest, a delivery this
* process could not consume. Read and clear used to be the same operation, so
* an agent that had read and answered every message still faced a stop guard
* demanding it read them (issue #160).
*/
async function cmdAck(bus: Bus, cfg: ResolvedConfig, all: boolean, ids: string[]): Promise<number> {
const me = await resolveAgent(cfg);
await guardMailbox(me, 'ack', 'consuming');
if (!all && ids.length === 0) {
fail('ack requires message ids (from `peek`/`inbox --json`) or --all: agentcomm ack <id…> | agentcomm ack --all');
}
const result = await bus.ack(me, all ? 'all' : ids);
if (cfg.json) {
await writeOut(JSON.stringify({ agent: me, ...result }, null, 2) + '\n');
return result.unknown.length > 0 ? 1 : 0;
}
await writeOut(
[
`acked ${result.acked.length} message(s) for ${me}`,
...(result.failed.length ? [`could not archive: ${result.failed.join(', ')} — still pending`] : []),
...(result.unknown.length
? [`not pending for ${me}: ${result.unknown.join(', ')} (already acked, or addressed to another alias)`]
: []),
'',
].join('\n'),
);
return result.unknown.length > 0 ? 1 : 0;
}

async function cmdWait(bus: Bus, cfg: ResolvedConfig, timeoutMs: number): Promise<number> {
const me = await resolveAgent(cfg);
// wait is non-consuming, but it HOLDS the mailbox for its whole timeout —
// the listener pattern. Leasing here is what lets a concurrent `inbox`
// report that it is about to drain someone's mail (issue #161).
await guardMailbox(me, 'wait', 'quiet');
const messages = await bus.wait(me, timeoutMs);
if (messages.length === 0) {
if (cfg.json) emit([]);
Expand All @@ -1124,6 +1208,7 @@ async function cmdWait(bus: Bus, cfg: ResolvedConfig, timeoutMs: number): Promis

async function cmdClaim(bus: Bus, cfg: ResolvedConfig, queue: string | undefined): Promise<number> {
const me = await resolveAgent(cfg);
await guardMailbox(me, 'claim', 'quiet'); // a shared queue is MEANT to have many claimers
if (!queue) {
fail('claim requires --queue <name>');
}
Expand Down
5 changes: 5 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface ParsedFlags {
flush: boolean;
check: boolean;
uninstall: boolean;
all: boolean;
events?: string;
since?: string;
_: string[]; // positional args
Expand All @@ -64,6 +65,7 @@ export function parseArgs(argv: string[]): ParsedFlags {
version: false,
check: false,
uninstall: false,
all: false,
_: [],
};
for (let i = 0; i < argv.length; i++) {
Expand Down Expand Up @@ -161,6 +163,9 @@ export function parseArgs(argv: string[]): ParsedFlags {
case 'uninstall':
flags.uninstall = true;
break;
case 'all':
flags.all = true;
break;
case 'events':
flags.events = takeVal();
break;
Expand Down
64 changes: 46 additions & 18 deletions src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,11 +180,38 @@ export async function runDaemon(uri: string): Promise<void> {
});
}

// warm mirror: full key set + bodies. On Snapshottable backends every poll
// is one round trip (bodies included); elsewhere the poll carries keys only
// and bodies fill lazily through the `get` passthrough — message blobs are
// immutable, and agent records (which mutate: heartbeats) are invalidated
// each poll so the next read refetches them.
// warm mirror: the full key set, plus the bodies worth holding. Message
// blobs are immutable, so a body already held is never re-read; agent
// records (which mutate: heartbeats) are refreshed every poll.
//
// What is NOT held warm is history (issue #167). A bus only grows, and
// re-reading every archived message and telemetry batch on every poll made
// the daemon's cost scale with all history instead of with what is hot —
// forever, on a store where nothing but the last few messages is live.
// Archives and events older than the window keep their KEY (so list, log,
// purge and channel discovery see the whole store, exactly as before) and
// their body fills on demand through the `get` passthrough.
const historyMs = Math.max(0, Number(process.env.AGENTCOMM_MIRROR_HISTORY_MS ?? 7 * 24 * 3600_000));
const HOT_PREFIXES = ['agents/', 'inbox/'];
/**
* ms timestamp encoded in an archive/event key, or null when it has none.
* Both layouts put a zero-padded, monotonic ms prefix on the filename:
* read/<recipient>/<ms>-<counter>_<id>.json and events/<ms>-<counter>_<id>.json.
*/
const keyTime = (key: string): number | null => {
const m = /^0*(\d+)-/.exec(key.slice(key.lastIndexOf('/') + 1));
return m ? Number(m[1]) : null;
};
const withinWindow = (key: string): boolean => {
const ts = keyTime(key);
return ts === null || Date.now() - ts <= historyMs;
};
/** Should the mirror hold this key's body at all? */
const worthWarming = (key: string): boolean => HOT_PREFIXES.some((p) => key.startsWith(p)) || withinWindow(key);
/** Should THIS poll read it? Not if we already hold an immutable body. */
const needsRead = (key: string): boolean =>
key.startsWith('agents/') || (worthWarming(key) && !mirror.has(key));

const mirror = new Map<string, Buffer>();
let keys = new Set<string>();

Expand Down Expand Up @@ -237,23 +264,24 @@ export async function runDaemon(uri: string): Promise<void> {

async function doPoll(): Promise<void> {
const spooled = await snapshotSpool();
// Bodies this poll actually read (fresh agent records + keys we do not
// already hold), merged onto the ones already warm.
const fetched = new Map<string, Buffer>();
let next: Set<string>;
if (isSnapshottable(backend)) {
const snap = await backend.snapshot('');
const present = new Set(snap.keys());
replaySpool(spooled, snap, present);
for (const k of snap.keys()) if (!present.has(k)) snap.delete(k);
mirror.clear();
for (const [k, v] of snap) mirror.set(k, v);
keys = present;
return;
const snap = await backend.snapshot('', { bodies: needsRead });
next = new Set(snap.keys);
for (const [k, v] of snap.bodies) fetched.set(k, v);
} else {
next = new Set(await backend.list(''));
}
const bodies = new Map<string, Buffer>();
const next = new Set(await backend.list(''));
replaySpool(spooled, bodies, next);
replaySpool(spooled, fetched, next);
for (const k of mirror.keys()) {
if (!next.has(k) || k.startsWith('agents/')) mirror.delete(k);
// drop what the store no longer has, what a fresher read supersedes,
// and history that has aged out of the warm window
if (!next.has(k) || k.startsWith('agents/') || !worthWarming(k)) mirror.delete(k);
}
for (const [k, v] of bodies) if (next.has(k)) mirror.set(k, v);
for (const [k, v] of fetched) if (next.has(k)) mirror.set(k, v);
keys = next;
}
// Coalesce: timer ticks, `refresh`, and post-claim polls that overlap a
Expand Down
3 changes: 2 additions & 1 deletion src/hook-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,8 @@ async function hookStopGuard(input: HookInput): Promise<void> {
reason:
`agentcomm delivery (working as intended — not an error): ${msgs.length} unread bus message(s) ` +
`for ${alias} (from: ${from}). Read them with \`agentcomm inbox --json\`, act or tell the user why not, ` +
'then finish.',
'then finish. If you already read them another way (peek, a digest), clear them with ' +
'`agentcomm ack --all` — that is what this guard is counting.',
}),
);
}
Expand Down
Loading