Skip to content
Open
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
11 changes: 10 additions & 1 deletion .agents/skills/using-agent-relay/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,19 @@ Prefer `send_dm` for lead/worker coordination. Use `post_message` when the
whole channel needs the update. Use `reply_to_thread` for follow-ups on a
specific message.

Choose the injection mode deliberately. Omit `mode` (or use `mode: "wait"`)
for normal coordination: Relay queues the message until the recipient reaches
a safe idle boundary, so it can remain unread while that agent is busy. Use
`mode: "steer"` only when immediate injection justifies interrupting active
work. In either mode, a successful send and message ID confirm enqueue, not
consumption; call `get_message_readers(message_id: "...")` before interpreting
silence as acknowledgement or refusal.

Examples:

```text
send_dm(to: "Lead", text: "STATUS: Auth routes are implemented; running tests next.")
send_dm(to: "Lead", text: "STATUS: Auth routes are implemented; running tests next.", mode: "wait")
send_dm(to: "Lead", text: "URGENT: Stop the deploy.", mode: "steer")
post_message(channel: "general", text: "The API endpoints are ready for review.")
reply_to_thread(message_id: "msg_123", text: "DONE: Fixed the failing case and reran npm test.")
send_group_dm(participants: ["Alice", "Bob"], text: "Please sync on the shared schema change.")
Expand Down
11 changes: 10 additions & 1 deletion .claude/skills/using-agent-relay/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,10 +160,19 @@ Prefer `send_dm` for lead/worker coordination. Use `post_message` when the
whole channel needs the update. Use `reply_to_thread` for follow-ups on a
specific message.

Choose the injection mode deliberately. Omit `mode` (or use `mode: "wait"`)
for normal coordination: Relay queues the message until the recipient reaches
a safe idle boundary, so it can remain unread while that agent is busy. Use
`mode: "steer"` only when immediate injection justifies interrupting active
work. In either mode, a successful send and message ID confirm enqueue, not
consumption; call `get_message_readers(message_id: "...")` before interpreting
silence as acknowledgement or refusal.

Examples:

```text
send_dm(to: "Lead", text: "STATUS: Auth routes are implemented; running tests next.")
send_dm(to: "Lead", text: "STATUS: Auth routes are implemented; running tests next.", mode: "wait")
send_dm(to: "Lead", text: "URGENT: Stop the deploy.", mode: "steer")
post_message(channel: "general", text: "The API endpoints are ready for review.")
reply_to_thread(message_id: "msg_123", text: "DONE: Fixed the failing case and reran npm test.")
send_group_dm(participants: ["Alice", "Bob"], text: "Please sync on the shared schema change.")
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `send_dm` and `agent-relay message dm send` receipts now name the exact requested and resolved recipients and report unconfirmed enqueue without claiming delivery.
- `get_message_readers` and `agent-relay message inbox get_readers` now surface a queued-or-unread signal for an empty reader list.
- `send_dm` mode docs and `agent-relay message dm send --mode` help now explain that `wait` injects on idle while `steer` injects immediately and may interrupt active work.
- `agent-relay node agent attach --mode drive` (and `--mode passthrough`) no longer floods the terminal with `input stream send failed: PTY input stream is closed` when the PTY input stream dies mid-session. The loss is now reported once, the stream is reopened with bounded backoff, and if that fails the command exits non-zero with a readable message instead of leaving a session that looks alive but accepts no input. Because attach forwards every byte except `Ctrl+C`/`Ctrl+]` while the stream is healthy, a source TUI with mouse tracking enabled could previously produce this flood from pointer movement alone, without a single keystroke; input is now dropped rather than forwarded for as long as the stream is down.
- A reopened attach input stream is verified to belong to the same worker process before any keystroke is forwarded. The stream is reopened by agent name, so without this a replaced worker could silently receive input typed for the session you attached to; the check fails closed when identity cannot be established.

Expand Down
5 changes: 4 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ agent-relay fleet spawn codex \
agent-relay fleet spawn codex --name api-worker --task "Review the current diff."

agent-relay message dm send api-worker "Detailed task instructions"
# Wake an idle worker immediately instead of queueing for its next tool boundary.
# wait is the default: it queues for the recipient's next safe idle boundary and
# can remain unread while that recipient is busy. steer requests immediate
# injection and may interrupt active work. A send ID confirms enqueue only;
# use `message inbox get_readers <id>` to confirm that the recipient consumed it.
agent-relay message dm send api-worker "Please check Relay now." --mode steer
agent-relay message inbox check --limit 20
agent-relay fleet release api-worker --reason "Work accepted"
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/cli/agent-relay-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@
});
} catch (err) {
if ((err as { name?: string }).name === 'AbortError') {
throw new Error(`Agent Relay result submission timed out after ${timeoutMs}ms`);

Check warning on line 279 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

There is no `cause` attached to the symptom error being thrown
}
throw err;
} finally {
Expand Down Expand Up @@ -329,7 +329,7 @@
return { agentName, agentToken };
}

