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.3",
"version": "0.15.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.14.3",
"version": "0.15.0",
"author": {
"name": "Yoni Davidson",
"email": "yonidavidson@tabnine.com"
Expand Down
30 changes: 30 additions & 0 deletions hooks/lib.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,36 @@ 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: 12 additions & 9 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 } from './lib.mjs';
import { readStdinJson, onTheBus, cli, aliasFrom, activitySince, gitBranch, branchStatusArgs } from './lib.mjs';

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

// Heartbeat rides the digest: a prompt is the strongest "this session is
// alive" signal, and both want the same ~5min cadence. register is async
// through the daemon outbox, so this costs ~0.3s. No --status: a heartbeat
// never overwrites a declared status.
await cli(['register', '--json'], cwd, 3_000);
// 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);
const peek = await cli(['peek', '--json'], cwd, 3_000);
const agents = await cli(['agents', '--json'], cwd, 3_000);
const agents = (await cli(['agents', '--json'], cwd, 3_000)) ?? preAgents;
await fs.writeFile(stamp, '').catch(() => {});
if (!peek && !agents) process.exit(0);

Expand Down Expand Up @@ -62,15 +63,17 @@ 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);
if (myRec && !myRec.status) {
// 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))) {
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 =
'You carry no bus status — declare what you are working on now: `agentcomm register --status "<short task>"` (or "blocked: <need>" to recruit help).';
`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).`;
await fs.writeFile(nudgeStamp, '').catch(() => {});
}
}
Expand Down
18 changes: 15 additions & 3 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 } from './lib.mjs';
import { readStdinJson, onTheBus, cli, busUriFrom, aliasFrom, gitBranch, branchStatusArgs } from './lib.mjs';

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

const reg = fresh ? null : await cli(['register', '--json'], cwd, 20_000);
// 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);
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);
const res = (await cli(['agents', '--json'], cwd)) ?? pre;

const bus = busUriFrom(peek.stderr) ?? busUriFrom(reg?.stderr);
const alias = aliasFrom(peek.stderr) ?? reg?.json?.name;
Expand All @@ -54,6 +65,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.`,
'To coordinate: `agentcomm send <to> <msg>` / `inbox --json` / `wait`; the agentcomm skill has the conventions.',
].filter(Boolean);

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.3",
"version": "0.15.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
5 changes: 4 additions & 1 deletion skills/agentcomm/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,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.

**Always carry a status.** At the start of ANY task, declare it:
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:
`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
31 changes: 30 additions & 1 deletion 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).toContain('You carry no bus status');
expect(out.hookSpecificOutput.additionalContext).toMatch(/bus status is (unset|just the branch)/);

// declared → clear throttles → no nudge, and (quiet bus) full silence
cliSync(['register', '--status', 'building the feed'], dir);
Expand Down Expand Up @@ -469,6 +469,35 @@ 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 () => {
const dir = await markedRepo();
execFileSync('git', ['-C', dir, 'checkout', '-q', '-b', 'feat-auth']);

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

// 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);
me = rosterJson(dir).find((a) => a.name.startsWith('hooky-'))!;
expect(me.status).toBe('reviewing PR 12'); // preserved

// 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
});

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