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.15.0",
"version": "0.16.0",
"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.15.0",
"version": "0.16.0",
"author": {
"name": "Yoni Davidson",
"email": "yonidavidson@tabnine.com"
Expand Down
2 changes: 1 addition & 1 deletion docs/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
name.append(dot, document.createTextNode(a.name));
const status = document.createElement('span');
status.className = 'board-status' + (a.status ? '' : ' idle');
status.textContent = a.status || '(no status declared)';
status.textContent = a.status || 'no task set';
const seen = document.createElement('span');
seen.className = 'board-seen';
seen.textContent = rel(a.lastSeen);
Expand Down
22 changes: 22 additions & 0 deletions hooks/hooks.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,28 @@
}
]
}
],
"TaskCreated": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/task-status.mjs\"",
"timeout": 10
}
]
}
],
"TaskCompleted": [
{
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/task-status.mjs\"",
"timeout": 10
}
]
}
]
}
}
30 changes: 0 additions & 30 deletions hooks/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -128,36 +128,6 @@ export async function activitySince(cwd, me, stateFile, cap = 4) {
};
}

/** Current git branch (mechanical, non-prompt), or null. */
export async function gitBranch(cwd) {
try {
// symbolic-ref works on unborn branches too (rev-parse HEAD needs a commit)
const b = await new Promise((res, rej) =>
execFile('git', ['-C', cwd, 'symbolic-ref', '--short', 'HEAD'], (e, out) =>
e ? rej(e) : res(out.trim()),
),
);
return b && b !== 'HEAD' ? b : null;
} catch {
return null;
}
}

/**
* Extra register args that give the board a mechanical default status —
* "on <branch>" — WITHOUT clobbering a status the agent actually declared.
* Set it only when the record has no status, or when the existing status is
* itself a branch-default (so it follows branch switches). A real declared
* status (anything not matching the branch pattern) is left untouched.
*/
export function branchStatusArgs(currentStatus, branch) {
if (!branch) return [];
const isBranchDefault = currentStatus == null || /^on \S+$/.test(currentStatus);
if (!isBranchDefault) return [];
if (currentStatus === `on ${branch}`) return []; // already correct, no write
return ['--status', `on ${branch}`];
}