export async function registerAgentWithRebind({

Check warning on line 332 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'registerAgentWithRebind' has a complexity of 23. Maximum allowed is 15
session,
setSession,
getRelay,
Expand Down Expand Up @@ -620,7 +620,10 @@
}
);

registerMessagingTools(server, getAgentClient);
registerMessagingTools(server, getAgentClient, async () => {
requireWorkspaceKey(getSession());
return getRelay().agents.list();
});

server.registerTool(
'add_agent',
Expand Down
34 changes: 27 additions & 7 deletions packages/cli/src/cli/commands/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
withSdkDefaults,
type SdkCommandDeps,
} from '../lib/sdk-command.js';
import {
directMessageReceipt,
messageReadersReceipt,
resolveExactAgentName,
} from '../lib/message-delivery-receipts.js';

export type MessageCommandDependencies = SdkCommandDeps;

Expand Down Expand Up @@ -119,16 +124,28 @@ export function registerMessageCommands(
.description('Send a direct message to an agent')
.argument('<agent>', 'Recipient agent')
.argument('<text>', 'Message text')
.option('--mode <mode>', 'Delivery mode: wait or steer', parseMessageMode)
.option(
'--mode <mode>',
'wait (default): inject on idle; steer: inject immediately and may interrupt active work',
parseMessageMode
)
).action(async (agent: string, text: string, o: Record<string, unknown>) => {
await runSdk(deps, async () => {
const mode = o.mode as 'wait' | 'steer' | undefined;
const relay = deps.createAgentRelay(opts(o));
const resolvedRecipient = resolveExactAgentName(await relay.agents.list(), agent);
printJson(
deps,
await deps.createAgentRelay(opts(o)).messages.direct({
to: agent,
text,
...(o.mode ? { mode: o.mode as 'wait' | 'steer' } : {}),
})
directMessageReceipt(
await relay.messages.direct({
to: agent,
text,
...(mode ? { mode } : {}),
}),
agent,
mode,
resolvedRecipient
)
);
});
});
Expand Down Expand Up @@ -228,7 +245,10 @@ export function registerMessageCommands(
.argument('<messageId>', 'Message id')
).action(async (messageId: string, o: Record<string, unknown>) => {
await runSdk(deps, async () => {
printJson(deps, await deps.createAgentRelay(opts(o)).messages.readers(messageId));
printJson(
deps,
messageReadersReceipt(await deps.createAgentRelay(opts(o)).messages.readers(messageId))
);
});
});

