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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## 0.1.1 (unreleased)

- Adds identity-bound A2A 1.0 client tools plus durable inbound task serving:
context-scoped sessions, restart catch-up, task-addressed cancellation, and
explicit complete/ask/fail intents. Outbound calls and replies use the
existing approval and recipient-allowlist controls.
- A2A features require `@inkbox/sdk` 0.5.5 or newer. The plugin keeps its
existing base dependency range for installations that do not use A2A.

## 0.1.0 (unreleased)

Initial release.
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,13 @@ groups are enabled.

## Tools

27 tools are enabled by default; 21 more are opt-in (see
33 tools are enabled by default; 21 more are opt-in (see
[Enabling more tools](#enabling-more-tools)). Names are stable — treat renames
as breaking.

| Group | Enabled by default | Opt-in |
|---|---|---|
| `a2a` | `inkbox_a2a_call`, `inkbox_a2a_check`, `inkbox_a2a_reply`, `inkbox_a2a_complete`, `inkbox_a2a_ask_caller`, `inkbox_a2a_fail` | — |
| `email` | `inkbox_send_email`, `inkbox_list_unread_emails`, `inkbox_list_emails`, `inkbox_get_email`, `inkbox_get_email_thread` | `inkbox_forward_email`, `inkbox_mark_emails_read` |
| `sms` | `inkbox_send_sms`, `inkbox_list_text_conversations`, `inkbox_get_text_conversation` | `inkbox_list_texts`, `inkbox_get_text`, `inkbox_mark_text_read`, `inkbox_mark_text_conversation_read` |
| `imessage` | `inkbox_send_imessage`, `inkbox_list_imessage_conversations`, `inkbox_get_imessage_conversation` | `inkbox_imessage_triage_number`, `inkbox_list_imessage_assignments`, `inkbox_send_imessage_reaction`, `inkbox_mark_imessage_conversation_read` |
Expand Down Expand Up @@ -154,10 +155,11 @@ export default async (input: any) => InkboxPlugin(input, {

## Outbound safety

Sends and calls are gated before anything leaves:
Sends, calls, and A2A writes are gated before anything leaves:

- **Approval prompts** (default): `inkbox_send_email`, `inkbox_send_sms`,
`inkbox_send_imessage`, `inkbox_forward_email`, and `inkbox_place_call`
`inkbox_send_imessage`, `inkbox_forward_email`, `inkbox_place_call`,
`inkbox_a2a_call`, and `inkbox_a2a_reply`
request approval through opencode's native permission system. Approve once,
or persist an allow rule from the prompt.
- **Recipient allowlist**: set `outbound.allowedRecipients` (exact email
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@inkbox/opencode-plugin",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"description": "Inkbox for opencode — give your agent an email address, a phone number (SMS/MMS + voice), iMessage, contacts, notes, and an encrypted credential vault.",
"license": "MIT",
Expand Down
20 changes: 20 additions & 0 deletions src/a2a-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface ActiveA2ATurn {
taskId: string;
messageId: string;
contextId: string;
replyIntentCommitted: boolean;
}

const turns = new Map<string, ActiveA2ATurn>();

export function setActiveA2ATurn(sessionID: string, turn: ActiveA2ATurn): void {
turns.set(sessionID, turn);
}

export function clearActiveA2ATurn(sessionID: string, turn: ActiveA2ATurn): void {
if (turns.get(sessionID) === turn) turns.delete(sessionID);
}

export function activeA2ATurn(sessionID: string): ActiveA2ATurn | undefined {
return turns.get(sessionID);
}
90 changes: 90 additions & 0 deletions src/a2a-delegations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { gatewayHome } from "./gateway/state.js";

export interface A2ADelegationRecord {
identityId: string;
origin: string;
cardUrl: string;
contextId?: string;
taskId?: string;
messageId: string;
sessionId?: string;
updatedAt: number;
}

type Records = Record<string, A2ADelegationRecord>;

function filePath(): string {
return path.join(gatewayHome(), "a2a-delegations.json");
}

function origin(url: string): string {
return new URL(url).origin.toLowerCase();
}

function read(): Records {
try {
const loaded = JSON.parse(fs.readFileSync(filePath(), "utf8"));
return loaded && typeof loaded === "object" ? loaded : {};
} catch (error: any) {
if (error?.code === "ENOENT") return {};
throw error;
}
}

function write(records: Records): void {
const target = filePath();
fs.mkdirSync(path.dirname(target), { recursive: true });
const tmp = `${target}.${process.pid}.tmp`;
fs.writeFileSync(tmp, `${JSON.stringify(records, null, 2)}\n`, {
mode: 0o600,
});
fs.renameSync(tmp, target);
fs.chmodSync(target, 0o600);
}

export function recordBeforeSend(input: {
identityId: string;
rpcUrl: string;
cardUrl: string;
contextId?: string;
taskId?: string;
messageId: string;
sessionId?: string;
}): string {
const resolvedOrigin = origin(input.rpcUrl);
const contextKey = input.contextId ?? `pending:${input.messageId}`;
const key = `${input.identityId}|${resolvedOrigin}|${contextKey}`;
const records = read();
records[key] = {
identityId: input.identityId,
origin: resolvedOrigin,
cardUrl: input.cardUrl,
contextId: input.contextId,
taskId: input.taskId,
messageId: input.messageId,
sessionId: input.sessionId,
updatedAt: Date.now(),
};
write(records);
return key;
}

export function promoteAfterSend(pendingKey: string, contextId: string, taskId: string): void {
const records = read();
const record = records[pendingKey];
if (!record) return;
delete records[pendingKey];
record.contextId = contextId;
record.taskId = taskId;
record.updatedAt = Date.now();
records[`${record.identityId}|${record.origin}|${contextId}`] = record;
write(records);
}

export function findDelegationByTask(taskId: string): A2ADelegationRecord | undefined {
return Object.values(read())
.filter((record) => record.taskId === taskId)
.sort((a, b) => b.updatedAt - a.updatedAt)[0];
}
Loading