@awebai/claude-channel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @awebai/claude-channel
2
+
3
+ MCP stdio bridge for the aweb coordination server.
4
+
5
+ ## Usage
6
+
7
+ Run the channel in a workspace that already has `.aw/workspace.yaml` and aw account config:
8
+
9
+ ```bash
10
+ npx @awebai/claude-channel
11
+ ```
12
+
13
+ For local development:
14
+
15
+ ```bash
16
+ npm install
17
+ npm start
18
+ ```
@@ -0,0 +1,21 @@
1
+ import type { APIClient } from "./client.js";
2
+ import type { VerificationStatus } from "../identity/signing.js";
3
+ export interface ChatMessage {
4
+ message_id: string;
5
+ from_agent: string;
6
+ from_address?: string;
7
+ to_address?: string;
8
+ body: string;
9
+ timestamp: string;
10
+ sender_leaving: boolean;
11
+ from_did?: string;
12
+ to_did?: string;
13
+ from_stable_id?: string;
14
+ to_stable_id?: string;
15
+ signature?: string;
16
+ signing_key_id?: string;
17
+ signed_payload?: string;
18
+ verification_status?: VerificationStatus;
19
+ }
20
+ export declare function fetchHistory(client: APIClient, sessionId: string, unreadOnly?: boolean, limit?: number): Promise<ChatMessage[]>;
21
+ export declare function markRead(client: APIClient, sessionId: string, upToMessageId: string): Promise<void>;
@@ -0,0 +1,38 @@
1
+ import { verifyMessage, verifySignedPayload } from "../identity/signing.js";
2
+ export async function fetchHistory(client, sessionId, unreadOnly = false, limit = 50) {
3
+ const params = new URLSearchParams();
4
+ if (unreadOnly)
5
+ params.set("unread_only", "true");
6
+ if (limit > 0)
7
+ params.set("limit", String(limit));
8
+ const resp = await client.get(`/v1/chat/sessions/${encodeURIComponent(sessionId)}/messages?${params}`);
9
+ for (const msg of resp.messages) {
10
+ msg.verification_status = await verifyChatMessage(msg);
11
+ }
12
+ return resp.messages;
13
+ }
14
+ export async function markRead(client, sessionId, upToMessageId) {
15
+ await client.post(`/v1/chat/sessions/${encodeURIComponent(sessionId)}/read`, { up_to_message_id: upToMessageId });
16
+ }
17
+ async function verifyChatMessage(msg) {
18
+ if (msg.signed_payload && msg.signature && msg.from_did) {
19
+ return verifySignedPayload(msg.signed_payload, msg.signature, msg.from_did, msg.signing_key_id || "");
20
+ }
21
+ const from = msg.from_address || msg.from_agent;
22
+ const env = {
23
+ from,
24
+ from_did: msg.from_did || "",
25
+ to: msg.to_address || "",
26
+ to_did: msg.to_did || "",
27
+ type: "chat",
28
+ subject: "",
29
+ body: msg.body,
30
+ timestamp: msg.timestamp,
31
+ from_stable_id: msg.from_stable_id,
32
+ to_stable_id: msg.to_stable_id,
33
+ message_id: msg.message_id,
34
+ signature: msg.signature,
35
+ signing_key_id: msg.signing_key_id,
36
+ };
37
+ return verifyMessage(env);
38
+ }
@@ -0,0 +1,15 @@
1
+ export declare class APIClient {
2
+ private baseURL;
3
+ private apiKey;
4
+ constructor(baseURL: string, apiKey: string);
5
+ get<T>(path: string): Promise<T>;
6
+ post<T>(path: string, body?: unknown): Promise<T>;
7
+ private request;
8
+ /** Open an SSE stream. Returns the raw Response for streaming. */
9
+ openSSE(path: string): Promise<Response>;
10
+ }
11
+ export declare class APIError extends Error {
12
+ statusCode: number;
13
+ body: string;
14
+ constructor(statusCode: number, body: string);
15
+ }
@@ -0,0 +1,57 @@
1
+ export class APIClient {
2
+ baseURL;
3
+ apiKey;
4
+ constructor(baseURL, apiKey) {
5
+ this.baseURL = baseURL;
6
+ this.apiKey = apiKey;
7
+ }
8
+ async get(path) {
9
+ return this.request("GET", path);
10
+ }
11
+ async post(path, body) {
12
+ return this.request("POST", path, body);
13
+ }
14
+ async request(method, path, body) {
15
+ const url = this.baseURL + path;
16
+ const headers = {
17
+ Authorization: `Bearer ${this.apiKey}`,
18
+ Accept: "application/json",
19
+ };
20
+ const init = { method, headers };
21
+ if (body !== undefined) {
22
+ headers["Content-Type"] = "application/json";
23
+ init.body = JSON.stringify(body);
24
+ }
25
+ const resp = await fetch(url, init);
26
+ if (!resp.ok) {
27
+ const text = await resp.text().catch(() => "");
28
+ throw new APIError(resp.status, text);
29
+ }
30
+ return resp.json();
31
+ }
32
+ /** Open an SSE stream. Returns the raw Response for streaming. */
33
+ async openSSE(path) {
34
+ const url = this.baseURL + path;
35
+ const resp = await fetch(url, {
36
+ headers: {
37
+ Authorization: `Bearer ${this.apiKey}`,
38
+ Accept: "text/event-stream",
39
+ "Cache-Control": "no-cache",
40
+ },
41
+ });
42
+ if (!resp.ok) {
43
+ const text = await resp.text().catch(() => "");
44
+ throw new APIError(resp.status, text);
45
+ }
46
+ return resp;
47
+ }
48
+ }
49
+ export class APIError extends Error {
50
+ statusCode;
51
+ body;
52
+ constructor(statusCode, body) {
53
+ super(body ? `aweb: http ${statusCode}: ${body}` : `aweb: http ${statusCode}`);
54
+ this.statusCode = statusCode;
55
+ this.body = body;
56
+ }
57
+ }
@@ -0,0 +1,23 @@
1
+ import type { APIClient } from "./client.js";
2
+ export type AgentEventType = "connected" | "mail_message" | "chat_message" | "control_pause" | "control_resume" | "control_interrupt" | "work_available" | "claim_update" | "claim_removed" | "error";
3
+ export interface AgentEvent {
4
+ type: AgentEventType;
5
+ agent_id?: string;
6
+ project_id?: string;
7
+ message_id?: string;
8
+ from_alias?: string;
9
+ session_id?: string;
10
+ subject?: string;
11
+ signal_id?: string;
12
+ task_id?: string;
13
+ title?: string;
14
+ status?: string;
15
+ text?: string;
16
+ sender_waiting?: boolean;
17
+ }
18
+ /**
19
+ * Consume the agent event stream (GET /v1/events/stream).
20
+ * Yields parsed AgentEvent objects. Reconnects on stream end.
21
+ */
22
+ export declare function streamAgentEvents(client: APIClient, signal: AbortSignal): AsyncGenerator<AgentEvent>;
23
+ export declare function parseAgentEvent(eventName: string, data: string): AgentEvent | null;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Consume the agent event stream (GET /v1/events/stream).
3
+ * Yields parsed AgentEvent objects. Reconnects on stream end.
4
+ */
5
+ export async function* streamAgentEvents(client, signal) {
6
+ while (!signal.aborted) {
7
+ const deadline = new Date(Date.now() + 5 * 60 * 1000).toISOString();
8
+ let resp;
9
+ try {
10
+ resp = await client.openSSE(`/v1/events/stream?deadline=${encodeURIComponent(deadline)}`);
11
+ }
12
+ catch (err) {
13
+ if (signal.aborted)
14
+ return;
15
+ // Back off on connection failure
16
+ await sleep(5000, signal);
17
+ continue;
18
+ }
19
+ try {
20
+ yield* parseSSEResponse(resp, signal);
21
+ }
22
+ catch (err) {
23
+ if (signal.aborted)
24
+ return;
25
+ // Stream ended or errored — reconnect after brief pause
26
+ await sleep(1000, signal);
27
+ }
28
+ finally {
29
+ resp.body?.cancel().catch(() => { });
30
+ }
31
+ }
32
+ }
33
+ async function* parseSSEResponse(resp, signal) {
34
+ const reader = resp.body?.getReader();
35
+ if (!reader)
36
+ return;
37
+ const decoder = new TextDecoder();
38
+ let buffer = "";
39
+ let currentEvent = "";
40
+ let dataLines = [];
41
+ try {
42
+ while (!signal.aborted) {
43
+ const { done, value } = await reader.read();
44
+ if (done)
45
+ break;
46
+ buffer += decoder.decode(value, { stream: true });
47
+ const lines = buffer.split("\n");
48
+ buffer = lines.pop() || "";
49
+ for (const rawLine of lines) {
50
+ const line = rawLine.replace(/\r$/, "");
51
+ if (line === "") {
52
+ // Empty line = event boundary
53
+ if (currentEvent || dataLines.length > 0) {
54
+ const event = parseAgentEvent(currentEvent, dataLines.join("\n"));
55
+ if (event)
56
+ yield event;
57
+ currentEvent = "";
58
+ dataLines = [];
59
+ }
60
+ continue;
61
+ }
62
+ if (line.startsWith(":"))
63
+ continue; // comment
64
+ if (line.startsWith("event:")) {
65
+ currentEvent = line.slice(6).trim();
66
+ }
67
+ else if (line.startsWith("data:")) {
68
+ dataLines.push(line.slice(5).trim());
69
+ }
70
+ }
71
+ }
72
+ }
73
+ finally {
74
+ reader.releaseLock();
75
+ }
76
+ }
77
+ const KNOWN_TYPES = new Set([
78
+ "connected", "mail_message", "chat_message",
79
+ "control_pause", "control_resume", "control_interrupt",
80
+ "work_available", "claim_update", "claim_removed", "error",
81
+ "actionable_mail", "actionable_chat",
82
+ ]);
83
+ export function parseAgentEvent(eventName, data) {
84
+ eventName = eventName.trim();
85
+ if (!eventName)
86
+ return null;
87
+ if (!KNOWN_TYPES.has(eventName))
88
+ return null;
89
+ if (eventName === "actionable_mail")
90
+ eventName = "mail_message";
91
+ if (eventName === "actionable_chat")
92
+ eventName = "chat_message";
93
+ try {
94
+ const payload = JSON.parse(data);
95
+ return { ...payload, type: eventName };
96
+ }
97
+ catch {
98
+ return { type: eventName };
99
+ }
100
+ }
101
+ function sleep(ms, signal) {
102
+ return new Promise((resolve) => {
103
+ if (signal.aborted) {
104
+ resolve();
105
+ return;
106
+ }
107
+ const timer = setTimeout(resolve, ms);
108
+ signal.addEventListener("abort", () => { clearTimeout(timer); resolve(); }, { once: true });
109
+ });
110
+ }
@@ -0,0 +1,26 @@
1
+ import type { APIClient } from "./client.js";
2
+ import type { VerificationStatus } from "../identity/signing.js";
3
+ export interface InboxMessage {
4
+ message_id: string;
5
+ from_agent_id: string;
6
+ from_alias: string;
7
+ to_alias?: string;
8
+ from_address?: string;
9
+ to_address?: string;
10
+ subject: string;
11
+ body: string;
12
+ priority: string;
13
+ thread_id?: string;
14
+ read_at?: string;
15
+ created_at: string;
16
+ from_did?: string;
17
+ to_did?: string;
18
+ from_stable_id?: string;
19
+ to_stable_id?: string;
20
+ signature?: string;
21
+ signing_key_id?: string;
22
+ signed_payload?: string;
23
+ verification_status?: VerificationStatus;
24
+ }
25
+ export declare function fetchInbox(client: APIClient, unreadOnly?: boolean, limit?: number): Promise<InboxMessage[]>;
26
+ export declare function ackMessage(client: APIClient, messageId: string): Promise<void>;
@@ -0,0 +1,40 @@
1
+ import { verifyMessage, verifySignedPayload } from "../identity/signing.js";
2
+ export async function fetchInbox(client, unreadOnly = true, limit = 50) {
3
+ const params = new URLSearchParams();
4
+ if (unreadOnly)
5
+ params.set("unread_only", "true");
6
+ if (limit > 0)
7
+ params.set("limit", String(limit));
8
+ const resp = await client.get(`/v1/messages/inbox?${params}`);
9
+ // Verify signatures on received messages
10
+ for (const msg of resp.messages) {
11
+ msg.verification_status = await verifyInboxMessage(msg);
12
+ }
13
+ return resp.messages;
14
+ }
15
+ export async function ackMessage(client, messageId) {
16
+ await client.post(`/v1/messages/${encodeURIComponent(messageId)}/ack`);
17
+ }
18
+ async function verifyInboxMessage(msg) {
19
+ if (msg.signed_payload && msg.signature && msg.from_did) {
20
+ return verifySignedPayload(msg.signed_payload, msg.signature, msg.from_did, msg.signing_key_id || "");
21
+ }
22
+ const from = msg.from_address || msg.from_alias;
23
+ const to = msg.to_address || msg.to_alias || "";
24
+ const env = {
25
+ from,
26
+ from_did: msg.from_did || "",
27
+ to,
28
+ to_did: msg.to_did || "",
29
+ type: "mail",
30
+ subject: msg.subject,
31
+ body: msg.body,
32
+ timestamp: msg.created_at,
33
+ from_stable_id: msg.from_stable_id,
34
+ to_stable_id: msg.to_stable_id,
35
+ message_id: msg.message_id,
36
+ signature: msg.signature,
37
+ signing_key_id: msg.signing_key_id,
38
+ };
39
+ return verifyMessage(env);
40
+ }
@@ -0,0 +1,15 @@
1
+ export interface AgentConfig {
2
+ baseURL: string;
3
+ apiKey: string;
4
+ did: string;
5
+ stableID: string;
6
+ address: string;
7
+ alias: string;
8
+ projectSlug: string;
9
+ }
10
+ /**
11
+ * Resolve agent configuration from the aw config files.
12
+ * Resolution order: worktree context → global config.
13
+ * Reads the same files as the Go aw client.
14
+ */
15
+ export declare function resolveConfig(workdir: string): Promise<AgentConfig>;
package/dist/config.js ADDED
@@ -0,0 +1,66 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import yaml from "js-yaml";
5
+ /**
6
+ * Resolve agent configuration from the aw config files.
7
+ * Resolution order: worktree context → global config.
8
+ * Reads the same files as the Go aw client.
9
+ */
10
+ export async function resolveConfig(workdir) {
11
+ const globalPath = process.env.AW_CONFIG_PATH || join(homedir(), ".config", "aw", "config.yaml");
12
+ const contextPath = join(workdir, ".aw", "context");
13
+ const workspacePath = join(workdir, ".aw", "workspace.yaml");
14
+ const globalConfig = await readYAML(globalPath);
15
+ if (!globalConfig?.accounts || Object.keys(globalConfig.accounts).length === 0) {
16
+ throw new Error(`no accounts configured in ${globalPath}`);
17
+ }
18
+ // Determine account name from worktree context or first available
19
+ let accountName;
20
+ const context = await readYAML(contextPath);
21
+ if (context?.default_account) {
22
+ accountName = context.default_account;
23
+ }
24
+ else if (context?.client_default_accounts?.aw) {
25
+ accountName = context.client_default_accounts.aw;
26
+ }
27
+ if (!accountName) {
28
+ accountName = Object.keys(globalConfig.accounts)[0];
29
+ }
30
+ const account = globalConfig.accounts[accountName];
31
+ if (!account) {
32
+ throw new Error(`account "${accountName}" not found in ${globalPath}`);
33
+ }
34
+ if (!account.api_key) {
35
+ throw new Error(`account "${accountName}" has no api_key`);
36
+ }
37
+ // Resolve server URL
38
+ const serverName = account.server || "default";
39
+ const server = globalConfig.servers?.[serverName];
40
+ const baseURL = server?.url || "https://app.aweb.ai";
41
+ // Resolve address
42
+ const namespace = account.namespace_slug || "";
43
+ const alias = account.alias || "";
44
+ const address = namespace && alias ? `${namespace}/${alias}` : "";
45
+ // Read workspace config for project slug
46
+ const workspace = await readYAML(workspacePath);
47
+ const projectSlug = account.default_project || workspace?.project_slug || "";
48
+ return {
49
+ baseURL,
50
+ apiKey: account.api_key,
51
+ did: account.did || "",
52
+ stableID: account.stable_id || "",
53
+ address,
54
+ alias,
55
+ projectSlug,
56
+ };
57
+ }
58
+ async function readYAML(path) {
59
+ try {
60
+ const content = await readFile(path, "utf-8");
61
+ return yaml.load(content) || null;
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
@@ -0,0 +1,6 @@
1
+ /** Encode an Ed25519 public key as a did:key DID string. */
2
+ export declare function computeDIDKey(publicKey: Uint8Array): string;
3
+ /** Decode a did:key DID string to an Ed25519 public key. */
4
+ export declare function extractPublicKey(did: string): Uint8Array;
5
+ /** Derive the canonical did:aw stable identifier from an Ed25519 public key. */
6
+ export declare function computeStableID(publicKey: Uint8Array): string;
@@ -0,0 +1,30 @@
1
+ import { createHash } from "node:crypto";
2
+ import bs58 from "bs58";
3
+ const DID_KEY_PREFIX = "did:key:z";
4
+ const ED25519_MULTICODEC = new Uint8Array([0xed, 0x01]);
5
+ /** Encode an Ed25519 public key as a did:key DID string. */
6
+ export function computeDIDKey(publicKey) {
7
+ const buf = new Uint8Array(2 + publicKey.length);
8
+ buf.set(ED25519_MULTICODEC);
9
+ buf.set(publicKey, 2);
10
+ return DID_KEY_PREFIX + bs58.encode(buf);
11
+ }
12
+ /** Decode a did:key DID string to an Ed25519 public key. */
13
+ export function extractPublicKey(did) {
14
+ if (!did.startsWith(DID_KEY_PREFIX)) {
15
+ throw new Error(`invalid did:key: missing prefix "${DID_KEY_PREFIX}"`);
16
+ }
17
+ const decoded = bs58.decode(did.slice(DID_KEY_PREFIX.length));
18
+ if (decoded.length !== 34) {
19
+ throw new Error(`invalid did:key: expected 34 bytes, got ${decoded.length}`);
20
+ }
21
+ if (decoded[0] !== 0xed || decoded[1] !== 0x01) {
22
+ throw new Error(`invalid did:key: expected Ed25519 multicodec 0xed01, got 0x${decoded[0].toString(16).padStart(2, "0")}${decoded[1].toString(16).padStart(2, "0")}`);
23
+ }
24
+ return decoded.slice(2);
25
+ }
26
+ /** Derive the canonical did:aw stable identifier from an Ed25519 public key. */
27
+ export function computeStableID(publicKey) {
28
+ const hash = createHash("sha256").update(publicKey).digest();
29
+ return "did:aw:" + bs58.encode(hash.subarray(0, 20));
30
+ }
@@ -0,0 +1,2 @@
1
+ /** Load an Ed25519 private key seed from a PEM file. */
2
+ export declare function loadSigningKey(path: string): Promise<Uint8Array>;
@@ -0,0 +1,18 @@
1
+ import { readFile } from "node:fs/promises";
2
+ /** Load an Ed25519 private key seed from a PEM file. */
3
+ export async function loadSigningKey(path) {
4
+ const content = await readFile(path, "utf-8");
5
+ const match = content.match(/-----BEGIN ED25519 PRIVATE KEY-----\r?\n([\s\S]+?)\r?\n-----END ED25519 PRIVATE KEY-----/);
6
+ if (!match) {
7
+ throw new Error(`no ED25519 PRIVATE KEY PEM block in ${path}`);
8
+ }
9
+ const b64 = match[1].replace(/\s/g, "");
10
+ const bin = atob(b64);
11
+ const bytes = new Uint8Array(bin.length);
12
+ for (let i = 0; i < bin.length; i++)
13
+ bytes[i] = bin.charCodeAt(i);
14
+ if (bytes.length !== 32) {
15
+ throw new Error(`invalid seed size ${bytes.length} in ${path}`);
16
+ }
17
+ return bytes;
18
+ }
@@ -0,0 +1,22 @@
1
+ export type PinResult = "ok" | "new" | "mismatch" | "skipped";
2
+ export interface Pin {
3
+ address: string;
4
+ handle: string;
5
+ stable_id?: string;
6
+ did_key?: string;
7
+ first_seen: string;
8
+ last_seen: string;
9
+ server: string;
10
+ }
11
+ export declare class PinStore {
12
+ pins: Map<string, Pin>;
13
+ addresses: Map<string, string>;
14
+ /** Check whether a DID matches the stored pin for an address. */
15
+ checkPin(address: string, did: string, lifetime: string): PinResult;
16
+ /** Record or update a TOFU pin. */
17
+ storePin(did: string, address: string, handle: string, server: string): void;
18
+ /** Serialize to YAML (compatible with Go's known_agents.yaml). */
19
+ toYAML(): string;
20
+ /** Deserialize from YAML. */
21
+ static fromYAML(content: string): PinStore;
22
+ }
@@ -0,0 +1,68 @@
1
+ import yaml from "js-yaml";
2
+ export class PinStore {
3
+ pins = new Map();
4
+ addresses = new Map();
5
+ /** Check whether a DID matches the stored pin for an address. */
6
+ checkPin(address, did, lifetime) {
7
+ if (lifetime === "ephemeral")
8
+ return "skipped";
9
+ const pinnedDID = this.addresses.get(address);
10
+ if (pinnedDID === undefined)
11
+ return "new";
12
+ if (pinnedDID === did)
13
+ return "ok";
14
+ return "mismatch";
15
+ }
16
+ /** Record or update a TOFU pin. */
17
+ storePin(did, address, handle, server) {
18
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
19
+ const existing = this.pins.get(did);
20
+ if (existing) {
21
+ if (existing.address !== address) {
22
+ this.addresses.delete(existing.address);
23
+ this.addresses.set(address, did);
24
+ existing.address = address;
25
+ }
26
+ existing.last_seen = now;
27
+ existing.handle = handle;
28
+ existing.server = server;
29
+ return;
30
+ }
31
+ this.pins.set(did, {
32
+ address,
33
+ handle,
34
+ first_seen: now,
35
+ last_seen: now,
36
+ server,
37
+ });
38
+ this.addresses.set(address, did);
39
+ }
40
+ /** Serialize to YAML (compatible with Go's known_agents.yaml). */
41
+ toYAML() {
42
+ const pinsObj = {};
43
+ for (const [k, v] of this.pins)
44
+ pinsObj[k] = v;
45
+ const addrsObj = {};
46
+ for (const [k, v] of this.addresses)
47
+ addrsObj[k] = v;
48
+ return yaml.dump({ pins: pinsObj, addresses: addrsObj });
49
+ }
50
+ /** Deserialize from YAML. */
51
+ static fromYAML(content) {
52
+ const data = yaml.load(content);
53
+ const store = new PinStore();
54
+ if (!data)
55
+ return store;
56
+ if (data.pins) {
57
+ for (const [k, v] of Object.entries(data.pins)) {
58
+ store.pins.set(k, v);
59
+ }
60
+ }
61
+ if (data.addresses) {
62
+ for (const [k, v] of Object.entries(data.addresses)) {
63
+ store.addresses.set(k, v);
64
+ }
65
+ }
66
+ return store;
67
+ }
68
+ }
@@ -0,0 +1,36 @@
1
+ export interface MessageEnvelope {
2
+ from: string;
3
+ from_did: string;
4
+ to: string;
5
+ to_did: string;
6
+ type: string;
7
+ subject: string;
8
+ body: string;
9
+ timestamp: string;
10
+ from_stable_id?: string;
11
+ to_stable_id?: string;
12
+ message_id?: string;
13
+ signature?: string;
14
+ signing_key_id?: string;
15
+ }
16
+ export type VerificationStatus = "verified" | "verified_custodial" | "unverified" | "failed" | "identity_mismatch";
17
+ /**
18
+ * Build the canonical JSON payload for message signing.
19
+ * Fields are sorted lexicographically, no whitespace, minimal escaping.
20
+ * Optional fields (from_stable_id, message_id, to_stable_id) are omitted when empty.
21
+ */
22
+ export declare function canonicalJSON(env: MessageEnvelope): string;
23
+ /** Sign a message envelope. Returns base64 signature (no padding). */
24
+ export declare function signMessage(seed: Uint8Array, env: MessageEnvelope): Promise<string>;
25
+ /**
26
+ * Verify a message envelope signature.
27
+ * Returns 'unverified' if DID or signature is missing.
28
+ * Returns 'failed' if signature doesn't verify.
29
+ * Returns 'verified' if valid.
30
+ */
31
+ export declare function verifyMessage(env: MessageEnvelope): Promise<VerificationStatus>;
32
+ /**
33
+ * Verify a signature against a pre-computed canonical payload string.
34
+ * Use when the server returns signed_payload alongside the message.
35
+ */
36
+ export declare function verifySignedPayload(signedPayload: string, signatureB64: string, fromDID: string, signingKeyID: string): Promise<VerificationStatus>;
@@ -0,0 +1,161 @@
1
+ import * as ed from "@noble/ed25519";
2
+ import { sha512 } from "@noble/hashes/sha2.js";
3
+ import { extractPublicKey } from "./did.js";
4
+ // @noble/ed25519 v2 requires setting the hash function
5
+ ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));
6
+ /**
7
+ * Build the canonical JSON payload for message signing.
8
+ * Fields are sorted lexicographically, no whitespace, minimal escaping.
9
+ * Optional fields (from_stable_id, message_id, to_stable_id) are omitted when empty.
10
+ */
11
+ export function canonicalJSON(env) {
12
+ const fields = [
13
+ ["body", env.body],
14
+ ["from", env.from],
15
+ ["from_did", env.from_did],
16
+ ["subject", env.subject],
17
+ ["timestamp", env.timestamp],
18
+ ["to", env.to],
19
+ ["to_did", env.to_did],
20
+ ["type", env.type],
21
+ ];
22
+ if (env.from_stable_id)
23
+ fields.push(["from_stable_id", env.from_stable_id]);
24
+ if (env.message_id)
25
+ fields.push(["message_id", env.message_id]);
26
+ if (env.to_stable_id)
27
+ fields.push(["to_stable_id", env.to_stable_id]);
28
+ fields.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
29
+ let result = "{";
30
+ for (let i = 0; i < fields.length; i++) {
31
+ if (i > 0)
32
+ result += ",";
33
+ result += '"' + fields[i][0] + '":"' + escapeJSON(fields[i][1]) + '"';
34
+ }
35
+ result += "}";
36
+ return result;
37
+ }
38
+ /** JSON-escape a string value, matching Go's writeEscapedString exactly. */
39
+ function escapeJSON(s) {
40
+ let result = "";
41
+ for (const ch of s) {
42
+ const code = ch.codePointAt(0);
43
+ switch (ch) {
44
+ case '"':
45
+ result += '\\"';
46
+ break;
47
+ case "\\":
48
+ result += "\\\\";
49
+ break;
50
+ case "\n":
51
+ result += "\\n";
52
+ break;
53
+ case "\r":
54
+ result += "\\r";
55
+ break;
56
+ case "\t":
57
+ result += "\\t";
58
+ break;
59
+ case "\b":
60
+ result += "\\b";
61
+ break;
62
+ case "\f":
63
+ result += "\\f";
64
+ break;
65
+ default:
66
+ if (code < 0x20) {
67
+ result += "\\u" + code.toString(16).padStart(4, "0");
68
+ }
69
+ else {
70
+ result += ch;
71
+ }
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+ function b64Encode(bytes) {
77
+ // Build binary string without spread to avoid stack overflow on large inputs
78
+ let bin = "";
79
+ for (let i = 0; i < bytes.length; i++)
80
+ bin += String.fromCharCode(bytes[i]);
81
+ // Base64 RFC 4648 no padding (RawStdEncoding)
82
+ return btoa(bin).replace(/=+$/, "");
83
+ }
84
+ function b64Decode(s) {
85
+ const bin = atob(s);
86
+ const bytes = new Uint8Array(bin.length);
87
+ for (let i = 0; i < bin.length; i++)
88
+ bytes[i] = bin.charCodeAt(i);
89
+ return bytes;
90
+ }
91
+ /** Sign a message envelope. Returns base64 signature (no padding). */
92
+ export async function signMessage(seed, env) {
93
+ const payload = canonicalJSON(env);
94
+ const sig = ed.sign(new TextEncoder().encode(payload), seed);
95
+ return b64Encode(sig);
96
+ }
97
+ /**
98
+ * Verify a message envelope signature.
99
+ * Returns 'unverified' if DID or signature is missing.
100
+ * Returns 'failed' if signature doesn't verify.
101
+ * Returns 'verified' if valid.
102
+ */
103
+ export async function verifyMessage(env) {
104
+ if (!env.from_did || !env.signature) {
105
+ return "unverified";
106
+ }
107
+ if (env.signing_key_id && env.signing_key_id !== env.from_did) {
108
+ return "failed";
109
+ }
110
+ if (!env.from_did.startsWith("did:key:z")) {
111
+ return "unverified";
112
+ }
113
+ let publicKey;
114
+ try {
115
+ publicKey = extractPublicKey(env.from_did);
116
+ }
117
+ catch {
118
+ return "failed";
119
+ }
120
+ let sigBytes;
121
+ try {
122
+ sigBytes = b64Decode(env.signature);
123
+ }
124
+ catch {
125
+ return "failed";
126
+ }
127
+ const payload = canonicalJSON(env);
128
+ const valid = ed.verify(sigBytes, new TextEncoder().encode(payload), publicKey);
129
+ return valid ? "verified" : "failed";
130
+ }
131
+ /**
132
+ * Verify a signature against a pre-computed canonical payload string.
133
+ * Use when the server returns signed_payload alongside the message.
134
+ */
135
+ export async function verifySignedPayload(signedPayload, signatureB64, fromDID, signingKeyID) {
136
+ if (!fromDID || !signatureB64 || !signedPayload) {
137
+ return "unverified";
138
+ }
139
+ if (signingKeyID && signingKeyID !== fromDID) {
140
+ return "failed";
141
+ }
142
+ if (!fromDID.startsWith("did:key:z")) {
143
+ return "unverified";
144
+ }
145
+ let publicKey;
146
+ try {
147
+ publicKey = extractPublicKey(fromDID);
148
+ }
149
+ catch {
150
+ return "failed";
151
+ }
152
+ let sigBytes;
153
+ try {
154
+ sigBytes = b64Decode(signatureB64);
155
+ }
156
+ catch {
157
+ return "failed";
158
+ }
159
+ const valid = ed.verify(sigBytes, new TextEncoder().encode(signedPayload), publicKey);
160
+ return valid ? "verified" : "failed";
161
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { APIClient } from "./api/client.js";
4
+ import { type AgentEvent } from "./api/events.js";
5
+ import { PinStore } from "./identity/pinstore.js";
6
+ export declare function dispatchEvent(mcp: Server, client: APIClient, pinStore: PinStore, selfAlias: string, dispatched: Set<string>, event: AgentEvent): Promise<void>;
7
+ export declare function isDirectExecution(moduleURL: string): boolean;
package/dist/index.js ADDED
@@ -0,0 +1,259 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { join } from "node:path";
5
+ import { homedir } from "node:os";
6
+ import { realpathSync } from "node:fs";
7
+ import { readFile, writeFile } from "node:fs/promises";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import { resolveConfig } from "./config.js";
10
+ import { APIClient } from "./api/client.js";
11
+ import { streamAgentEvents } from "./api/events.js";
12
+ import { fetchInbox, ackMessage } from "./api/mail.js";
13
+ import { fetchHistory, markRead } from "./api/chat.js";
14
+ import { PinStore } from "./identity/pinstore.js";
15
+ const PIN_STORE_PATH = join(homedir(), ".config", "aw", "known_agents.yaml");
16
+ const MAX_DISPATCHED_IDS = 2000;
17
+ async function loadPinStore() {
18
+ try {
19
+ const content = await readFile(PIN_STORE_PATH, "utf-8");
20
+ return PinStore.fromYAML(content);
21
+ }
22
+ catch {
23
+ return new PinStore();
24
+ }
25
+ }
26
+ async function savePinStore(store) {
27
+ await writeFile(PIN_STORE_PATH, store.toYAML(), { mode: 0o600 });
28
+ }
29
+ function checkTOFUPin(store, verificationStatus, fromAlias, fromDID, fromStableID) {
30
+ if (!verificationStatus || verificationStatus !== "verified" || !fromDID || !fromAlias) {
31
+ return { status: verificationStatus, stored: false };
32
+ }
33
+ const pinKey = fromStableID?.startsWith("did:aw:") ? fromStableID : fromDID;
34
+ const result = store.checkPin(fromAlias, pinKey, "persistent");
35
+ switch (result) {
36
+ case "new":
37
+ store.storePin(pinKey, fromAlias, "", "");
38
+ if (fromStableID?.startsWith("did:aw:")) {
39
+ const pin = store.pins.get(pinKey);
40
+ pin.stable_id = fromStableID;
41
+ pin.did_key = fromDID;
42
+ }
43
+ return { status: verificationStatus, stored: true };
44
+ case "ok":
45
+ return { status: verificationStatus, stored: false };
46
+ case "mismatch":
47
+ return { status: "identity_mismatch", stored: false };
48
+ case "skipped":
49
+ return { status: verificationStatus, stored: false };
50
+ }
51
+ }
52
+ function pruneDispatched(dispatched) {
53
+ if (dispatched.size <= MAX_DISPATCHED_IDS)
54
+ return;
55
+ const excess = dispatched.size - MAX_DISPATCHED_IDS;
56
+ let removed = 0;
57
+ for (const id of dispatched) {
58
+ if (removed >= excess)
59
+ break;
60
+ dispatched.delete(id);
61
+ removed++;
62
+ }
63
+ }
64
+ async function main() {
65
+ const workdir = process.cwd();
66
+ const config = await resolveConfig(workdir);
67
+ const client = new APIClient(config.baseURL, config.apiKey);
68
+ const pinStore = await loadPinStore();
69
+ const mcp = new Server({ name: "aw", version: "0.0.1" }, {
70
+ capabilities: {
71
+ experimental: { "claude/channel": {} },
72
+ },
73
+ instructions: `Events from the aw channel are coordination messages from other agents in your team. Use the aw CLI to respond, not MCP tools.
74
+
75
+ Mail events (type="mail") are async. Read them and act if needed. Acknowledge with: aw mail ack <message_id>
76
+
77
+ Chat events (type="chat") may have sender_waiting="true", meaning the sender is blocked waiting for your reply. Respond promptly with: aw chat send-and-wait <from> "<reply>"
78
+ If you need more time, send a status update the same way.
79
+
80
+ Control events (type="control") are operational signals. On "pause", stop current work and wait. On "resume", continue. On "interrupt", stop and await new instructions.`,
81
+ });
82
+ // Connect MCP over stdio
83
+ const transport = new StdioServerTransport();
84
+ await mcp.connect(transport);
85
+ // Start SSE event loop in background
86
+ const abort = new AbortController();
87
+ process.on("SIGINT", () => abort.abort());
88
+ process.on("SIGTERM", () => abort.abort());
89
+ await startEventLoop(mcp, client, pinStore, config.alias, abort.signal);
90
+ }
91
+ async function startEventLoop(mcp, client, pinStore, selfAlias, signal) {
92
+ const dispatched = new Set();
93
+ for await (const event of streamAgentEvents(client, signal)) {
94
+ try {
95
+ await dispatchEvent(mcp, client, pinStore, selfAlias, dispatched, event);
96
+ pruneDispatched(dispatched);
97
+ }
98
+ catch (err) {
99
+ console.error(`[aw-channel] dispatch error: ${err}`);
100
+ }
101
+ }
102
+ }
103
+ export async function dispatchEvent(mcp, client, pinStore, selfAlias, dispatched, event) {
104
+ switch (event.type) {
105
+ case "mail_message": {
106
+ const messages = await fetchInbox(client, true, 10);
107
+ let pinsDirty = false;
108
+ for (const msg of messages) {
109
+ if (dispatched.has(msg.message_id))
110
+ continue;
111
+ dispatched.add(msg.message_id);
112
+ const from = msg.from_alias || msg.from_address || "";
113
+ const tofu = checkTOFUPin(pinStore, msg.verification_status, from, msg.from_did, msg.from_stable_id);
114
+ msg.verification_status = tofu.status;
115
+ if (tofu.stored)
116
+ pinsDirty = true;
117
+ const meta = {
118
+ type: "mail",
119
+ from,
120
+ message_id: msg.message_id,
121
+ };
122
+ if (msg.subject)
123
+ meta.subject = msg.subject;
124
+ if (msg.priority && msg.priority !== "normal")
125
+ meta.priority = msg.priority;
126
+ if (msg.verification_status)
127
+ meta.verified = String(msg.verification_status === "verified" || msg.verification_status === "verified_custodial");
128
+ await mcp.notification({
129
+ method: "notifications/claude/channel",
130
+ params: { content: msg.body, meta },
131
+ });
132
+ // Auto-ack: message has been delivered to Claude
133
+ ackMessage(client, msg.message_id).catch((err) => console.error(`[aw-channel] ack failed: ${err}`));
134
+ }
135
+ if (pinsDirty)
136
+ await savePinStore(pinStore);
137
+ break;
138
+ }
139
+ case "chat_message": {
140
+ if (!event.session_id)
141
+ break;
142
+ const messages = await fetchHistory(client, event.session_id, true, 10);
143
+ let pinsDirty = false;
144
+ let lastMessageId;
145
+ for (const msg of messages) {
146
+ if (msg.from_agent === selfAlias)
147
+ continue;
148
+ if (dispatched.has(msg.message_id))
149
+ continue;
150
+ dispatched.add(msg.message_id);
151
+ const tofu = checkTOFUPin(pinStore, msg.verification_status, msg.from_agent, msg.from_did, msg.from_stable_id);
152
+ msg.verification_status = tofu.status;
153
+ if (tofu.stored)
154
+ pinsDirty = true;
155
+ const meta = {
156
+ type: "chat",
157
+ from: msg.from_agent,
158
+ session_id: event.session_id,
159
+ message_id: msg.message_id,
160
+ };
161
+ if (event.sender_waiting)
162
+ meta.sender_waiting = "true";
163
+ if (msg.sender_leaving)
164
+ meta.sender_leaving = "true";
165
+ if (msg.verification_status)
166
+ meta.verified = String(msg.verification_status === "verified" || msg.verification_status === "verified_custodial");
167
+ await mcp.notification({
168
+ method: "notifications/claude/channel",
169
+ params: { content: msg.body, meta },
170
+ });
171
+ lastMessageId = msg.message_id;
172
+ }
173
+ // Mark read up to last dispatched message
174
+ if (lastMessageId) {
175
+ markRead(client, event.session_id, lastMessageId).catch((err) => console.error(`[aw-channel] mark-read failed: ${err}`));
176
+ }
177
+ if (pinsDirty)
178
+ await savePinStore(pinStore);
179
+ break;
180
+ }
181
+ case "control_pause":
182
+ case "control_resume":
183
+ case "control_interrupt": {
184
+ const signalType = event.type.replace("control_", "");
185
+ await mcp.notification({
186
+ method: "notifications/claude/channel",
187
+ params: {
188
+ content: "",
189
+ meta: {
190
+ type: "control",
191
+ signal: signalType,
192
+ signal_id: event.signal_id || "",
193
+ },
194
+ },
195
+ });
196
+ break;
197
+ }
198
+ case "work_available": {
199
+ await mcp.notification({
200
+ method: "notifications/claude/channel",
201
+ params: {
202
+ content: event.title || "",
203
+ meta: {
204
+ type: "work",
205
+ task_id: event.task_id || "",
206
+ },
207
+ },
208
+ });
209
+ break;
210
+ }
211
+ case "claim_update": {
212
+ await mcp.notification({
213
+ method: "notifications/claude/channel",
214
+ params: {
215
+ content: event.title || "",
216
+ meta: {
217
+ type: "claim",
218
+ task_id: event.task_id || "",
219
+ title: event.title || "",
220
+ status: event.status || "",
221
+ },
222
+ },
223
+ });
224
+ break;
225
+ }
226
+ case "claim_removed": {
227
+ await mcp.notification({
228
+ method: "notifications/claude/channel",
229
+ params: {
230
+ content: "",
231
+ meta: {
232
+ type: "claim_removed",
233
+ task_id: event.task_id || "",
234
+ },
235
+ },
236
+ });
237
+ break;
238
+ }
239
+ default:
240
+ break;
241
+ }
242
+ }
243
+ export function isDirectExecution(moduleURL) {
244
+ const entry = process.argv[1];
245
+ if (!entry)
246
+ return false;
247
+ try {
248
+ return realpathSync(entry) === realpathSync(fileURLToPath(moduleURL));
249
+ }
250
+ catch {
251
+ return moduleURL === pathToFileURL(entry).href;
252
+ }
253
+ }
254
+ if (isDirectExecution(import.meta.url)) {
255
+ main().catch((err) => {
256
+ console.error(`[aw-channel] fatal: ${err}`);
257
+ process.exit(1);
258
+ });
259
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@awebai/claude-channel",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "bin": "./dist/index.js",
8
+ "files": [
9
+ "dist",
10
+ "README.md",
11
+ "package.json"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.json",
15
+ "prepublishOnly": "npm run build",
16
+ "test": "vitest run --exclude test/integration.test.ts",
17
+ "test:integration": "vitest run test/integration.test.ts",
18
+ "start": "tsx src/index.ts"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.12.0",
22
+ "@noble/ed25519": "^2.2.3",
23
+ "@noble/hashes": "^2.0.1",
24
+ "bs58": "^6.0.0",
25
+ "js-yaml": "^4.1.0"
26
+ },
27
+ "devDependencies": {
28
+ "@types/js-yaml": "^4.0.9",
29
+ "@types/node": "^24.5.2",
30
+ "tsx": "^4.20.6",
31
+ "typescript": "^5.9.3",
32
+ "vitest": "^4.0.0"
33
+ }
34
+ }