Skip to content
Closed
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
102 changes: 102 additions & 0 deletions docs/vrs/01-ding/01-claude/packaging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Claude channel packaging boundary

This page defines the optional work needed to make `st2 claude-mcp` a managed
Claude Code channel plugin. It is a decision aid, not build authority.

## Decision

Do not package the plugin yet. The maintained private-fleet path owns the st2
binary and its startup PTY. It can accept the provider's exact direct-server
warning once per Claude process. This is bounded startup control, not screen
classification for each message.

The plugin is worth building when a deployment requires zero-interaction
startup, a centrally managed allowlist, or distribution of the channel metadata
independently from st2. Until then it adds a marketplace, installation, and
organization-policy surface without changing the MCP transport or durable inbox
contract.

## Plugin shape

The plugin source would live at `integrations/claude-channel/` in this
repository. Its root would contain two files:

```text
integrations/claude-channel/
├── .claude-plugin/plugin.json
└── .mcp.json
```

`.claude-plugin/plugin.json` would declare the stable plugin name and one
`channels` entry. The entry's `server` value would name the MCP server in
`.mcp.json`. `.mcp.json` would declare one standard-input MCP server whose
command is `st2` and whose arguments are `claude-mcp`. The installed st2 binary
would remain the implementation. The plugin would contain only provider
metadata and launch configuration.