export function aliasFrom(stderr) {
const m = /acting as (\S+)/.exec(stderr ?? '');
return m?.[1] ?? null;
Expand Down
21 changes: 9 additions & 12 deletions hooks/prompt-digest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { createHash } from 'node:crypto';
import { readStdinJson, onTheBus, cli, aliasFrom, activitySince, gitBranch, branchStatusArgs } from './lib.mjs';
import { readStdinJson, onTheBus, cli, aliasFrom, activitySince } from './lib.mjs';

const input = await readStdinJson();
const cwd = input.cwd || process.cwd();
Expand All @@ -23,14 +23,12 @@ try {
if (Date.now() - (await fs.stat(stamp)).mtimeMs < 5 * 60_000) process.exit(0);
} catch { /* first digest */ }

// Heartbeat rides the digest (~5min). Fetch roster first to read our own
// status, then register — refreshing the mechanical "on <branch>" default
// without ever clobbering a status the agent declared.
const preAgents = await cli(['agents', '--json'], cwd, 3_000);
const myPre = preAgents && Array.isArray(preAgents.json) ? preAgents.json.find((a) => a.thisSession) : null;
await cli(['register', '--json', ...branchStatusArgs(myPre?.status, await gitBranch(cwd))], cwd, 3_000);
// Heartbeat rides the digest (~5min); a plain register keeps the existing
// status (Bus preserves it — statuses come from the task list or explicit
// declaration, never from a heartbeat).
await cli(['register', '--json'], cwd, 3_000);
const peek = await cli(['peek', '--json'], cwd, 3_000);
const agents = (await cli(['agents', '--json'], cwd, 3_000)) ?? preAgents;
const agents = await cli(['agents', '--json'], cwd, 3_000);
await fs.writeFile(stamp, '').catch(() => {});
if (!peek && !agents) process.exit(0);

Expand Down Expand Up @@ -63,17 +61,16 @@ const alias0 = aliasFrom(peek?.stderr);
// coordination — nudge it (gently: at most once per 30min per repo)
let statusNudge = null;
const myRec = roster.find((a) => a.name === alias0);
// nudge when there is no status OR only the mechanical branch default —
// agents should upgrade "on <branch>" to what they are actually doing
if (myRec && (!myRec.status || /^on \S+$/.test(myRec.status))) {
// nudge only when truly statusless (no task list, no declaration)
if (myRec && !myRec.status) {
const nudgeStamp = path.join(os.tmpdir(), `agentcomm-nudge-${id}`);
let due = true;
try {
due = Date.now() - (await fs.stat(nudgeStamp)).mtimeMs > 30 * 60_000;
} catch { /* first */ }
if (due) {
statusNudge =
`Your bus status is ${myRec.status ? `just the branch ("${myRec.status}")` : 'unset'} — say what you are actually doing so teammates see it: \`agentcomm register --status "<short task>"\` (or "blocked: <need>" to recruit help).`;
'You have no bus status — set one so teammates see your work: `agentcomm register --status "<short task>"` (it also auto-follows your task list). "blocked: <need>" recruits help.';
await fs.writeFile(nudgeStamp, '').catch(() => {});
}
}
Expand Down
19 changes: 4 additions & 15 deletions hooks/session-start.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* its derived session alias, and the live roster — before the first prompt.
* Silent everywhere else; silent on any failure.
*/
import { readStdinJson, onTheBus, cli, busUriFrom, aliasFrom, gitBranch, branchStatusArgs } from './lib.mjs';
import { readStdinJson, onTheBus, cli, busUriFrom, aliasFrom } from './lib.mjs';

const input = await readStdinJson();
const cwd = input.cwd || process.cwd();
Expand All @@ -25,22 +25,11 @@ try {
fresh = Date.now() - (await fsp.stat(stamp)).mtimeMs < 10 * 60_000;
} catch { /* never registered from here */ }

// Know our current status before registering, so a mechanical branch
// default never clobbers a real one the agent declared.
const pre = await cli(['agents', '--json'], cwd, 20_000);
const branch = await gitBranch(cwd);
let myStatus;
if (pre && Array.isArray(pre.json)) {
const mine = pre.json.find((a) => a.thisSession);
myStatus = mine?.status;
}
const reg = fresh
? null
: await cli(['register', '--json', ...branchStatusArgs(myStatus, branch)], cwd, 20_000);
const reg = fresh ? null : await cli(['register', '--json'], cwd, 20_000);
if (reg) await fsp.writeFile(stamp, '').catch(() => {});
const peek = await cli(['peek', '--json'], cwd);
if (!peek) process.exit(0);
const res = (await cli(['agents', '--json'], cwd)) ?? pre;
const res = await cli(['agents', '--json'], cwd);

const bus = busUriFrom(peek.stderr) ?? busUriFrom(reg?.stderr);
const alias = aliasFrom(peek.stderr) ?? reg?.json?.name;
Expand All @@ -65,7 +54,7 @@ const lines = [
(a) =>
`call to action — ${a.name} is asking: "${a.status}". If you can answer from what you already know, reply: \`agentcomm send ${a.name} "<answer>" --subject status\`.`,
),
`As your FIRST action, declare what you are actually doing so teammates can see it: \`agentcomm register --status "<short task>"\`${branch ? ` (currently shown as "on ${branch}")` : ''}. Update it when your task changes; "blocked: <need>" recruits help.`,
'Your status shows on the shared board — it auto-follows your task list (TaskCreate), or set it explicitly: `agentcomm register --status "<short task>"`; "blocked: <need>" recruits help.',
'To coordinate: `agentcomm send <to> <msg>` / `inbox --json` / `wait`; the agentcomm skill has the conventions.',
].filter(Boolean);

Expand Down
22 changes: 22 additions & 0 deletions hooks/task-status.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env node
/**
* TaskCreated / TaskCompleted hook: mirror the agent's own task into its bus
* status — a REAL, self-authored "what I'm doing" that needs no extra action.
* "implement auth endpoints" beats "on main". Fires only in opted-in repos;
* silent on any failure; the write is async through the daemon outbox (~0.2s).
*/
import { readStdinJson, onTheBus, cli } from './lib.mjs';

const input = await readStdinJson();
const cwd = input.cwd || process.cwd();
if (!(await onTheBus(cwd))) process.exit(0);

const subject = (input.task_subject ?? '').trim();
if (!subject) process.exit(0);

const status =
input.hook_event_name === 'TaskCompleted' ? `done: ${subject}` : subject;
const clipped = status.length > 80 ? status.slice(0, 79) + '…' : status;

await cli(['register', '--status', clipped], cwd, 3_000);
process.exit(0);
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.15.0",
"version": "0.16.0",
"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
19 changes: 11 additions & 8 deletions skills/agentcomm/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,10 +154,13 @@ the discipline mechanical — don't duplicate them:
your presence and — only when there is news — may appear in your context;
during LONG turns a mid-task digest (≤ once per 10min, via tool results)
keeps you reachable: act on its unread/ask signals per the same rules.
Digests carry a bus-activity feed (recent messages, including between
other agents — the bus is a trusted space): read it and ACT when it
affects you — someone finished what you depend on, duplicates your work,
or corrected something you're using. Ignore what doesn't touch your task — unread count, riders that
Digests carry a bus-activity feed and other agents' live statuses
(the bus is a trusted space). This is not background chatter — treat it
like a teammate talking: when you see someone doing something that
overlaps, unblocks, duplicates, or contradicts your work, ACT on it —
send them what they need, ask for what they have, or adjust course, in
the same turn. Say so to your user ("worker-2 is on the schema I need —
asking them"). Only ignore what genuinely doesn't touch your task — unread count, riders that
joined, and what active agents say they're doing. Act on it like any bus
fact; no need to re-check what it reports.

Expand All @@ -172,10 +175,10 @@ help; check `log --limit 10` first (it may already be answered); reply once,
on the asker's thread. When unblocked, change your status back to plain
work — that stops the recruiting.

The hooks give you a mechanical starting status — `on <branch>` — so the
roster is never blank, but that only says WHERE you are, not WHAT you are
doing. **Upgrade it at the start of any task** — declaring overrides the
branch default and sticks until you change it:
Your status auto-follows your task list: creating a task (TaskCreate) sets
your bus status to that task's subject, so the shared board reflects what
you are actually doing with no extra effort. Set it explicitly when you
want more precision or aren't using tasks:
`agentcomm register --status "reviewing PR 12"` (short, present tense; it
persists across heartbeats until you change it — set `--status done` when
finishing). An empty status makes you invisible to coordination: nobody
Expand Down
40 changes: 18 additions & 22 deletions test/hooks.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ describe('plugin hooks: bus discipline made mechanical', () => {

const first = await runHook('prompt-digest.mjs', { cwd: dir }, dir);
const out = JSON.parse(first.stdout) as { hookSpecificOutput: { additionalContext: string } };
expect(out.hookSpecificOutput.additionalContext).toMatch(/bus status is (unset|just the branch)/);
expect(out.hookSpecificOutput.additionalContext).toContain("no bus status");

// declared → clear throttles → no nudge, and (quiet bus) full silence
cliSync(['register', '--status', 'building the feed'], dir);
Expand Down Expand Up @@ -469,33 +469,29 @@ describe('plugin hooks: bus discipline made mechanical', () => {
expect(quiet.stdout).toBe('');
});

it('session-start sets a mechanical branch-default status; a real status is never clobbered', async () => {
it('a created task auto-sets the bus status; completing it shows "done: …"', async () => {
const dir = await markedRepo();
execFileSync('git', ['-C', dir, 'checkout', '-q', '-b', 'feat-auth']);
cliSync(['register'], dir); // on the roster, no status yet

// fresh session, no status → hook sets "on feat-auth"
await runHook('session-start.mjs', { cwd: dir, source: 'startup' }, dir);
await runHook(
'task-status.mjs',
{ cwd: dir, hook_event_name: 'TaskCreated', task_subject: 'implement the auth endpoints' },
dir,
);
let me = rosterJson(dir).find((a) => a.name.startsWith('hooky-'))!;
expect(me.status).toBe('on feat-auth');
expect(me.status).toBe('implement the auth endpoints');

// agent declares a real status → later session-start must NOT overwrite it
cliSync(['register', '--status', 'reviewing PR 12'], dir);
// clear the register throttle so the hook re-registers
for (const f of await fs.readdir(dir)) {
if (f.startsWith('agentcomm-register-')) await fs.rm(path.join(dir, f));
}
await runHook('session-start.mjs', { cwd: dir, source: 'startup' }, dir);
await runHook(
'task-status.mjs',
{ cwd: dir, hook_event_name: 'TaskCompleted', task_subject: 'implement the auth endpoints' },
dir,
);
me = rosterJson(dir).find((a) => a.name.startsWith('hooky-'))!;
expect(me.status).toBe('reviewing PR 12'); // preserved
expect(me.status).toBe('done: implement the auth endpoints');

// switching branches only moves a branch-DEFAULT status, not a real one
execFileSync('git', ['-C', dir, 'checkout', '-q', '-b', 'feat-other']);
for (const f of await fs.readdir(dir)) {
if (f.startsWith('agentcomm-register-')) await fs.rm(path.join(dir, f));
}
await runHook('session-start.mjs', { cwd: dir, source: 'startup' }, dir);
me = rosterJson(dir).find((a) => a.name.startsWith('hooky-'))!;
expect(me.status).toBe('reviewing PR 12'); // still the declared one
// silent outside opted-in repos / with no subject
const noSub = await runHook('task-status.mjs', { cwd: dir, hook_event_name: 'TaskCreated' }, dir);
expect(noSub.code).toBe(0);
});

it('stop guard honors stop_hook_active (no loops) and throttles repeat checks', async () => {
Expand Down
Loading