diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa4f21..cc15846 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.7.0] - 2026-08-08 + +### Added +- `notch send` and MCP `send_packet` now return a plain-text delivery notice that can be forwarded through agent/chat channels. +- `notch inbox status [--at local:name]` and read-only MCP `get_inbox_delivery` expose retained delivery state for sender-side acknowledgement checks. + ### Fixed - Receiver size-policy failures remain retryable after limits change, legacy size rejections can recover, and rejected deliveries no longer produce false-success resend results. - Durable inbox listing ignores unmanaged sync-folder clutter while continuing to reject malformed entries in the managed delivery-ID namespace. diff --git a/README.md b/README.md index 8fef3e3..a3ebef5 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ notch inbox init --name review-agent --root /shared/3notch-mailbox # Pack and send from the source notch packet pack notch send .notchpkt --to local:review-agent +notch inbox status --at local:review-agent # Verify and import at the receiver notch inbox list @@ -102,7 +103,7 @@ notch inbox pull --import notch inbox ack ``` -The local address is a routing label, not authenticated identity. Deliveries are hashed, validated, scanned, retained after ack, and traced locally. See [Durable inbox](docs/guides/durable-inbox.md). +`notch send` prints an agent-readable delivery notice that can move through Claude cross-session messaging, Slack, email, or any other text channel. The local address is a routing label, not authenticated identity. Deliveries are hashed, validated, scanned, retained after ack, and traced locally. See [Durable inbox](docs/guides/durable-inbox.md). ### Browser-only chats (fallback) @@ -144,7 +145,7 @@ notch packet import import a packet into .notch/inbox/ notch packet preview show what an agent will read notch packet pack produce a .notchpkt archive notch packet unpack import a .notchpkt archive -notch inbox init/list/pull/ack configure and process durable deliveries +notch inbox init/list/status/pull/ack configure and process durable deliveries notch send --to
send a packed project handoff notch packet list / show list / inspect packets notch reply typed reply to a packet @@ -163,7 +164,7 @@ notch mcp serve local stdio MCP server `notch mcp serve` exposes these tools over local stdio: -- Read-only: `get_brief`, `list_briefs`, `get_targeted_brief`, `get_packet`, `list_packets`, `list_inbox`, `get_status`, `check_store`, `run_doctor` +- Read-only: `get_brief`, `list_briefs`, `get_targeted_brief`, `get_packet`, `list_packets`, `list_inbox`, `get_inbox_delivery`, `get_status`, `check_store`, `run_doctor` - Write: `create_brief`, `create_packet`, `create_mark`, `create_reply`, `create_seed_packet`, `import_packet`, `import_seed_packet`, `inbox_init`, `send_packet`, `pull_inbox_packet`, `ack_inbox_delivery` Private records under `.notch/private/` are hidden unless the server starts with `--include-private`. See [docs/guides/mcp-setup.md](docs/guides/mcp-setup.md) for client-specific setup. diff --git a/docs/guides/durable-inbox.md b/docs/guides/durable-inbox.md index 6c05bdb..7340bbd 100644 --- a/docs/guides/durable-inbox.md +++ b/docs/guides/durable-inbox.md @@ -38,6 +38,8 @@ notch send .notchpkt --to local:review-agent Before a shared-mailbox write, 3Notch checks that the source is a regular `.notchpkt`, caps compressed and unpacked work, validates packet and artifact schemas, verifies artifact hashes, scans packet text and text-like artifacts, and blocks private or seed packets. The recipient must already be registered; a typo does not create a new mailbox. +`notch send` also prints a plain-text delivery notice. Forward that notice through the user's chosen channel, such as Claude cross-session messaging, Slack, email, or a GitHub comment. The notice is only text; the receiving agent still has to run 3Notch commands under its own permissions. + Retrying the same packet ID and exact bytes returns the existing active or completed delivery. Reusing a packet ID with different archive bytes fails. A terminally rejected delivery is never reported as a successful resend; correct the packet and create a new packet ID instead. ## Receive @@ -70,6 +72,14 @@ notch inbox ack Ack never deletes packet bytes. `notch inbox list` shows pending only; `notch inbox list --all` includes pulled, acknowledged, and rejected history. +The sender can inspect retained state when it can read the same mailbox root: + +```bash +notch inbox status --at local:review-agent +``` + +This reports whether the delivery is still pending, was pulled, was acknowledged, or was rejected. The `--at` address selects which registered local inbox to inspect; without it, status checks the current store's own address. + ## Reply Reply to the imported packet normally, then pack and send the reply to the original sender's address: @@ -93,12 +103,13 @@ The local stdio MCP server exposes the same workflow: - `inbox_init` - `send_packet` - `list_inbox` +- `get_inbox_delivery` - `pull_inbox_packet` - `ack_inbox_delivery` This is the neutral boundary for Claude, Codex, Grok, Cursor, ChatGPT, or any other MCP client that can run the 3Notch stdio server. Tools return small structured results with delivery ID, packet ID, byte hash, state, path, optional imported packet ID, and next action. -`send_packet` is annotated as an externally visible write. List is read-only. Pull and ack mutate state; ack is non-destructive. Client approval and `.notch/config.json` write-tool policy still apply. +`send_packet` is annotated as an externally visible write and returns the delivery notice. List and `get_inbox_delivery` are read-only. Pull and ack mutate state; ack is non-destructive. Client approval and `.notch/config.json` write-tool policy still apply. A future remote adapter can expose the same contracts over authenticated MCP Streamable HTTP. It does not need to add model-to-model calls, hosted identity, or vendor branches to this local core. diff --git a/docs/guides/mcp-setup.md b/docs/guides/mcp-setup.md index aea6277..b738201 100644 --- a/docs/guides/mcp-setup.md +++ b/docs/guides/mcp-setup.md @@ -45,6 +45,7 @@ Read-only: - `get_packet`, `list_packets` - `get_status`, `check_store`, `run_doctor` - `list_inbox` — list pending or retained durable deliveries +- `get_inbox_delivery` — read pending, pulled, acknowledged, or rejected status for one delivery Write tools (require client tool-permission grant): @@ -55,7 +56,7 @@ Write tools (require client tool-permission grant): - `create_seed_packet` - `import_packet`, `import_seed_packet` - `inbox_init` — register an explicit local mailbox root and address -- `send_packet` — send an already-packed project packet to a registered `local:` address +- `send_packet` — send an already-packed project packet to a registered `local:` address and return a delivery notice - `pull_inbox_packet` — verify and optionally import a delivery - `ack_inbox_delivery` — acknowledge without deleting retained bytes diff --git a/package-lock.json b/package-lock.json index a4f1668..5d40f1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@3notch/cli", - "version": "0.6.0", + "version": "0.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@3notch/cli", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/package.json b/package.json index 66a52b4..1fd877c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@3notch/cli", - "version": "0.6.0", + "version": "0.7.0", "description": "Save the working state your AI tools won't. Local checkpoints and portable packets across tools, repos, and machines.", "type": "module", "license": "MIT", diff --git a/src/cli/commands/inbox.ts b/src/cli/commands/inbox.ts index b9e97d3..0314759 100644 --- a/src/cli/commands/inbox.ts +++ b/src/cli/commands/inbox.ts @@ -5,6 +5,7 @@ import { printInfo, printJson } from '../output.js'; import { loadConfig } from '../../core/config-service.js'; import { ackInboxDelivery, + getInboxDeliveryStatus, initializeDurableInbox, listInboxDeliveries, pullInboxDelivery, @@ -26,6 +27,10 @@ type InboxPullOptions = { import?: boolean; }; +type InboxStatusOptions = { + at?: string; +}; + type SendOptions = { to?: string; }; @@ -58,6 +63,8 @@ export function registerInboxCommand(program: Command): void { printInfo(`To: ${options.to}`, context.output); printInfo(`SHA-256: ${result.packetHash}`, context.output); printInfo(`Next: ${result.nextAction}`, context.output); + printInfo('Notice:', context.output); + printInfo(result.notice, context.output); }); const inbox = program.command('inbox').description('configure and process durable packet deliveries'); @@ -124,6 +131,41 @@ export function registerInboxCommand(program: Command): void { printInfo(`Next: ${result.nextAction}`, context.output); }); + inbox + .command('status') + .description('read retained delivery state by ID') + .argument('') + .option('--at
', 'registered local recipient address to inspect') + .action(async (deliveryId: string, options: InboxStatusOptions, command: Command) => { + const context = getCliContext(command); + const loaded = await loadCurrentConfig(context); + const result = await getInboxDeliveryStatus(loaded, { + deliveryId, + ...(options.at ? { address: options.at } : {}), + }); + + if (context.output.json) { + printJson(result); + return; + } + + printInfo(`Delivery ${result.deliveryId} is ${result.state} at ${result.address}`, context.output); + printInfo(`From: ${result.delivery.from}`, context.output); + printInfo(`To: ${result.delivery.to}`, context.output); + printInfo(`Packet: ${result.packetId}`, context.output); + printInfo(`SHA-256: ${result.packetHash}`, context.output); + + if (result.importedPacketId) { + printInfo(`Imported: ${result.importedPacketId}`, context.output); + } + + if (result.delivery.errorCode) { + printInfo(`Error: ${result.delivery.errorCode}`, context.output); + } + + printInfo(`Next: ${result.nextAction}`, context.output); + }); + inbox .command('pull') .description('verify a delivery and optionally import it into this store') diff --git a/src/cli/commands/onboard.ts b/src/cli/commands/onboard.ts index 01ffca2..4f97eb8 100644 --- a/src/cli/commands/onboard.ts +++ b/src/cli/commands/onboard.ts @@ -481,7 +481,7 @@ This project uses 3Notch for explicit, reviewable context handoffs between AI to 2. Use \`notch packet list\`, \`notch packet show \`, and \`notch packet preview \` to inspect existing handoffs. 3. When the user asks you to hand context to another repo, person, or tool, create a packet with \`notch packet create\`. 4. Use \`--file \` when the receiver needs copied bytes. Copied files keep their project-relative path under \`artifacts/\`. If you are unsure, omit \`:purpose\`; the default is \`asset\`. Valid artifact purposes are \`asset\`, \`source\`, \`reference\`, and \`output\`; common labels like \`favicon\`, \`icon\`, \`logo\`, and \`image\` are accepted as \`asset\`. Use \`--ref \` only when the receiver shares the same filesystem path. -5. Use \`notch packet pack \` and \`notch packet unpack \` for manual cross-machine movement. Use \`notch inbox init\`, \`notch send\`, \`notch inbox pull --import\`, and \`notch inbox ack\` only when the user intentionally configures a shared local mailbox root. +5. Use \`notch packet pack \` and \`notch packet unpack \` for manual cross-machine movement. Use \`notch inbox init\`, \`notch send\`, \`notch inbox status\`, \`notch inbox pull --import\`, and \`notch inbox ack\` only when the user intentionally configures a shared local mailbox root. 6. Run \`notch check\` after imports or relationship-heavy changes. Run \`notch doctor\` when the store seems unhealthy. ## Continuation Checkpoints @@ -505,6 +505,7 @@ notch packet pack notch packet unpack .notchpkt notch inbox init --name review-agent --root /shared/3notch-mailbox notch send .notchpkt --to local:review-agent +notch inbox status --at local:review-agent notch inbox pull --import notch inbox ack notch check diff --git a/src/cli/prompts/shared.ts b/src/cli/prompts/shared.ts index 84c0304..6bcd567 100644 --- a/src/cli/prompts/shared.ts +++ b/src/cli/prompts/shared.ts @@ -21,6 +21,7 @@ export const v1McpToolNames = [ 'inbox_init', 'send_packet', 'list_inbox', + 'get_inbox_delivery', 'pull_inbox_packet', 'ack_inbox_delivery', 'create_mark', @@ -67,7 +68,9 @@ Use status checks when helpful: Use durable inbox for asynchronous handoff after both stores have intentionally configured the same local mailbox root: - Call send_packet only for a packed project handoff the user wants delivered to a registered local: address. Never use it for private or seed packets. +- Forward the returned delivery notice through the user's chosen agent or chat channel when another session needs to act on it. - Call list_inbox to discover pending deliveries. +- Call get_inbox_delivery when the sender or receiver needs pending, pulled, acknowledged, or rejected status for one delivery. - Call pull_inbox_packet without import to verify first, or with import=true to run normal 3Notch validation and import. - Call ack_inbox_delivery only after review, successful import, or an intentional skip. Acknowledgement retains packet bytes. - Treat local addresses as labels, not authenticated identities, and never claim the local adapter is encrypted or network-guaranteed. diff --git a/src/core/inbox-service.ts b/src/core/inbox-service.ts index bbb7455..d558b94 100644 --- a/src/core/inbox-service.ts +++ b/src/core/inbox-service.ts @@ -29,7 +29,7 @@ import { import { importPacketFolder } from './transfer-service.js'; import { errorToNotchError, NotchException } from '../types/errors.js'; import type { LoadedConfig } from './config-service.js'; -import type { InboxConfig, InboxDelivery, InboxResult } from '../types/inbox.js'; +import type { InboxConfig, InboxDelivery, InboxDeliveryStatusResult, InboxResult } from '../types/inbox.js'; import type { NotchPacket, SourceTool } from '../types/records.js'; const archiveOverheadBytes = 8 * 1024 * 1024; @@ -59,6 +59,7 @@ export type InboxListResult = { export type InboxSendResult = InboxResult & { idempotent: boolean; + notice: string; }; export async function initializeDurableInbox( @@ -149,6 +150,8 @@ export async function sendInboxPacket( ...(stored.idempotent ? { reason: 'idempotent-retry' } : {}), }); + const notice = deliveryNotice(stored.delivery); + return { ok: true, deliveryId: stored.delivery.deliveryId, @@ -157,7 +160,8 @@ export async function sendInboxPacket( state: stored.delivery.state, packetPath: stored.packetPath, idempotent: stored.idempotent, - nextAction: `Ask ${stored.delivery.to} to run notch inbox list, then pull and import ${stored.delivery.deliveryId}.`, + notice, + nextAction: `Send the delivery notice to ${stored.delivery.to}; status can be checked with notch inbox status ${stored.delivery.deliveryId} --at ${stored.delivery.to}.`, }; } catch (error) { await auditFailure(context, author, 'inbox-send', error, { @@ -190,6 +194,22 @@ export async function listInboxDeliveries( }; } +export async function getInboxDeliveryStatus( + context: LoadedConfig, + input: { deliveryId: string; address?: string }, +): Promise { + const config = await loadInboxConfig(context); + const address = input.address ?? config.address; + parseLocalInboxAddress(address); + const result = await new LocalInboxAdapter(config.root).getDelivery(address, input.deliveryId); + + return { + ...inboxResult(result.delivery, result.packetPath, deliveryStatusNextAction(result.delivery)), + address, + delivery: result.delivery, + }; +} + export async function pullInboxDelivery( context: LoadedConfig, input: { deliveryId: string; import?: boolean; asReviewed?: boolean }, @@ -607,6 +627,37 @@ function inboxResult(delivery: InboxDelivery, packetPath: string, nextAction: st }; } +function deliveryNotice(delivery: InboxDelivery): string { + return [ + '3Notch delivery notice', + `from: ${delivery.from}`, + `to: ${delivery.to}`, + `delivery_id: ${delivery.deliveryId}`, + `packet_id: ${delivery.packetId}`, + `sha256: ${delivery.packetHash}`, + `receiver_action: run \`notch inbox pull ${delivery.deliveryId} --import\`, review the packet, then run \`notch inbox ack ${delivery.deliveryId}\``, + `sender_status: run \`notch inbox status ${delivery.deliveryId} --at ${delivery.to}\``, + ].join('\n'); +} + +function deliveryStatusNextAction(delivery: InboxDelivery): string { + if (delivery.state === 'pending') { + return `${delivery.to} has not pulled this delivery yet. Receiver should run notch inbox pull ${delivery.deliveryId} --import, review it, then run notch inbox ack ${delivery.deliveryId}.`; + } + + if (delivery.state === 'pulled') { + return delivery.importedPacketId + ? `${delivery.to} imported ${delivery.importedPacketId}; wait for review or ask the receiver to acknowledge ${delivery.deliveryId}.` + : `${delivery.to} verified the archive but has not imported it; receiver should import or acknowledge the delivery intentionally.`; + } + + if (delivery.state === 'acked') { + return `${delivery.to} acknowledged the delivery. Packet bytes remain retained for audit.`; + } + + return `${delivery.to} rejected the delivery${delivery.errorCode ? ` with ${delivery.errorCode}` : ''}. Send a corrected packet with a new packet ID.`; +} + async function auditFailure( context: LoadedConfig, author: InboxActor, diff --git a/src/core/local-inbox-adapter.ts b/src/core/local-inbox-adapter.ts index 9f382db..66e9564 100644 --- a/src/core/local-inbox-adapter.ts +++ b/src/core/local-inbox-adapter.ts @@ -27,6 +27,11 @@ export type PutLocalDeliveryResult = { packetPath: string; }; +export type ReadLocalDeliveryResult = { + delivery: InboxDelivery; + packetPath: string; +}; + export type LockedLocalDelivery = { delivery: InboxDelivery; packet: Buffer; @@ -46,6 +51,7 @@ export type LockedLocalDelivery = { export interface InboxTransportAdapter { initializeRecipient: (address: string) => Promise; putDelivery: (address: string, input: PutLocalDeliveryInput) => Promise; + getDelivery: (address: string, deliveryId: string) => Promise; listDeliveries: (address: string, options?: { includeAll?: boolean }) => Promise; withDelivery: ( address: string, @@ -272,6 +278,17 @@ export class LocalInboxAdapter implements InboxTransportAdapter { }); } + async getDelivery(address: string, deliveryId: string): Promise { + this.assertDeliveryId(deliveryId); + const recipientPath = await this.assertRecipient(address); + const deliveryPath = path.join(recipientPath, 'deliveries', deliveryId); + + return { + delivery: await this.readDeliveryMetadata(deliveryPath), + packetPath: path.join(deliveryPath, 'packet.notchpkt'), + }; + } + async listDeliveries(address: string, options: { includeAll?: boolean } = {}): Promise { const recipientPath = await this.assertRecipient(address); const deliveriesPath = path.join(recipientPath, 'deliveries'); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ffb0d8d..e255e00 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,7 +10,7 @@ import { createTargetedBrief, getProjectBrief, getTargetedBrief, listTargetedBri import { checkStore } from '../core/check-service.js'; import { loadConfig } from '../core/config-service.js'; import { runDoctor } from '../core/doctor-service.js'; -import { ackInboxDelivery, initializeDurableInbox, listInboxDeliveries, pullInboxDelivery, sendInboxPacket } from '../core/inbox-service.js'; +import { ackInboxDelivery, getInboxDeliveryStatus, initializeDurableInbox, listInboxDeliveries, pullInboxDelivery, sendInboxPacket } from '../core/inbox-service.js'; import { createMark, createPacket, createReply, getPacket, listPackets } from '../core/packet-service.js'; import { createSeedPacket, importSeedPacket } from '../core/seed-service.js'; import { getStatus } from '../core/status-service.js'; @@ -76,6 +76,13 @@ const toolDefinitions: ToolDefinition[] = [ readOnly: true, outputSchema: inboxListOutputSchema(), }, + { + name: 'get_inbox_delivery', + title: 'Get inbox delivery', + description: 'Read retained delivery state by ID, optionally at another registered local: address in the same mailbox root, so the sender can see pulled, acknowledged, or rejected status.', + readOnly: true, + outputSchema: inboxStatusOutputSchema(), + }, { name: 'pull_inbox_packet', title: 'Pull inbox packet', @@ -103,7 +110,7 @@ export function createNotchMcpServer(options: NotchMcpServerOptions = {}): Serve { name: '3notch', version: VERSION }, { capabilities: { tools: {} }, - instructions: '3Notch exposes local, explicit, reviewable packets. For async handoff: create and pack a project packet, send_packet to a registered local: address, list_inbox at the receiver, pull_inbox_packet with import=true, review, then ack_inbox_delivery. Durable inbox labels are not authenticated identities; private/seed packets are blocked. 3Notch does not scrape chats, invoke other models, run shells, host a relay, or delete acknowledged packets.', + instructions: '3Notch exposes local, explicit, reviewable packets. For async handoff: create and pack a project packet, send_packet to a registered local: address, forward the returned delivery notice, list_inbox at the receiver, pull_inbox_packet with import=true, review, ack_inbox_delivery, and use get_inbox_delivery when the sender needs status. Durable inbox labels are not authenticated identities; private/seed packets are blocked. 3Notch does not scrape chats, invoke other models, run shells, host a relay, or delete acknowledged packets.', }, ); @@ -350,6 +357,13 @@ async function executeTool( case 'list_inbox': { return await listInboxDeliveries(context, { includeAll: Boolean(args.includeAll) }) as unknown as Record; } + case 'get_inbox_delivery': { + const address = stringArg(args.address); + return await getInboxDeliveryStatus(context, { + deliveryId: requiredString(args.deliveryId, 'deliveryId'), + ...(address ? { address } : {}), + }) as unknown as Record; + } case 'pull_inbox_packet': { if (Boolean(args.asReviewed) && !args.import) { throw new NotchException({ @@ -502,7 +516,7 @@ function inboxActionOutputSchema(includeIdempotent: boolean): Record { }; } +function inboxStatusOutputSchema(): Record { + return { + type: 'object', + additionalProperties: false, + required: ['ok', 'address', 'deliveryId', 'packetId', 'packetHash', 'state', 'packetPath', 'delivery', 'nextAction'], + properties: { + ok: { const: true }, + address: { type: 'string', pattern: '^local:' }, + deliveryId: { type: 'string', pattern: '^delivery_[a-f0-9]{24}$' }, + packetId: { type: 'string', minLength: 1 }, + packetHash: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + state: { enum: ['pending', 'pulled', 'acked', 'rejected'] }, + packetPath: { type: 'string', minLength: 1 }, + importedPacketId: { type: 'string', minLength: 1 }, + delivery: inboxDeliveryOutputSchema(), + nextAction: { type: 'string', minLength: 1 }, + }, + }; +} + function inboxListOutputSchema(): Record { return { type: 'object', @@ -545,32 +579,36 @@ function inboxListOutputSchema(): Record { address: { type: 'string', pattern: '^local:' }, deliveries: { type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['schemaVersion', 'deliveryId', 'transport', 'from', 'to', 'packetId', 'packetHash', 'bytes', 'state', 'createdAt', 'updatedAt'], - properties: { - schemaVersion: { const: '1.0.0' }, - deliveryId: { type: 'string' }, - transport: { const: 'local' }, - from: { type: 'string' }, - to: { type: 'string' }, - packetId: { type: 'string' }, - packetHash: { type: 'string' }, - bytes: { type: 'integer' }, - state: { enum: ['pending', 'pulled', 'acked', 'rejected'] }, - createdAt: { type: 'string' }, - updatedAt: { type: 'string' }, - pulledAt: { type: 'string' }, - ackedAt: { type: 'string' }, - rejectedAt: { type: 'string' }, - importedAt: { type: 'string' }, - importedPacketId: { type: 'string' }, - errorCode: { type: 'string' }, - }, - }, + items: inboxDeliveryOutputSchema(), }, nextAction: { type: 'string', minLength: 1 }, }, }; } + +function inboxDeliveryOutputSchema(): Record { + return { + type: 'object', + additionalProperties: false, + required: ['schemaVersion', 'deliveryId', 'transport', 'from', 'to', 'packetId', 'packetHash', 'bytes', 'state', 'createdAt', 'updatedAt'], + properties: { + schemaVersion: { const: '1.0.0' }, + deliveryId: { type: 'string' }, + transport: { const: 'local' }, + from: { type: 'string' }, + to: { type: 'string' }, + packetId: { type: 'string' }, + packetHash: { type: 'string' }, + bytes: { type: 'integer' }, + state: { enum: ['pending', 'pulled', 'acked', 'rejected'] }, + createdAt: { type: 'string' }, + updatedAt: { type: 'string' }, + pulledAt: { type: 'string' }, + ackedAt: { type: 'string' }, + rejectedAt: { type: 'string' }, + importedAt: { type: 'string' }, + importedPacketId: { type: 'string' }, + errorCode: { type: 'string' }, + }, + }; +} diff --git a/src/schemas/mcp-tools.schema.json b/src/schemas/mcp-tools.schema.json index a050a6f..a880f84 100644 --- a/src/schemas/mcp-tools.schema.json +++ b/src/schemas/mcp-tools.schema.json @@ -234,6 +234,15 @@ "includeAll": { "type": "boolean" } } }, + "get_inbox_delivery": { + "type": "object", + "additionalProperties": false, + "required": ["deliveryId"], + "properties": { + "deliveryId": { "type": "string", "pattern": "^delivery_[a-f0-9]{24}$" }, + "address": { "type": "string", "pattern": "^local:[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$" } + } + }, "pull_inbox_packet": { "type": "object", "additionalProperties": false, diff --git a/src/types/inbox.ts b/src/types/inbox.ts index d5d2af5..c9c2dd7 100644 --- a/src/types/inbox.ts +++ b/src/types/inbox.ts @@ -43,3 +43,8 @@ export type InboxResult = { importedPacketId?: string; nextAction: string; }; + +export type InboxDeliveryStatusResult = InboxResult & { + address: string; + delivery: InboxDelivery; +}; diff --git a/tests/cli/inbox.test.ts b/tests/cli/inbox.test.ts index e23d9c0..ca8d5c5 100644 --- a/tests/cli/inbox.test.ts +++ b/tests/cli/inbox.test.ts @@ -36,6 +36,7 @@ describe('notch durable inbox CLI', () => { ], { cwd: sender.path }); const sent = JSON.parse(send.stdout) as { deliveryId: string; + notice: string; packetHash: string; packetId: string; }; @@ -45,6 +46,9 @@ describe('notch durable inbox CLI', () => { packetId: packed.packetId, packetHash: expect.stringMatching(/^[a-f0-9]{64}$/u), }); + expect(sent.notice).toContain('3Notch delivery notice'); + expect(sent.notice).toContain(`notch inbox pull ${sent.deliveryId} --import`); + expect(sent.notice).toContain(`notch inbox status ${sent.deliveryId} --at local:receiver-agent`); await writeFile( path.join( @@ -77,6 +81,19 @@ describe('notch durable inbox CLI', () => { expect(ack.exitCode).toBe(0); expect(ack.stdout).toContain(`Acknowledged delivery ${sent.deliveryId}`); expect(ack.stdout).toContain('Packet bytes retained for audit.'); + const senderStatus = await runCli(['--json', 'inbox', 'status', sent.deliveryId, '--at', 'local:receiver-agent'], { + cwd: sender.path, + }); + expect(JSON.parse(senderStatus.stdout)).toMatchObject({ + address: 'local:receiver-agent', + deliveryId: sent.deliveryId, + delivery: expect.objectContaining({ state: 'acked', importedPacketId: packed.packetId }), + state: 'acked', + }); + const humanStatus = await runCli(['inbox', 'status', sent.deliveryId, '--at', 'local:receiver-agent'], { + cwd: sender.path, + }); + expect(humanStatus.stdout).toContain(`Delivery ${sent.deliveryId} is acked at local:receiver-agent`); expect((await runCli(['--json', 'inbox', 'list'], { cwd: receiver.path })).stdout).toContain('"deliveries": []'); expect(JSON.parse((await runCli(['--json', 'inbox', 'list', '--all'], { cwd: receiver.path })).stdout)).toMatchObject({ deliveries: [expect.objectContaining({ state: 'acked', importedPacketId: packed.packetId })], @@ -135,6 +152,7 @@ describe('notch durable inbox CLI', () => { expect(rootHelp.stdout).toContain('inbox'); expect(inboxHelp.stdout).toContain('init'); expect(inboxHelp.stdout).toContain('list'); + expect(inboxHelp.stdout).toContain('status'); expect(inboxHelp.stdout).toContain('pull'); expect(inboxHelp.stdout).toContain('ack'); expect(inboxHelp.stdout).not.toContain('delete'); diff --git a/tests/cli/prompt.test.ts b/tests/cli/prompt.test.ts index 5ee38af..682766a 100644 --- a/tests/cli/prompt.test.ts +++ b/tests/cli/prompt.test.ts @@ -17,6 +17,7 @@ const v2Tools = [ 'inbox_init', 'send_packet', 'list_inbox', + 'get_inbox_delivery', 'pull_inbox_packet', 'ack_inbox_delivery', 'create_mark', diff --git a/tests/mcp/inbox-tools.test.ts b/tests/mcp/inbox-tools.test.ts index 3fb241b..94d9652 100644 --- a/tests/mcp/inbox-tools.test.ts +++ b/tests/mcp/inbox-tools.test.ts @@ -38,15 +38,17 @@ describe('durable inbox MCP tools', () => { const send = await senderHarness.callTool('send_packet', { packetPath: packed.archivePath, to: 'local:receiver-model', - }) as { structuredContent: { deliveryId: string; packetHash: string } }; + }) as { structuredContent: { deliveryId: string; notice: string; packetHash: string } }; expect(send).toMatchObject({ structuredContent: { deliveryId: expect.stringMatching(/^delivery_[a-f0-9]{24}$/u), packetHash: expect.stringMatching(/^[a-f0-9]{64}$/u), state: 'pending', idempotent: false, + notice: expect.stringContaining('3Notch delivery notice'), }, }); + expect(send.structuredContent.notice).toContain(`notch inbox pull ${send.structuredContent.deliveryId} --import`); await expect(receiverHarness.callTool('list_inbox')).resolves.toMatchObject({ structuredContent: { @@ -72,6 +74,18 @@ describe('durable inbox MCP tools', () => { state: 'acked', }, }); + await expect(senderHarness.callTool('get_inbox_delivery', { + deliveryId: send.structuredContent.deliveryId, + address: 'local:receiver-model', + })).resolves.toMatchObject({ + structuredContent: { + address: 'local:receiver-model', + deliveryId: send.structuredContent.deliveryId, + delivery: expect.objectContaining({ state: 'acked', importedPacketId: packed.packetId }), + nextAction: expect.stringContaining('acknowledged'), + state: 'acked', + }, + }); await expect(receiverHarness.callTool('list_inbox')).resolves.toMatchObject({ structuredContent: { deliveries: [] }, }); @@ -112,6 +126,15 @@ describe('durable inbox MCP tools', () => { }, outputSchema: expect.objectContaining({ type: 'object' }), }); + expect(tools.get('get_inbox_delivery')).toMatchObject({ + title: 'Get inbox delivery', + annotations: { + readOnlyHint: true, + destructiveHint: false, + openWorldHint: false, + }, + outputSchema: expect.objectContaining({ type: 'object' }), + }); expect(tools.get('ack_inbox_delivery')?.description).toContain('never deletes'); } finally { await harness.close(); diff --git a/tests/mcp/server-start.test.ts b/tests/mcp/server-start.test.ts index d513c64..4344735 100644 --- a/tests/mcp/server-start.test.ts +++ b/tests/mcp/server-start.test.ts @@ -32,6 +32,7 @@ describe('MCP server foundation', () => { 'create_reply', 'create_seed_packet', 'get_brief', + 'get_inbox_delivery', 'get_packet', 'get_status', 'get_targeted_brief', diff --git a/tests/schema/mcp-tool-schemas.test.ts b/tests/schema/mcp-tool-schemas.test.ts index 8006637..3dfc727 100644 --- a/tests/schema/mcp-tool-schemas.test.ts +++ b/tests/schema/mcp-tool-schemas.test.ts @@ -21,6 +21,7 @@ const expectedTools = [ 'inbox_init', 'send_packet', 'list_inbox', + 'get_inbox_delivery', 'pull_inbox_packet', 'ack_inbox_delivery', 'get_status', @@ -63,10 +64,13 @@ describe('MCP tool input schemas', () => { const ajv = new Ajv2020({ strict: true }); ajv.addSchema(sharedSchema); const validateSend = ajv.compile(getMcpToolInputSchema('send_packet')); + const validateStatus = ajv.compile(getMcpToolInputSchema('get_inbox_delivery')); const validatePull = ajv.compile(getMcpToolInputSchema('pull_inbox_packet')); expect(validateSend({ packetPath: '/tmp/packet.notchpkt', to: 'local:review-agent' })).toBe(true); expect(validateSend({ packetPath: '/tmp/packet.notchpkt', to: 'local:../review-agent' })).toBe(false); + expect(validateStatus({ deliveryId: 'delivery_aaaaaaaaaaaaaaaaaaaaaaaa', address: 'local:review-agent' })).toBe(true); + expect(validateStatus({ deliveryId: 'delivery_aaaaaaaaaaaaaaaaaaaaaaaa', address: 'local:../review-agent' })).toBe(false); expect(validatePull({ deliveryId: 'delivery_aaaaaaaaaaaaaaaaaaaaaaaa', import: true })).toBe(true); expect(validatePull({ deliveryId: '../../delivery', import: true })).toBe(false); }); diff --git a/tests/unit/inbox-service.test.ts b/tests/unit/inbox-service.test.ts index b99b9d6..1503303 100644 --- a/tests/unit/inbox-service.test.ts +++ b/tests/unit/inbox-service.test.ts @@ -8,6 +8,7 @@ import { readAuditLog } from '../../src/core/audit-service.js'; import { loadConfig } from '../../src/core/config-service.js'; import { ackInboxDelivery, + getInboxDeliveryStatus, initializeDurableInbox, listInboxDeliveries, pullInboxDelivery, @@ -41,22 +42,56 @@ describe('durable inbox service', () => { }); expect(sent.idempotent).toBe(false); + expect(sent.notice).toContain(`notch inbox pull ${sent.deliveryId} --import`); expect(retry).toMatchObject({ deliveryId: sent.deliveryId, idempotent: true }); expect(await listInboxDeliveries(receiverContext)).toMatchObject({ deliveries: [expect.objectContaining({ deliveryId: sent.deliveryId, state: 'pending' })], }); + expect(await getInboxDeliveryStatus(senderContext, { + deliveryId: sent.deliveryId, + address: 'local:receiver', + })).toMatchObject({ + state: 'pending', + nextAction: expect.stringContaining('has not pulled this delivery yet'), + }); const verified = await pullInboxDelivery(receiverContext, { deliveryId: sent.deliveryId }); + expect(verified).toMatchObject({ state: 'pulled', packetId: packed.packetId }); + expect(await getInboxDeliveryStatus(senderContext, { + deliveryId: sent.deliveryId, + address: 'local:receiver', + })).toMatchObject({ + state: 'pulled', + nextAction: expect.stringContaining('verified the archive but has not imported it'), + }); + const imported = await pullInboxDelivery(receiverContext, { deliveryId: sent.deliveryId, import: true }); const importRetry = await pullInboxDelivery(receiverContext, { deliveryId: sent.deliveryId, import: true }); - const acked = await ackInboxDelivery(receiverContext, { deliveryId: sent.deliveryId }); - const ackRetry = await ackInboxDelivery(receiverContext, { deliveryId: sent.deliveryId }); - - expect(verified).toMatchObject({ state: 'pulled', packetId: packed.packetId }); expect(imported).toMatchObject({ state: 'pulled', importedPacketId: packed.packetId }); expect(importRetry).toMatchObject({ importedPacketId: packed.packetId }); + expect(await getInboxDeliveryStatus(senderContext, { + deliveryId: sent.deliveryId, + address: 'local:receiver', + })).toMatchObject({ + state: 'pulled', + delivery: expect.objectContaining({ importedPacketId: packed.packetId }), + nextAction: expect.stringContaining(`imported ${packed.packetId}`), + }); + + const acked = await ackInboxDelivery(receiverContext, { deliveryId: sent.deliveryId }); + const ackRetry = await ackInboxDelivery(receiverContext, { deliveryId: sent.deliveryId }); expect(acked.state).toBe('acked'); expect(ackRetry.state).toBe('acked'); + expect(await getInboxDeliveryStatus(senderContext, { + deliveryId: sent.deliveryId, + address: 'local:receiver', + })).toMatchObject({ + address: 'local:receiver', + deliveryId: sent.deliveryId, + delivery: expect.objectContaining({ state: 'acked', importedPacketId: packed.packetId }), + nextAction: expect.stringContaining('acknowledged'), + state: 'acked', + }); expect(await readFile(acked.packetPath)).toEqual(packed.archive); expect((await listInboxDeliveries(receiverContext)).deliveries).toEqual([]); expect(await listInboxDeliveries(receiverContext, { includeAll: true })).toMatchObject({ @@ -319,6 +354,14 @@ describe('durable inbox service', () => { expect(await listInboxDeliveries(receiverContext, { includeAll: true })).toMatchObject({ deliveries: [expect.objectContaining({ state: 'rejected', errorCode: 'NOTCH_INBOX_HASH_MISMATCH' })], }); + expect(await getInboxDeliveryStatus(senderContext, { + deliveryId: sent.deliveryId, + address: 'local:receiver', + })).toMatchObject({ + state: 'rejected', + delivery: expect.objectContaining({ errorCode: 'NOTCH_INBOX_HASH_MISMATCH' }), + nextAction: expect.stringContaining('NOTCH_INBOX_HASH_MISMATCH'), + }); expect(await readAuditLog(receiverContext.paths.logs)).toEqual(expect.arrayContaining([ expect.objectContaining({ operation: 'inbox-reject',