Claude Code requires the channel server name in the manifest to match the MCP
server key. The plugin must pass `claude plugin validate --strict` before
distribution. See the provider's
[plugin reference](https://code.claude.com/docs/en/plugins-reference#channels).

## Private-fleet distribution

A private marketplace is a versioned catalog that points Claude Code at the
plugin source. Fleet setup would register that marketplace, install the plugin
at user or managed scope, and start each Claude agent with this selector:

```text
--channels plugin:st2-channel@<private-marketplace>
```

The selector is required for every session. Installation alone does not enable
the channel. The plugin manifest selects the MCP server named by its `channels`
entry; the command-line selector names the plugin and marketplace.

## Organization policy

An eligible organization can approve its private plugin without Anthropic
marketplace review. An Owner must enable Claude Code channels and set managed
policy like this:

```json
{
"channelsEnabled": true,
"allowedChannelPlugins": [
{ "marketplace": "<private-marketplace>", "plugin": "st2-channel" }
]
}
```

`allowedChannelPlugins` replaces the provider's default allowlist. The policy
must therefore include every channel plugin that the organization still wants
to permit. Users cannot override it.

This policy is needed only when the deployment uses managed settings. A host
with no managed policy can accept a locally declared server through the
development selector. A host whose managed policy disables channels rejects the
development selector too.

The provider documents these rules under
[Enterprise controls](https://code.claude.com/docs/en/channels#enterprise-controls)
and
[Research preview](https://code.claude.com/docs/en/channels#research-preview).

## Direct-server acceptance boundary

The current maintained path starts the locally built and pinned st2 MCP server
with this selector:

```text
--dangerously-load-development-channels server:<server-name>
```

Claude asks for one confirmation on every process start. The launcher may send
Return only after it recognizes the exact warning and exact expected selector.
It must not answer a workspace trust question or another startup prompt on that
basis. After this startup gate, native DING uses only MCP notifications and no
terminal input.

This path is not permission to run arbitrary downloaded channel servers. If the
server is no longer a locally owned st2 artifact, or if the deployment requires
no startup confirmation, build and validate the plugin path above.
178 changes: 174 additions & 4 deletions docs/vrs/01-ding/01-claude/spec.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,182 @@
# Claude harness specification

The screen grammar by which DING recognizes a Claude composer. It realizes
[`../requirements.md`](../requirements.md) through the mechanism in
[`../spec.md`](../spec.md).
This document defines native MCP delivery and the legacy Claude screen
grammar. It realizes [`../requirements.md`](../requirements.md) through the
selection and durability rules in [`../spec.md`](../spec.md).

## Status

Active.
Active. Live Claude Code 2.1.226 and 2.1.227 observations prove idle wake,
active-turn delivery, and queued delivery through a directly declared MCP
server. The maintained private-fleet path uses one bounded provider confirmation
when the Claude process starts. It does not classify the screen for each DING.

## Native MCP transport

`deliver "mcp"` selects this transport. st2 starts one `st2 claude-mcp`
process for the declared agent. The process serves MCP over standard input and
standard output. It does not use the PTY, inspect the screen, or start the
legacy `ding` sidecar.

### Provider enablement

The launched Claude session must enable the MCP server as a channel source. An
MCP connection alone is not sufficient. Claude can connect to a server, list
its tools, and still discard every channel notification when the server is not
enabled as a channel.

The server advertises the `experimental.claude/channel` capability. During the
provider's research preview, the maintained direct-server launch uses this
selector:

```text
--dangerously-load-development-channels server:<server-name>
```

`--channels server:<server-name>` is not sufficient for a directly declared
server. Claude can connect that server and then reject its notifications because
the server is not on the approved channel allowlist.

The development selector presents one confirmation when each Claude process
starts. st2 owns the startup PTY. It may confirm only the exact development
channel warning that names the expected server selector. An unrelated prompt,
a different selector, or an untrusted workspace blocks startup and receives no
input. This bounded startup action is not the legacy per-message DING screen
classifier.

If an organization deploys managed settings, that policy must permit channels
with `channelsEnabled: true`. A deployment with no managed policy does not need
to create a managed settings file: live Claude Code 2.1.226 accepted the direct
development selector with no such file. A managed plugin allowlist can remove
the startup confirmation; [`packaging.md`](./packaging.md) defines that optional
path.

The MCP wire does not return a channel-acceptance receipt to the server, so the
adapter must not claim that it detects provider acceptance at runtime. The
provider evidence below proves that the maintained launch configuration accepts
the channel.

The direct-server selector is permitted only for the locally built and pinned
st2 server. It must not approve an arbitrary downloaded server. The bounded
plugin and policy alternative is in [`packaging.md`](./packaging.md).

### Notification

The adapter sends one MCP notification with this method:

```text
notifications/claude/channel
```

The notification parameters have this shape:

```json
{
"content": "Subject: <subject>\n\n<body>",
"meta": {
"from": "<sender bus ID>",
"messageFilename": "<message filename>",
"threadFilename": "<thread-root filename>",
"identity": "<recipient bus ID>"
}
}
```

`content` is the message body. A present subject adds the shown `Subject:`
line and one blank line. A message without a subject uses the body alone.
`threadFilename` is the valid `in-reply-to` value when one exists. Otherwise,
it is `messageFilename`. The adapter does not invent sender, thread, or
recipient values from display text.

Claude presents an accepted notification as an inbound channel item. When the
session is idle, the item starts a turn without terminal input from st2. If a
turn is active, Claude queues the item. More than one waiting item can be
coalesced into the next turn. The adapter must not assume one provider turn for
each notification.

Claude treats channel content as untrusted input. The model may report or refuse
an instruction in that content. Native DING proves that the item was presented
and woke the session; it does not prove that the model obeyed the content or
produced a particular reply. The adapter does not parse the rendered inbound
line and does not wait for a model response.

### Durable inbox and retry

The selected catalog inbox remains authoritative. The adapter does not archive
or delete a message after notification. The agent reads and archives the
message through the normal message commands.

Before each notification attempt, the adapter runs the normal message sweep.
An archive record with the same filename wins. The adapter sends nothing for a
message that the sweep removes from the inbox.

The notification attempt succeeds only when the MCP transport is open and the
notification write completes. A successful write completes this DING attempt;
it does not acknowledge, read, or archive the message. A write failure keeps
the message eligible for retry. A closed transport is a normal adapter stop.
It sends nothing further and leaves every remaining message unread.

The adapter scans the existing inbox when it starts. It then uses a file
watcher as a low-latency signal and a poll as the correctness backstop. The
poll interval is at most 15 seconds. Watcher and poll observations share one
successful-delivery set, so they do not send the same file twice in one
adapter incarnation. A failed notification does not enter that set.

The poll path must work when the watcher is disabled. This is a required test
mode and a production recovery property. The watcher may fail or miss an
event without stopping delivery.

One malformed or unreadable inbox file must not stop the scan, watcher, poll,
or later valid messages. The adapter reports the file failure, leaves that
file unread, and continues with the other files.

### Shutdown and recovery

Transport close stops the adapter without an error. st2 may start a new
adapter while the agent still declares this transport. A new adapter scans the
inbox again. It may repeat a notification that a prior adapter wrote before it
lost its process-local delivery set. This at-least-once behavior is safe
because the inbox file is still the source of truth and message actions are
idempotent.

### Provider evidence

A live Claude Code observation must prove all of these results before the
native adapter is accepted:

- Claude reports the MCP server as connected and the channel as accepted.
- A message sent to an idle agent appears as an inbound channel item.
- The inbound item starts observable work without a user keystroke.
- A message sent during active work does not corrupt user input or terminal
state.
- Several messages sent during active work remain queued without loss. The
provider may coalesce them into one later turn.
- The inbox file remains until the agent reads and archives it.
- A deliberately broken notification or disabled transport makes the test
fail.

The repository-owned probe in [`tools/channel-probe/`](../../../../tools/channel-probe/)
registers no tools and sends only channel notifications. The 2026-08-11 live
matrix produced these results:

- Claude Code 2.1.226, with no managed policy file, accepted the direct
development selector. One idle notification started a turn with no user input
after the bounded startup confirmation.
- Claude Code 2.1.227 started turn 1 for notification A. Notifications B and C
arrived 0.1 seconds apart while turn 1 was running. After turn 1 ended, Claude
started turn 2 and presented both waiting items together. All three distinct
identifiers were present.
- The model refused the probe's embedded output instruction because channel
content is untrusted. The refusal does not make delivery fail: the provider
debug events and spontaneous turns prove presentation and wake.
- Claude Code 2.1.226 connected the same class of directly declared server but
rejected channel notifications when the required development selector was
omitted. A connected MCP transport alone is therefore not acceptance.

## Legacy screen transport

The remaining sections define the unchanged screen grammar selected by
`ding`.

## Locating the composer

Expand Down
30 changes: 30 additions & 0 deletions tools/channel-probe/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Claude channel probe

This probe registers no MCP tools. It advertises the Claude channel capability
and sends one or more `notifications/claude/channel` notifications.

Run the idle-wake cell with:

```sh
./run-probe.sh
```

The script creates an isolated temporary workspace. It sends Return only for
the new-workspace trust prompt and the development-channel warning. It reports
success only when the provider debug log records a channel notification and a
later engine turn. The model does not need to obey the notification content.

Run the active-turn queue cell with three simple identifiers:

```sh
PROBE_TOKENS=QUEUE-A,QUEUE-B,QUEUE-C PROBE_INTERVAL=100 ./run-probe.sh
```

Notification A must start turn 1. Notifications B and C must arrive while turn
1 is active. Both must then produce a completed turn 2. The provider may
coalesce the two waiting notifications into that turn. `PROBE_DELAY` sets the
first delay in milliseconds. The idle default remains one token after 20
seconds.

These files are evidence tools. They are not the production `st2 claude-mcp`
adapter.
57 changes: 57 additions & 0 deletions tools/channel-probe/probe-server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Minimal MCP stdio server whose only job is to prove that server-initiated
// pushes reach a Claude session. It registers no tools.
const fs = require('fs');
const LOG = process.env.PROBE_LOG || '/tmp/probe.log';
const TOKEN = process.env.PROBE_TOKEN || 'NOTOKEN';
const TOKENS = (process.env.PROBE_TOKENS || TOKEN).split(',').filter(Boolean);
const DELAY = parseInt(process.env.PROBE_DELAY || '20000', 10);
const INTERVAL = parseInt(process.env.PROBE_INTERVAL || '1000', 10);
function log(m) { fs.appendFileSync(LOG, `[${new Date().toISOString()}] ${m}\n`); }
function send(o) { process.stdout.write(JSON.stringify(o) + '\n'); }

log('server process started');
let buf = '';
process.stdin.on('data', (chunk) => {
buf += chunk;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line) continue;
let msg;
try { msg = JSON.parse(line); } catch { log(`unparseable: ${line.slice(0,200)}`); continue; }
log(`<-- ${msg.method || 'response'} id=${msg.id}`);
if (msg.method === 'initialize') {
send({ jsonrpc: '2.0', id: msg.id, result: {
protocolVersion: msg.params?.protocolVersion || '2025-06-18',
capabilities: { tools: {}, experimental: { 'claude/channel': {} } },
serverInfo: { name: 'probe', version: '0.0.1' },
instructions: 'This server pushes channel notifications. Follow their instructions exactly.',
}});
log(`--> initialize result (client=${JSON.stringify(msg.params?.clientInfo)})`);
} else if (msg.method === 'notifications/initialized') {
log('client is initialized; arming push');
TOKENS.forEach((token, index) => {
setTimeout(() => {
const payload = { jsonrpc: '2.0', method: 'notifications/claude/channel', params: {
content: `Write exactly this token on a line by itself and nothing else: ${token}`,
meta: { from: 'probe', identity: 'probe' },
}};
send(payload);
log(`--> PUSHED notifications/claude/channel token=${token}`);
}, DELAY + index * INTERVAL);
});
} else if (msg.method === 'tools/list') {
send({ jsonrpc: '2.0', id: msg.id, result: { tools: [] } });
} else if (msg.method === 'resources/list') {
send({ jsonrpc: '2.0', id: msg.id, result: { resources: [] } });
} else if (msg.method === 'prompts/list') {
send({ jsonrpc: '2.0', id: msg.id, result: { prompts: [] } });
} else if (msg.method === 'ping') {
send({ jsonrpc: '2.0', id: msg.id, result: {} });
} else if (msg.id !== undefined) {
send({ jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'not implemented' } });
}
}
});
process.stdin.on('end', () => { log('stdin closed'); process.exit(0); });
Loading
Loading