Expand Down
17 changes: 15 additions & 2 deletions packages/cli/src/cli/commands/relaycast-groups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function createRelayMock() {
messages: {
send: vi.fn(async (i: unknown) => ({ id: 'm1', ...(i as object) })),
direct: vi.fn(async (i: unknown) => ({ id: 'd1', ...(i as object) })),
readers: vi.fn(async () => []),
react: vi.fn(async () => ({ emoji: 'eyes', count: 1, agents: [] })),
},
integrations: {
Expand Down Expand Up @@ -145,13 +146,15 @@ describe('SDK-backed CLI groups', () => {
});

it('message dm send routes to messages.direct', async () => {
const { program, relay } = harness(registerMessageCommands);
const { program, relay, log } = harness(registerMessageCommands);
await program.parseAsync(['message', 'dm', 'send', 'lead', 'hi'], { from: 'user' });
expect(relay.messages.direct).toHaveBeenCalledWith({ to: 'lead', text: 'hi' });
expect(log).toHaveBeenCalledWith(expect.stringContaining('"status": "queued_unconfirmed"'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('"resolvedRecipient": "lead"'));
});

it('message dm send exposes immediate Relay delivery', async () => {
const { program, relay } = harness(registerMessageCommands);
const { program, relay, log } = harness(registerMessageCommands);
await program.parseAsync(['message', 'dm', 'send', 'lead', 'wake up', '--mode', 'steer'], {
from: 'user',
});
Expand All @@ -160,6 +163,16 @@ describe('SDK-backed CLI groups', () => {
text: 'wake up',
mode: 'steer',
});
expect(log).toHaveBeenCalledWith(expect.stringContaining('"mode": "steer"'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('immediate injection'));
});

it('message inbox get_readers signals that an empty reader list is still queued or unread', async () => {
const { program, relay, log } = harness(registerMessageCommands);
await program.parseAsync(['message', 'inbox', 'get_readers', 'd1'], { from: 'user' });
expect(relay.messages.readers).toHaveBeenCalledWith('d1');
expect(log).toHaveBeenCalledWith(expect.stringContaining('"status": "queued_or_unread"'));
expect(log).toHaveBeenCalledWith(expect.stringContaining('"readConfirmed": false'));
});

it('integration webhook create routes to integrations.webhooks.create', async () => {
Expand Down
98 changes: 98 additions & 0 deletions packages/cli/src/cli/lib/message-delivery-receipts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
export type DirectMessageMode = 'wait' | 'steer';

export type DirectMessageDeliveryReceipt = Record<string, unknown> & {
target?: { kind: 'agent'; agentName: string };
delivery: {
status: 'queued_unconfirmed' | 'recipient_mismatch' | 'recipient_unresolved';
mode: DirectMessageMode;
requestedRecipient: string;
resolvedRecipient: string | null;
recipientMatched: boolean | null;
readConfirmed: false;
note: string;
};
};

function asRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: { value };
}

/** Resolve only a full, exact agent name; never fall back to a prefix. */
export function resolveExactAgentName(agents: readonly unknown[], requestedRecipient: string): string {
const resolvedRecipient = agents
.map((agent) => {
const name = asRecord(agent).name;
return typeof name === 'string' ? name : undefined;
})
.find((name) => name === requestedRecipient);
if (!resolvedRecipient) {
throw new Error(`Recipient "${requestedRecipient}" was not found by exact agent-name match.`);
}
return resolvedRecipient;
}

/**
* Add the delivery facts that Relaycast's create-message response does not
* contain. A message id confirms durable enqueue only; delivery/read
* confirmation remains observable through get_message_readers. The resolved
* recipient must come from an independent directory lookup; the created
* message's target may only echo the request and is deliberately not trusted.
*/
export function directMessageReceipt(
value: unknown,
requestedRecipient: string,
mode: DirectMessageMode = 'wait',
resolvedRecipient?: string
): DirectMessageDeliveryReceipt {
const message = asRecord(value);
const messageWithoutUntrustedTarget = { ...message };
delete messageWithoutUntrustedTarget.target;
const recipientMatched = resolvedRecipient ? resolvedRecipient === requestedRecipient : null;
const status =
recipientMatched === null
? 'recipient_unresolved'
: recipientMatched
? 'queued_unconfirmed'
: 'recipient_mismatch';
const note =
recipientMatched === null
? `Recipient resolution was unavailable for ${requestedRecipient}; enqueue is not reported as successful delivery.`
: recipientMatched
? mode === 'steer'
? 'Queued as an immediate injection request that may interrupt active work. This receipt does not confirm delivery or reading; call get_message_readers with the message id.'
: "Queued for injection at the recipient's next safe idle boundary. It can remain unread while the recipient is busy. This receipt does not confirm delivery or reading; call get_message_readers with the message id."
: `Recipient mismatch: requested ${requestedRecipient}, but the directory resolved ${resolvedRecipient}.`;

return {
...messageWithoutUntrustedTarget,
...(resolvedRecipient ? { target: { kind: 'agent' as const, agentName: resolvedRecipient } } : {}),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
delivery: {
status,
mode,
requestedRecipient,
resolvedRecipient: resolvedRecipient ?? null,
recipientMatched,
readConfirmed: false,
note,
},
};
}

export function messageReadersReceipt(readers: unknown[]): {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new messageReadersReceipt helper (which turns an empty reader list into an explicit queued_or_unread signal) is wired only into the MCP get_message_readers tool, not into the CLI's inbox get_readers / dm get_readers command in commands/message.ts. The CLI still prints the raw readers array with no delivery signal, so at the CLI public choice point an empty result is not surfaced as queued_or_unread — inconsistent with the PR's goal of exposing this signal. Consider applying the same receipt in the CLI get_readers handlers for a consistent contract across surfaces.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/cli/src/cli/lib/message-delivery-receipts.ts, line 69:

<comment>The new `messageReadersReceipt` helper (which turns an empty reader list into an explicit `queued_or_unread` signal) is wired only into the MCP `get_message_readers` tool, not into the CLI's `inbox get_readers` / `dm get_readers` command in `commands/message.ts`. The CLI still prints the raw `readers` array with no delivery signal, so at the CLI public choice point an empty result is not surfaced as `queued_or_unread` — inconsistent with the PR's goal of exposing this signal. Consider applying the same receipt in the CLI `get_readers` handlers for a consistent contract across surfaces.</comment>

<file context>
@@ -0,0 +1,84 @@
+  };
+}
+
+export function messageReadersReceipt(readers: unknown[]): {
+  readers: unknown[];
+  delivery: { status: 'read' | 'queued_or_unread'; readConfirmed: boolean; signal: string };
</file context>

@khaliqgant khaliqgant Aug 8, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 49f8ba3. CLI message inbox get_readers now emits the same read or queued_or_unread receipt contract as the MCP tool, with a dedicated regression.

readers: unknown[];
delivery: { status: 'read' | 'queued_or_unread'; readConfirmed: boolean; signal: string };
} {
const readConfirmed = readers.length > 0;
return {
readers,
delivery: {
status: readConfirmed ? 'read' : 'queued_or_unread',
readConfirmed,
signal: readConfirmed
? 'At least one agent has read this message.'
: 'No agent has read this message. A send receipt confirms enqueue only; the recipient may still be busy or offline.',
},
};
}
Loading
Loading