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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"name": "agentcomm",
"source": "./",
"description": "Multi-agent mailbox CLI — any git remote is a bus (git+ssh/github/sqlite/s3/gcs/postgres). register/send/inbox/wait/claim/log/channels so Claude coordinates with other agents or sessions; auto-selects the repo bus inside a git repo.",
"version": "0.14.1",
"version": "0.14.2",
"author": {
"name": "Yoni Davidson"
},
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "agentcomm",
"description": "Multi-agent mailbox CLI — any git remote is a bus (git+ssh/github/sqlite/s3/gcs/postgres). register/send/inbox/wait/claim/log/channels so Claude coordinates with other agents or sessions; auto-selects the repo bus inside a git repo.",
"version": "0.14.1",
"version": "0.14.2",
"author": {
"name": "Yoni Davidson",
"email": "yonidavidson@tabnine.com"
Expand Down
32 changes: 28 additions & 4 deletions hooks/prompt-digest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,25 @@ const pending = Array.isArray(peek?.json) ? peek.json.length : 0;
const roster = agents && Array.isArray(agents.json) ? agents.json : [];
const names = roster.map((a) => a.name).sort();
let known = [];
let knownStatuses = {};
try {
known = JSON.parse(await fs.readFile(rosterFile, 'utf8'));
const snap = JSON.parse(await fs.readFile(rosterFile, 'utf8'));
if (Array.isArray(snap)) known = snap; // pre-0.14.2 snapshot shape
else {
known = snap.names ?? [];
knownStatuses = snap.statuses ?? {};
}
} catch { /* no snapshot yet */ }
await fs.writeFile(rosterFile, JSON.stringify(names)).catch(() => {});
const statuses = Object.fromEntries(roster.filter((a) => a.status).map((a) => [a.name, a.status]));
await fs.writeFile(rosterFile, JSON.stringify({ names, statuses: { ...knownStatuses, ...statuses } })).catch(() => {});
const joined = known.length ? names.filter((n) => !known.includes(n)) : [];
// a status CHANGE is news in itself — "X started doing Y" must reach the
// others within one digest cycle, not wait for unrelated traffic
const statusChanges = known.length
? roster.filter(
(a) => a.status && a.name !== aliasFrom(peek?.stderr) && knownStatuses[a.name] !== a.status,
)
: [];
const activeAgents = roster.filter((a) => Date.now() - Date.parse(a.lastSeen) < 10 * 60_000);
const alias0 = aliasFrom(peek?.stderr);
// status adoption: an agent with no declared status is invisible to
Expand Down Expand Up @@ -79,8 +93,11 @@ if (joined.length)
);
const isAsk = (t) => /^(blocked|need|help)\b/i.test(t ?? '');
const asks = activeAgents.filter((a) => a.name !== aliasFrom(peek?.stderr) && isAsk(a.status));
const changed = statusChanges.filter((a) => !isAsk(a.status));
if (changed.length)
bits.push(`now working — ${changed.slice(0, 4).map((a) => `${a.name}: ${a.status}`).join(' · ')}`);
const withStatus = activeAgents
.filter((a) => a.status && !isAsk(a.status))
.filter((a) => a.status && !isAsk(a.status) && !changed.some((c) => c.name === a.name))
.map((a) => `${a.name}: ${a.status}`);
if (withStatus.length) bits.push(`working — ${withStatus.slice(0, 4).join(' · ')}`);
bits.push(`${activeAgents.length}/${roster.length} agents active`);
Expand All @@ -92,7 +109,14 @@ for (const a of asks.slice(0, 3)) {
'Otherwise continue your own task.',
);
}
if (!pending && joined.length === 0 && ctas.length === 0 && activity.length === 0 && !statusNudge)
if (
!pending &&
joined.length === 0 &&
ctas.length === 0 &&
activity.length === 0 &&
statusChanges.length === 0 &&
!statusNudge
)
process.exit(0); // no news, no noise

process.stdout.write(
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agentcomm",
"version": "0.14.1",
"version": "0.14.2",
"description": "A tiny mailbox/message bus for AI agents that shell out to one CLI. Pluggable storage backends behind a single Backend interface.",
"type": "module",
"license": "MIT",
Expand Down
40 changes: 40 additions & 0 deletions test/hooks.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,17 @@ describe('plugin hooks: bus discipline made mechanical', () => {
for (const f of await fs.readdir(dir)) {
if (f.startsWith('agentcomm-digest-') && !f.includes('roster')) await fs.rm(path.join(dir, f));
}
// clearing the ask IS news (the change to plain work is announced, no CTA)
const cleared = await runHook('prompt-digest.mjs', { cwd: dir }, dir);
const clearedOut = JSON.parse(cleared.stdout) as { hookSpecificOutput: { additionalContext: string } };
expect(clearedOut.hookSpecificOutput.additionalContext).toContain('now working — worker-1: building auth');
expect(clearedOut.hookSpecificOutput.additionalContext).not.toContain('call to action');

// and once seen, steady state → silence
for (const f of await fs.readdir(dir)) {
if (f.startsWith('agentcomm-digest-') && !f.includes('roster') && !f.includes('acts'))
await fs.rm(path.join(dir, f));
}
const quiet = await runHook('prompt-digest.mjs', { cwd: dir }, dir);
expect(quiet.stdout).toBe('');
});
Expand Down Expand Up @@ -429,6 +440,35 @@ describe('plugin hooks: bus discipline made mechanical', () => {
expect(second.stdout).toBe('');
});

it("a status CHANGE alone is news — 'X started doing Y' reaches others in one cycle", async () => {
const dir = await markedRepo();
cliSync(['register', '--status', 'my own work'], dir);
cliSync(['register', '--status', 'idling'], dir, 'schema-worker');

// prime the snapshot (statuses recorded)
await runHook('prompt-digest.mjs', { cwd: dir }, dir);

// ONLY a status change — no mail, no riders, no asks
cliSync(['register', '--status', 'designing the auth schema'], dir, 'schema-worker');
for (const f of await fs.readdir(dir)) {
if (f.startsWith('agentcomm-digest-') && !f.includes('roster') && !f.includes('acts'))
await fs.rm(path.join(dir, f));
}
const news = await runHook('prompt-digest.mjs', { cwd: dir }, dir);
const out = JSON.parse(news.stdout) as { hookSpecificOutput: { additionalContext: string } };
expect(out.hookSpecificOutput.additionalContext).toContain(
'now working — schema-worker: designing the auth schema',
);

// unchanged next cycle → silence again
for (const f of await fs.readdir(dir)) {
if (f.startsWith('agentcomm-digest-') && !f.includes('roster') && !f.includes('acts'))
await fs.rm(path.join(dir, f));
}
const quiet = await runHook('prompt-digest.mjs', { cwd: dir }, dir);
expect(quiet.stdout).toBe('');
});

it('stop guard honors stop_hook_active (no loops) and throttles repeat checks', async () => {
const dir = await markedRepo();
cliSync(['register'], dir);
Expand Down
Loading