Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <delivery-id> [--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.
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,15 @@ notch inbox init --name review-agent --root /shared/3notch-mailbox
# Pack and send from the source
notch packet pack <packet-id>
notch send <packet-id>.notchpkt --to local:review-agent
notch inbox status <delivery-id> --at local:review-agent

# Verify and import at the receiver
notch inbox list
notch inbox pull <delivery-id> --import
notch inbox ack <delivery-id>
```

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)

Expand Down Expand Up @@ -144,7 +145,7 @@ notch packet import <file-or-folder> import a packet into .notch/inbox/
notch packet preview <id> show what an agent will read
notch packet pack <id> produce a .notchpkt archive
notch packet unpack <archive> 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 <archive> --to <address> send a packed project handoff
notch packet list / show list / inspect packets
notch reply <id> typed reply to a packet
Expand All @@ -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.
Expand Down
13 changes: 12 additions & 1 deletion docs/guides/durable-inbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ notch send <packet-id>.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
Expand Down Expand Up @@ -70,6 +72,14 @@ notch inbox ack <delivery-id>

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 <delivery-id> --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:
Expand All @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion docs/guides/mcp-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):

Expand All @@ -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

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": "@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",
Expand Down
42 changes: 42 additions & 0 deletions src/cli/commands/inbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { printInfo, printJson } from '../output.js';
import { loadConfig } from '../../core/config-service.js';
import {
ackInboxDelivery,
getInboxDeliveryStatus,
initializeDurableInbox,
listInboxDeliveries,
pullInboxDelivery,
Expand All @@ -26,6 +27,10 @@ type InboxPullOptions = {
import?: boolean;
};

type InboxStatusOptions = {
at?: string;
};

type SendOptions = {
to?: string;
};
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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('<delivery-id>')
.option('--at <address>', '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')
Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,7 +481,7 @@ This project uses 3Notch for explicit, reviewable context handoffs between AI to
2. Use \`notch packet list\`, \`notch packet show <id>\`, and \`notch packet preview <id>\` 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 <path[:purpose]>\` 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 <path>\` only when the receiver shares the same filesystem path.
5. Use \`notch packet pack <id>\` and \`notch packet unpack <archive>\` 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 <id>\` and \`notch packet unpack <archive>\` 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
Expand All @@ -505,6 +505,7 @@ notch packet pack <packet-id>
notch packet unpack <packet-id>.notchpkt
notch inbox init --name review-agent --root /shared/3notch-mailbox
notch send <packet-id>.notchpkt --to local:review-agent
notch inbox status <delivery-id> --at local:review-agent
notch inbox pull <delivery-id> --import
notch inbox ack <delivery-id>
notch check
Expand Down
3 changes: 3 additions & 0 deletions src/cli/prompts/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const v1McpToolNames = [
'inbox_init',
'send_packet',
'list_inbox',
'get_inbox_delivery',
'pull_inbox_packet',
'ack_inbox_delivery',
'create_mark',
Expand Down Expand Up @@ -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.
Expand Down
55 changes: 53 additions & 2 deletions src/core/inbox-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -59,6 +59,7 @@ export type InboxListResult = {

export type InboxSendResult = InboxResult & {
idempotent: boolean;
notice: string;
};

export async function initializeDurableInbox(
Expand Down Expand Up @@ -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,
Expand All @@ -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, {
Expand Down Expand Up @@ -190,6 +194,22 @@ export async function listInboxDeliveries(
};
}

export async function getInboxDeliveryStatus(
context: LoadedConfig,
input: { deliveryId: string; address?: string },
): Promise<InboxDeliveryStatusResult> {
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 },
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions src/core/local-inbox-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export type PutLocalDeliveryResult = {
packetPath: string;
};

export type ReadLocalDeliveryResult = {
delivery: InboxDelivery;
packetPath: string;
};

export type LockedLocalDelivery = {
delivery: InboxDelivery;
packet: Buffer;
Expand All @@ -46,6 +51,7 @@ export type LockedLocalDelivery = {
export interface InboxTransportAdapter {
initializeRecipient: (address: string) => Promise<void>;
putDelivery: (address: string, input: PutLocalDeliveryInput) => Promise<PutLocalDeliveryResult>;
getDelivery: (address: string, deliveryId: string) => Promise<ReadLocalDeliveryResult>;
listDeliveries: (address: string, options?: { includeAll?: boolean }) => Promise<InboxDelivery[]>;
withDelivery: <T>(
address: string,
Expand Down Expand Up @@ -272,6 +278,17 @@ export class LocalInboxAdapter implements InboxTransportAdapter {
});
}

async getDelivery(address: string, deliveryId: string): Promise<ReadLocalDeliveryResult> {
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<InboxDelivery[]> {
const recipientPath = await this.assertRecipient(address);
const deliveriesPath = path.join(recipientPath, 'deliveries');
Expand Down
Loading
Loading