@matuto-ai/claude-code-broker-connector 0.4.0 → 0.6.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.
@@ -8,7 +8,7 @@
8
8
  "name": "psst",
9
9
  "source": "./",
10
10
  "description": "Psst messaging for Claude Code",
11
- "version": "0.4.0"
11
+ "version": "0.6.0"
12
12
  }
13
13
  ],
14
14
  "metadata": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "psst",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Session-bound Psst agent messaging with explicit receive and acknowledgment. Bounded opt-in native notices; no permission relay.",
5
5
  "author": {
6
6
  "name": "Bruno Orsolon"
@@ -11,17 +11,9 @@
11
11
  "broker_url": {
12
12
  "type": "string",
13
13
  "title": "Psst broker URL",
14
- "description": "HTTPS broker origin. This is not a credential; a previously authorized installation is still required. Leave blank to use the selected installation or PSST_BROKER_URL.",
14
+ "description": "HTTPS broker origin. Explicit setup requests administrator approval once; ordinary startup only reconnects a saved approved installation.",
15
15
  "required": false,
16
16
  "default": ""
17
- },
18
- "pairing_secret": {
19
- "type": "string",
20
- "title": "Psst one-use pairing secret",
21
- "description": "Enter only in this native private configuration field, never in chat. Used solely by explicit setup, not startup. Remove through native UI after successful setup if supported.",
22
- "required": false,
23
- "sensitive": true,
24
- "default": ""
25
17
  }
26
18
  }
27
19
  }
package/.mcp.json CHANGED
@@ -6,8 +6,7 @@
6
6
  "${CLAUDE_PLUGIN_ROOT}/bin/plugin-mcp.mjs"
7
7
  ],
8
8
  "env": {
9
- "PSST_PLUGIN_BROKER_URL": "${user_config.broker_url}",
10
- "PSST_PLUGIN_PAIRING_SECRET": "${user_config.pairing_secret}"
9
+ "PSST_PLUGIN_BROKER_URL": "${user_config.broker_url}"
11
10
  }
12
11
  }
13
12
  }
package/README.md CHANGED
@@ -1,49 +1,58 @@
1
1
  # Psst for Claude Code
2
2
 
3
+ > **Unreleased 0.6.0 candidate.** The new commands/behavior below are not in the existing npm release. Do not run the versioned registry install until publication is verified. For review, use this checkout and `npm pack --ignore-scripts`; no release is created by this PR.
4
+
3
5
  Native Claude Code plugin for discovery, send, receive, explicit read acknowledgment,
4
6
  and delivery status through [Psst](https://github.com/matuto-ai/psst).
5
7
  Install **inside the existing Linux sandbox where Claude already runs**. No host
6
8
  launcher, new container, broker source checkout, API model key, or npm dependency
7
9
  is needed. The plugin uses the existing Claude login and normal permissions.
8
10
 
11
+ ## Before installation
12
+
13
+ Use the exposed launcher and native plugin manager only. If you only have the
14
+ in-session `!` shell, use it for npm and the read-only doctor below, then use
15
+ Claude’s `/plugin` UI; do not start a nested interactive Claude or use
16
+ `--plugin-dir` startup flags. Package installation is not native MCP readiness.
17
+
9
18
  ## Install from GitHub
10
19
 
11
- After release `v0.4.0` is published, install the pinned release:
20
+ After release `v0.6.0` is published, install the pinned release:
12
21
 
13
22
  ```sh
14
- claude plugin marketplace add matuto-ai/claude-code-broker-connector#v0.4.0
23
+ claude plugin marketplace add matuto-ai/claude-code-broker-connector#v0.6.0
15
24
  claude plugin install psst@psst-connectors
16
25
  ```
17
26
 
18
27
  Once released, pin the reviewed release tag instead of the branch. This package
19
28
  is a private/custom marketplace, not a listing in Anthropic's official catalog.
20
29
  Review the plugin prompt before installing. Requires **Linux, Node 24.19+ within
21
- 24.x on PATH**, and Claude Code with exec-form hooks (audited with 2.1.309).
22
-
23
- Initial private setup requires broker **0.3.0 with pairing enabled** and an
24
- operator-approved, Claude-specific one-use pairing secret for this installation.
25
- A URL or connector upgrade alone does not authorize enrollment. Enter the secret
26
- only in the native sensitive field; no per-session file transfer is needed.
27
-
28
- After installation, open Claude normally. For first authorization, configure
29
- `broker_url` and the sensitive one-use `pairing_secret` in the native `/plugin`
30
- configuration dialog, never in chat. Close the panel and verify its reload applied;
31
- if changes remain pending, use `/reload-plugins` inside Claude or start a fresh
32
- session. Then explicitly run:
33
-
34
- ```text
35
- /psst:connect
36
- ```
37
-
38
- This saves the authorized installation in its existing config volume. New native
39
- sessions attach automatically with separate client IDs and inboxes; no external
40
- connect command or credential file per session is required. Existing private
41
- bundles can instead use `/psst:connect /absolute/private/connection.json`.
42
- See [configuration, persistence and acceptance limits](docs/installation-selection.md).
30
+ 24.x on PATH**, and Claude Code with exec-form hooks (audited with 2.1.310).
31
+
32
+ After installing, open Claude normally and configure just **broker_url** in the
33
+ native `/plugin` configuration dialog. No pairing-secret field, copied token or
34
+ per-session connection file is required. Close the panel and verify its reload
35
+ applied; if necessary run `/reload-plugins` inside Claude or start a fresh session.
36
+ Then explicitly run `/psst:connect` once. It requests access from an approval-capable
37
+ Psst broker. The broker owner logs into its `/admin` form and approves the exact
38
+ installation request. A URL alone is not authorization.
39
+
40
+ The connector generates and privately persists its candidate before contacting
41
+ the broker. While pending it cannot use messaging. Approval connects automatically;
42
+ future sessions reuse that installation authorization with independent client IDs
43
+ and inboxes. If Claude restarts while pending, it only checks the same durable
44
+ request: no new key or enrollment POST. Denial/expiry stop the attempt.
45
+
46
+ Explicit replacement uses `/psst:connect` with an explicit replacement request;
47
+ the old connection stays usable until the new approval attaches and selection is
48
+ safely committed. Cancel setup only stops this session's pending attachment; it
49
+ does not revoke shared access. Existing private bundles remain an advanced legacy
50
+ path, not the primary onboarding flow. See [persistence and limits](docs/installation-selection.md).
43
51
 
44
52
  No environment-variable setup, extra agent or new container is required. The
45
- plugin must be loaded before a genuine native SessionStart; its MCP tools remain
46
- available while disconnected. Existing sessions can reload the plugin, but a
53
+ plugin must be loaded before a genuine native SessionStart; its MCP tools can be used while disconnected **only when the native host actually
54
+ loads the plugin MCP server**. Verify Psst appears in `/mcp`; hooks and skills
55
+ alone do not establish that. Existing sessions can reload the plugin, but a
47
56
  reload does not fabricate native session binding. Existing private bundles work;
48
57
  [private pairing files](docs/pairing.md) are optional when the broker supports them.
49
58
  Legacy environment/default-file configuration remains supported; see
@@ -54,15 +63,17 @@ Legacy environment/default-file configuration remains supported; see
54
63
  Ask Claude to use Psst for the peers and purpose you authorize, or invoke `/psst:psst`.
55
64
  The native MCP tools are:
56
65
 
57
- - `broker_setup`: explicit initial authorization using private native configuration, no secret arguments.
66
+ - `broker_setup`: explicitly request administrator approval using the configured URL; optional `replace: true` requires explicit replacement intent.
67
+ - `broker_setup_status`: read local status without a network or model polling loop.
68
+ - `broker_setup_cancel`: cancel this session’s pending setup, preserving existing access.
58
69
  - `broker_connect`: activate this session using a private connection JSON file.
59
70
  - `broker_disconnect`: stop transport without acknowledging unread messages.
60
71
  - `broker_identity`: this exact session address.
61
72
  - `broker_list_clients`: discover authorized peers; select the exact `client_id`.
62
- - `broker_send`: send with a stable `send_key`; preserve it if the result is uncertain.
63
- - `broker_receive`: read the FIFO head and its opaque `receipt_token`.
73
+ - `broker_send`: send to an exact recipient; technical retry keys stay private; inspect outgoing history after uncertainty.
74
+ - `broker_receive`: read the FIFO head with its private receipt kept inside the connector.
64
75
  - `broker_acknowledge`: explicitly acknowledge that read using its `message_id`
65
- and `receipt_token`, advancing to the next message.
76
+ (the connector resolves the previously exposed receipt internally), advancing to the next message.
66
77
  - `broker_message_status`: broker delivery state.
67
78
  - `broker_update_metadata`: descriptive name/project/model metadata.
68
79
 
@@ -71,6 +82,8 @@ A native plugin monitor starts automatically in every interactive Claude session
71
82
  2.1.310+. Its bounded, body-free mailbox notices let Claude receive and explicitly
72
83
  acknowledge messages without a user polling prompt. No terminal input is injected
73
84
  and no permission is granted. Agent content remains untrusted AGENT data.
85
+ **Production requires automatic delivery; do not treat manual pull as a fallback.**
86
+ If the native monitor/MCP is absent, readiness is blocked.
74
87
  **Prototype:** authenticated native wakeup is not yet certified;
75
88
  [see delivery limits and evidence](docs/automatic-delivery.md).
76
89
 
@@ -100,27 +113,48 @@ After npm publication is verified, install the released package into a private
100
113
  local tools directory (reuse your existing prefix when updating):
101
114
 
102
115
  ```sh
103
- npm install --prefix "$HOME/.local/share/psst-claude" --ignore-scripts --no-audit --no-fund @matuto-ai/claude-code-broker-connector@0.4.0
104
- "$HOME/.local/share/psst-claude/node_modules/.bin/psst-claude" plugin-path
116
+ npm install --prefix "$HOME/.local/share/psst-claude" --ignore-scripts --no-audit --no-fund @matuto-ai/claude-code-broker-connector@0.6.0
117
+ "$HOME/.local/share/psst-claude/node_modules/.bin/psst-claude" doctor
105
118
  ```
106
119
 
107
- For a first installation, add that printed directory with
108
- `claude plugin marketplace add <directory>`, then
109
- `claude plugin install psst@psst-connectors`. For an existing marketplace backed
110
- by that directory, refresh it and update the installed plugin:
120
+ The read-only doctor needs no connection file. It checks Node **>=24.19 and <25**,
121
+ the bundled runtime, observed plugin-MCP suppression and the documented native
122
+ marketplace listing. It prints the exact native install or update step for this
123
+ package. It never writes registration, settings or credentials. Exit 2 means a
124
+ restriction/source conflict or unavailable inspection; exit 1 means an invalid
125
+ package/runtime/configuration. Exit 0 means only local checks passed, **not** native
126
+ readiness. If the launcher cannot list sources from `!`, use `/plugin` directly.
111
127
 
112
- ```sh
113
- claude plugin marketplace update psst-connectors
114
- claude plugin update psst@psst-connectors
128
+ For a **fresh** local source, the printed native command is:
129
+
130
+ ```text
131
+ /plugin install psst --marketplace <printed-package-root>
115
132
  ```
116
133
 
117
- Use the original installation scope if it was not the default user scope. Do not
118
- register a duplicate marketplace or replace a shared source without checking it.
119
- The new plugin version avoids reusing the previous release cache. In an existing Claude
120
- session use `/reload-plugins`; a fresh session supplies SessionStart normally.
121
- Native permissions and workspace trust still apply. The CLI also offers
122
- `doctor [absolute-config-path]`; it never writes credentials or changes Claude
123
- settings by itself. No postinstall scripts are used.
134
+ For an **existing source backed by this same npm directory**, keep the prefix
135
+ across upgrades, refresh `/plugin marketplace update psst-connectors`, then use
136
+ `/plugin manage` to update Psst at its existing scope. If `psst-connectors` points
137
+ elsewhere (including an existing managed local source), doctor stops: an npm
138
+ install in another prefix does not update it. Its administrator must update the
139
+ approved persistent source. Do not overwrite it, remove/re-add the marketplace,
140
+ create a duplicate or bypass restricted Git. Shared source/bootstrap upgrades
141
+ remain an administrator integration step, not an automatic connector migration.
142
+
143
+ After native installation/update, use a fresh session for genuine SessionStart.
144
+ Verify the exact version in `/plugin list` and actual Psst attachment in `/mcp`,
145
+ then `/plugin configure psst@psst-connectors` → broker URL → `/psst:connect`.
146
+ If MCP is missing, stop there: a standalone server smoke test is not attachment.
147
+ A reported `CLAUDE_CODE_SKIP_PLUGIN_MCP_SERVERS` restriction needs administrator
148
+ review at its approved source, not a flag override or a parallel MCP registration.
149
+ The doctor deliberately distinguishes an inherited flag from a setting observed
150
+ on disk; neither its absence nor readable settings prove effective host policy.
151
+ No repeated reload/restart loop is needed without new evidence.
152
+
153
+ The local npm path avoids Git distribution, but does not bypass native permission,
154
+ source ownership or sandbox policy. Real launcher compatibility and authenticated
155
+ native setup/arrival still need acceptance testing. No postinstall scripts run.
156
+ `doctor [absolute-config-path]` additionally validates an explicitly selected
157
+ legacy configuration without reading its credential file.
124
158
 
125
159
  ## Maintainer checks
126
160
 
@@ -154,3 +188,7 @@ of new sessions, and the limits of legacy external bundles. Each native session
154
188
  still has its own client ID/inbox. The monitor uses the official always-start
155
189
  trigger. Actual authenticated dialog-to-MCP handoff and native delivery acceptance
156
190
  remain unverified; fixture tests are not a claim of live model acceptance.
191
+
192
+ Outgoing history, content, explicit resend and cancellation: [candidate contract](docs/message-lifecycle.md).
193
+
194
+ [Candidate verification and remaining gates](docs/candidate-verification.md).
package/bin/cli.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { fileURLToPath } from 'node:url';
3
3
  import { dirname, join } from 'node:path';
4
- import { readPluginConfig, configPath } from '../src/plugin-state.mjs';
4
+ import { readFileSync } from 'node:fs';
5
+ import { supportedRuntime, pluginMcpDiagnostic, inspectNativeMarketplace } from '../src/preflight.mjs';
5
6
  import { verifyReference } from '../src/reference-pin.mjs';
6
7
  const root = dirname(dirname(fileURLToPath(import.meta.url)));
7
8
  const [command, argument] = process.argv.slice(2);
@@ -9,10 +10,13 @@ try {
9
10
  if (command === 'plugin-path') console.log(root);
10
11
  else if (command === 'config-example') console.log(JSON.stringify({brokerUrl:'https://broker.example.invalid',tokenFile:'/absolute/private/claude/token',stateRoot:'/absolute/private/claude/state',name:'claude-code',project:null},null,2));
11
12
  else if (command === 'doctor') {
12
- if(process.platform !== 'linux' || Number(process.versions.node.split('.')[0]) !== 24) throw new Error('linux_node_24_required');
13
+ if(!supportedRuntime()) throw new Error('linux_node_24_19_through_24_x_required');
13
14
  verifyReference(join(root,'vendor','psst'));
14
- readPluginConfig(argument ?? configPath());
15
- console.log('Local configuration and bundled runtime are valid. No network request, credential read, or live Claude session was tested.');
16
- } else if (!command || ['--help','help'].includes(command)) console.log('psst-claude plugin-path | config-example | doctor [absolute-config-path]\nNative installation: claude plugin marketplace add <plugin-path>, then claude plugin install psst@psst-connectors. Restart Claude after configuration.');
15
+ if (argument) { const { readPluginConfig } = await import('../src/plugin-state.mjs'); readPluginConfig(argument); }
16
+ const report = { package_version: JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version, runtime: 'supported', bundled_runtime: 'verified', legacy_config: argument ? 'valid' : 'not_requested', native_mcp: pluginMcpDiagnostic(), installation: inspectNativeMarketplace(root), live_acceptance: 'not_tested' };
17
+ console.log(JSON.stringify(report, null, 2));
18
+ console.log('No network request to Psst, credential read, registration or settings change was made. A local preflight is not native readiness.');
19
+ if (report.native_mcp.state !== 'native_attachment_unverified' || ['source_conflict','inspection_unavailable'].includes(report.installation.state)) process.exitCode = 2;
20
+ } else if (!command || ['--help','help'].includes(command)) console.log('psst-claude plugin-path | config-example | doctor [absolute-config-path]\ndoctor is read-only and needs no connection file. Follow its native /plugin install/update guidance; conflicts stop without changes. Then verify native /mcp before setup.');
17
21
  else throw new Error('unknown_command');
18
22
  } catch(error) { console.error(`psst-claude: ${/^[a-z0-9_]+$/.test(error.message)?error.message:'configuration_check_failed'}`);process.exitCode=1; }
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ import {processIdentity} from '../src/launch-state.mjs';
2
3
  import { readPluginConfig, nativeStateConfig, pluginHook } from '../src/plugin-state.mjs';
3
4
  try {
4
5
  let size=0; const chunks=[];
5
6
  for await (const chunk of process.stdin) { size+=chunk.length; if(size>256*1024) throw new Error('hook_too_large'); chunks.push(chunk); }
6
7
  const event=JSON.parse(Buffer.concat(chunks).toString('utf8'));
7
- const result=pluginHook(nativeStateConfig(),event);
8
+ const result=pluginHook(nativeStateConfig(process.env,processIdentity(process.ppid),event.session_id),event);
8
9
  // Compatibility for an already configured 0.2.1 host journal.
9
10
  try { pluginHook(readPluginConfig(),event); } catch { /* Configuration is optional before connect. */ }
10
11
  if(result) process.stdout.write(JSON.stringify(result)+'\n');
@@ -1,6 +1,11 @@
1
1
  #!/usr/bin/env node
2
+ import { createToolScope } from '../src/tool-scope.mjs';
2
3
  import { installationRoot, readSelection, selectedConfig, saveSelection, configuredBrokerUrl, enforceConfiguredBroker } from '../src/installation-selection.mjs';
3
- import { connectPairingFile, connectPairingSecret } from '../src/enrollment-support.mjs';
4
+ import { connectPairingFile } from '../src/enrollment-support.mjs';
5
+ import {switchApproved} from '../src/approval-attachment.mjs';
6
+ import {ApprovalSession} from '../src/approval-session.mjs';
7
+ import {beginApproval,pollApproval,readApprovalState} from '../src/approval-support.mjs';
8
+ import {readSetupContext,saveSetupContext} from '../src/setup-context.mjs';
4
9
  import { join, dirname } from 'node:path';
5
10
  import { fileURLToPath } from 'node:url';
6
11
  import { writeFileSync } from 'node:fs';
@@ -8,15 +13,15 @@ import { readPrivateJson, writePrivateJson, privateDirectory } from '../src/laun
8
13
  import { verifyReference } from '../src/reference-pin.mjs';
9
14
  import { readPluginConfig, nativeStateConfig, hostDirectory, readBinding, verifyBinding, bindingModel, journalName, refreshEnrollment } from '../src/plugin-state.mjs';
10
15
 
11
- let server,runtime,inbox,timer,live=true,expected,directory,host,tools,connected=false,connecting=false,disabled=false,generation=0,autoAttempted=false;
12
- let legacyConfig=null,connectedConfig=null,lastNotice=null,lastNoticeAt=0;
16
+ let toolScope,connectionAbort,recoveryPromise,server,runtime,inbox,timer,live=true,expected,directory,host,tools,connected=false,connecting=false,disabled=false,generation=0,autoAttempted=false;
17
+ let legacyConfig=null,connectedConfig=null,lastNotice=null,lastNoticeAt=0,approvalSession=null,setupContext=null,approvalTransition=null;
13
18
  const failure=(code,effect='not_applied')=>({error:{code,retryable:true,effect}});
14
19
  const bound=()=>live&&expected&&verifyBinding(directory,expected);
15
20
  const active=()=>connected&&bound();
16
21
  function status(){
17
22
  if(!directory||!expected)return;
18
23
  const head=connected&&inbox?inbox.head():null;
19
- const value={host,sessionId:expected.sessionId,lifetime:expected.lifetime,connected:!!active(),pending_id:head?.state==='pending'?head.message_id:null};
24
+ const value={host,sessionId:expected.sessionId,lifetime:expected.lifetime,connected:!!active(),pending_id:head?.state==='pending'?head.message_id:null,setup_state:approvalSession?.status?.state??null,setup_connected:approvalSession?.status?.connected===true};
20
25
  const key=JSON.stringify(value),now=Date.now();
21
26
  if(key===lastNotice&&now-lastNoticeAt<2000)return;
22
27
  writePrivateJson(join(directory,'notice.json'),{...value,updated_at:now});lastNotice=key;lastNoticeAt=now;
@@ -25,7 +30,7 @@ function monitorAttention(){
25
30
  try{const s=readPrivateJson(join(directory,'monitor-state.json'));if(s.sessionId===expected.sessionId&&s.lifetime===expected.lifetime&&s.pending_id===(inbox?.head()?.message_id??null)&&Date.now()-s.updated_at>=0&&Date.now()-s.updated_at<5000)return {attention_required:s.attention_required===true,rate_limited:s.rate_limited===true,next_retry_at:s.next_retry_at};}catch{}
26
31
  return {state:'not_observed'};
27
32
  }
28
- async function stop(closeSession){connected=false;status();await runtime?.stop({closeSession});inbox?.close();runtime=null;inbox=null;tools=null;}
33
+ async function stop(closeSession){connected=false;status();connectionAbort?.abort();await toolScope?.close();await recoveryPromise?.catch(()=>{});recoveryPromise=null;await runtime?.stop({closeSession});inbox?.close();runtime=null;inbox=null;tools=null;}
29
34
  try {
30
35
  const root=dirname(dirname(fileURLToPath(import.meta.url)));verifyReference(join(root,'vendor','psst'));
31
36
  const [{Inbox,newSessionIdentity},{BrokerClient},{AdapterRuntime},{createTools},{createCooperativeInbox},{serveStdio}]=await Promise.all([
@@ -40,11 +45,12 @@ try {
40
45
  try{const b=readBinding(candidate);if(b.host.pid===host.pid&&verifyBinding(candidate,b)){directory=candidate;expected=b;return true;}}catch{}
41
46
  }return false;
42
47
  }
43
- async function connect(config){
44
- config=enforceConfiguredBroker(refreshEnrollment(config));
48
+ async function connect(config,{allowSavedOrigin=false,validIntent=()=>true}={}){
49
+ config=refreshEnrollment(config);if(!allowSavedOrigin)config=enforceConfiguredBroker(config);
45
50
  if(connecting)return failure('connection_busy');
46
51
  if(!confirm())return failure('session_binding_unavailable');
47
52
  if(connected)return connectedConfig.brokerUrl===config.brokerUrl&&connectedConfig.tokenFile===config.tokenFile&&connectedConfig.enrollment?.credentialId===config.enrollment?.credentialId?tools.broker_identity():failure('connection_conflict');
53
+ if(runtime&&!connected)await stop(false);
48
54
  connecting=true;disabled=false;const epoch=generation;
49
55
  try{
50
56
  const journal=privateDirectory(join(directory,journalName(expected.lifetime,config)));
@@ -55,24 +61,29 @@ try {
55
61
  const identityFile=join(journal,'identity.json');
56
62
  try{writeFileSync(identityFile,JSON.stringify(newSessionIdentity('claude-code'))+'\n',{mode:0o600,flag:'wx'});}catch(e){if(e.code!=='EEXIST')throw e;}
57
63
  const identity=readPrivateJson(identityFile);inbox=new Inbox({directory:journal,identity});
64
+ connectionAbort=new AbortController();toolScope=createToolScope();
58
65
  const client=new BrokerClient({url:config.brokerUrl,tokenFile:config.tokenFile,caFile:config.caFile,insecureDevelopment:config.insecureDevelopment===true,inbox});
59
- runtime=new AdapterRuntime({client,inbox,verifySession:bound,isAlive:bound,onDiagnostic:({code})=>process.stderr.write(`psst_${code}\n`)});
66
+ runtime=new AdapterRuntime({client,inbox,verifySession:bound,isAlive:bound,onDiagnostic:({code})=>{if(['unauthenticated','forbidden','session_closed'].includes(code)){connected=false;status();}process.stderr.write(`psst_${code}\n`);}});
60
67
  tools={...createTools({client,inbox,observeMetadata:()=>({model:bindingModel(readBinding(directory))})}),...createCooperativeInbox({inbox,isActive:active}),
61
- broker_identity:async()=>({credential_id:config.enrollment?.credentialId??null,credential_expires_at:config.enrollment?.expiresAt??null,monitor_attention:monitorAttention(),client_id:identity.client_id,client_type:'claude-code',native_session_id:expected.sessionId,local_mode:'cooperative_read_with_bounded_monitor_notices',automatic_wakeup:'bounded_monitor_when_interactive',native_acceptance_recorded:false})};
68
+ broker_identity:async()=>({credential_id:config.enrollment?.credentialId??null,credential_expires_at:config.enrollment?.expiresAt??null,monitor_attention:monitorAttention(),client_id:identity.client_id,client_type:'claude-code',native_session_id:expected.sessionId,mode:'automatic_required',local_mode:'native_monitor_delivery',readiness:monitorAttention().state==='not_observed'?'blocked_monitor_not_observed':'native_wakeup_acceptance_unverified',automatic_wakeup:'bounded_monitor_when_interactive',native_acceptance_recorded:false})};
62
69
  await runtime.start({name:config.name,project:config.project??null,model:bindingModel(expected)});
63
- if(!bound()||generation!==epoch||disabled)throw Error('session_changed');connected=true;connectedConfig=config;disabled=false;writePrivateJson(join(directory,'activation.json'),{sessionId:expected.sessionId,lifetime:expected.lifetime,disabled:false,config});status();return tools.broker_identity();
70
+ if(!bound()||generation!==epoch||disabled||!validIntent())throw Error('session_changed');
71
+ recoveryPromise=client.recoverOutgoing({limit:10,signal:AbortSignal.any([connectionAbort.signal,AbortSignal.timeout(5000)])});
72
+ await recoveryPromise.catch(()=>{});
73
+ if(!bound()||generation!==epoch||disabled||!validIntent())throw Error('session_changed');connected=true;connectedConfig=config;disabled=false;writePrivateJson(join(directory,'activation.json'),{sessionId:expected.sessionId,lifetime:expected.lifetime,disabled:false,config});status();return tools.broker_identity();
64
74
  }catch{await stop(!bound());return failure(generation!==epoch?'connection_cancelled':'connection_failed','unknown');}finally{connecting=false;}
65
75
  }
66
- const names=['broker_list_clients','broker_send','broker_update_metadata','broker_message_status','broker_receive','broker_acknowledge','broker_identity'];
67
- const guarded=Object.fromEntries(names.map(name=>[name,async args=>{
76
+ const names=['broker_list_clients','broker_send','broker_update_metadata','broker_message_status','broker_receive','broker_acknowledge','broker_identity','broker_sent_history','broker_message_content','broker_resend','broker_cancel'];
77
+ const guarded=Object.fromEntries(names.map(name=>[name,async (args,context)=>{
68
78
  if(!active())return failure(expected?'not_connected':'session_binding_unavailable');
69
- const result=await tools[name](args);status();return result;
79
+ return toolScope.run(async signal=>{const result=await tools[name](args,{...context,signal});status();return result;},context?.signal);
70
80
  }]));
71
81
  let pairing=false;
72
- const explicitConnect=async({connection_file},nativeSetup=false)=>{
82
+ const explicitConnect=async({connection_file})=>{
83
+ approvalSession?.cancel();
73
84
  // Explicit user intent supersedes deferred startup resume, including failed attempts.
74
85
  autoAttempted=true;
75
- if(pairing||connecting)return failure('connection_busy');
86
+ if(pairing||connecting||approvalTransition)return failure('connection_busy');
76
87
  if(!confirm())return failure('session_binding_unavailable');
77
88
  try{
78
89
  let selectionRevision=null,selectionReadable=true;const selectionEpoch=generation;
@@ -81,13 +92,12 @@ try {
81
92
  if(!selectionReadable)selection={saved:false,reason:'installation_selection_invalid_or_unreadable'};
82
93
  else try{selection=saveSelection(file,selectionRevision,installationRoot(),connectedConfig);}catch{selection={saved:false,reason:'installation_selection_unavailable'};}
83
94
  return {...result,installation_selection:selection};};
84
- const input=nativeSetup?null:readPrivateJson(connection_file);
85
- if(nativeSetup||input?.format==='psst-pairing-v1'){
95
+ const input=readPrivateJson(connection_file);
96
+ if(input?.format==='psst-pairing-v1'){
86
97
  if(connected)return failure('disconnect_before_pairing');
87
98
  pairing=true;const epoch=generation;
88
99
  const options={directory:join(installationRoot(),'enrollment'),clientType:'claude-code',brokerUrl:configuredBrokerUrl()??undefined};
89
- if(nativeSetup&&(!options.brokerUrl||!process.env.PSST_PLUGIN_PAIRING_SECRET))return failure('native_setup_configuration_required');
90
- const result=nativeSetup?await connectPairingSecret({...options,pairingSecret:process.env.PSST_PLUGIN_PAIRING_SECRET}):await connectPairingFile({...options,pairingFile:connection_file});
100
+ const result=await connectPairingFile({...options,pairingFile:connection_file});
91
101
  if(epoch!==generation||!bound())return failure('connection_cancelled','unknown');
92
102
  return remember(await connect(readPluginConfig(result.connectionFile)),result.connectionFile);
93
103
  }
@@ -96,10 +106,67 @@ try {
96
106
  finally{pairing=false;}
97
107
  };
98
108
  guarded.broker_connect=args=>explicitConnect(args);
99
- // Explicit native action only: residual native secrets are never used at startup.
100
- guarded.broker_setup=()=>explicitConnect({},true);
101
- guarded.broker_disconnect=async()=>{autoAttempted=true;generation++;disabled=true;connected=false;if(expected)writePrivateJson(join(directory,'activation.json'),{sessionId:expected.sessionId,lifetime:expected.lifetime,disabled:true});status();if(connecting)void runtime?.stop({closeSession:false}).catch(()=>{});else await stop(false);return{connected:false,pending_connection_cancelled:connecting||pairing,pending_retained:true,native_acceptance_recorded:false};};
102
- server=serveStdio({tools:guarded,onClose:async()=>{const closeSession=!bound();generation++;live=false;await stop(closeSession);},onDiagnostic:()=>{}});
109
+ const setupOptions=brokerUrl=>({brokerUrl,directory:installationRoot(),clientType:'claude-code',label:'Claude Code installation'});
110
+ const updateSetup=state=>{
111
+ if(!setupContext||!state.requestId)return;
112
+ try{
113
+ const latest=readSetupContext(),nextState=state.connected?'applied':state.state;
114
+ if(latest?.broker_url===setupContext.broker_url&&latest.selection_revision===setupContext.selection_revision&&latest.request_id===state.requestId){
115
+ setupContext=latest;if(latest.state===nextState||latest.state==='applied')return status();
116
+ }
117
+ setupContext=saveSetupContext({...setupContext,request_id:state.requestId,state:nextState},setupContext.revision);
118
+ }
119
+ catch{/* A competing explicit choice owns selection; never overwrite it. */}
120
+ status();
121
+ };
122
+ approvalSession=new ApprovalSession({beginApproval,pollApproval,readApprovalState,isCurrent:()=>bound()&&!disabled,
123
+ onState:updateSetup,onApproved:async(result,current)=>{
124
+ const context=readSetupContext();
125
+ if(!current()||!context||context.broker_url!==setupContext?.broker_url||context.selection_revision!==setupContext?.selection_revision||(context.revision!==setupContext?.revision&&context.request_id!==result.requestId)||(context.request_id&&context.request_id!==result.requestId))return {connected:false,error:'setup_context_changed'};
126
+ const selection=readSelection();if((selection?.revision??null)!==context.selection_revision&&selection?.connection_file!==result.connectionFile)return {connected:false,error:'installation_selection_changed'};
127
+ const config=readPluginConfig(result.connectionFile),previous=connected?connectedConfig:null;
128
+ if(new URL(config.brokerUrl).origin!==context.broker_url)return {connected:false,error:'configured_broker_mismatch'};
129
+ const transaction={phase:'stopping'};approvalTransition=transaction;
130
+ const restoreAllowed=()=>approvalTransition===transaction&&bound()&&!disabled;
131
+ return switchApproved({previous,stop:()=>stop(false),
132
+ connect:(old,{restore,isCurrent})=>connect(restore?old:config,{allowSavedOrigin:restore,validIntent:isCurrent}),
133
+ select:()=>current()?saveSelection(result.connectionFile,context.selection_revision,installationRoot(),connectedConfig):{saved:false,reason:'connection_cancelled'},
134
+ isCurrent:()=>current()&&approvalTransition===transaction,canRestore:restoreAllowed,
135
+ onPhase:phase=>{
136
+ if(approvalTransition!==transaction)return;
137
+ transaction.phase=phase;
138
+ if(phase==='finished'){
139
+ if(!connected&&expected)writePrivateJson(join(directory,'activation.json'),{sessionId:expected.sessionId,lifetime:expected.lifetime,disabled:true});
140
+ approvalTransition=null;
141
+ }
142
+ }});
143
+ }});
144
+ guarded.broker_setup=async({replace=false}={})=>{
145
+ autoAttempted=true;if(pairing||connecting||approvalSession.busy||approvalTransition)return failure('connection_busy');
146
+ if(!confirm())return failure('session_binding_unavailable');
147
+ try{
148
+ const brokerUrl=configuredBrokerUrl();if(!brokerUrl)return failure('native_setup_configuration_required');
149
+ const old=readSetupContext();
150
+ if(old&&old.broker_url===brokerUrl&&['requested','pending','approved'].includes(old.state)&&!replace){setupContext=old;}
151
+ else setupContext=saveSetupContext({broker_url:brokerUrl,selection_revision:readSelection()?.revision??null,request_id:null,state:'requested'},old?.revision??null);
152
+ disabled=false;writePrivateJson(join(directory,'setup-activation.json'),{sessionId:expected.sessionId,lifetime:expected.lifetime,cancelled:false});
153
+ return await approvalSession.start(setupOptions(brokerUrl),{replace});
154
+ }catch(error){
155
+ // A failed POST may have durable local candidate state. Later startup may GET
156
+ // it, but never invent a new request or retry POST automatically.
157
+ return {...failure(error.retryable?'approval_temporarily_unavailable':'approval_setup_blocked',error.effect??'not_applied'),...(Number.isFinite(error.retryAfterMs)?{retry_after_ms:Math.max(1000,Math.min(300000,error.retryAfterMs))}:{})};
158
+ }
159
+ };
160
+ const deliveryReadiness=()=>({mode:'automatic_required',readiness:monitorAttention().state==='not_observed'?'blocked_monitor_not_observed':'native_wakeup_acceptance_unverified',manual_receive:'diagnostic_test_only'});
161
+ guarded.broker_setup_status=async()=>{
162
+ if(approvalSession.status.state!=='not_started')return {...approvalSession.status,...deliveryReadiness()};
163
+ try{const context=readSetupContext();if(!context)return {state:active()?'connected':'setup_required',connected:!!active(),...deliveryReadiness()};
164
+ const saved=readApprovalState(setupOptions(context.broker_url));return saved?{state:saved.state,requestId:saved.requestId,requestExpiresAt:saved.requestExpiresAt,connected:!!active(),...deliveryReadiness()}:{state:'setup_required',connected:!!active(),...deliveryReadiness()};
165
+ }catch{return {state:'blocked',error:'invalid_saved_setup',connected:!!active(),...deliveryReadiness()};}
166
+ };
167
+ guarded.broker_setup_cancel=async()=>{approvalSession.cancel();if(approvalTransition&&['stopping','attaching','selecting'].includes(approvalTransition.phase))generation++;if(expected)writePrivateJson(join(directory,'setup-activation.json'),{sessionId:expected.sessionId,lifetime:expected.lifetime,cancelled:true});return {state:'cancelled',existing_connection_retained:!!active()};};
168
+ guarded.broker_disconnect=async()=>{approvalSession.cancel();autoAttempted=true;generation++;disabled=true;connected=false;if(expected)writePrivateJson(join(directory,'activation.json'),{sessionId:expected.sessionId,lifetime:expected.lifetime,disabled:true});status();if(connecting)void runtime?.stop({closeSession:false}).catch(()=>{});else await stop(false);return{connected:false,pending_connection_cancelled:connecting||pairing,pending_retained:true,native_acceptance_recorded:false};};
169
+ server=serveStdio({tools:guarded,onClose:async()=>{const closeSession=!bound();approvalSession?.cancel();generation++;live=false;await stop(closeSession);},onDiagnostic:()=>{}});
103
170
  // Stay available while disconnected. Never retry failed enrollment/auth indefinitely.
104
171
  timer=setInterval(()=>{void(async()=>{
105
172
  if(expected&&!bound()){live=false;await server.close();return;}
@@ -107,13 +174,22 @@ try {
107
174
  if(expected&&!autoAttempted&&!disabled){
108
175
  autoAttempted=true;let config=legacyConfig;
109
176
  try{const a=readPrivateJson(join(directory,'activation.json'));if(a.sessionId===expected.sessionId&&a.lifetime===expected.lifetime){disabled=a.disabled===true;config=a.config??null;}}catch{}
177
+ let resumeOptions=null;
178
+ try{
179
+ const saved=readSetupContext(),cancel=(()=>{try{return readPrivateJson(join(directory,'setup-activation.json'));}catch{return null;}})();
180
+ if(saved&&['requested','pending','approved'].includes(saved.state)&&!(cancel?.sessionId===expected.sessionId&&cancel?.lifetime===expected.lifetime&&cancel?.cancelled)&&!disabled){
181
+ const local=readApprovalState(setupOptions(saved.broker_url));
182
+ if(local&&(!saved.request_id||saved.request_id===local.requestId)){setupContext=saved;resumeOptions=setupOptions(saved.broker_url);}
183
+ }
184
+ }catch{process.stderr.write('psst_setup_resume_unavailable: existing request state is invalid; no new request created.\n');}
110
185
  if(config&&!disabled){
111
- try{const result=await connect(config);if(result?.error)process.stderr.write('psst_autoconnect_unavailable: saved installation could not attach; use explicit connect after correcting configuration.\n');}
186
+ try{const result=await connect(config,{allowSavedOrigin:!!resumeOptions&&setupContext.selection_revision===(readSelection()?.revision??null)});if(result?.error)process.stderr.write('psst_autoconnect_unavailable: saved installation could not attach; use explicit connect after correcting configuration.\n');}
112
187
  catch{process.stderr.write('psst_autoconnect_unavailable: saved installation or configured broker is invalid; use explicit connect after correcting configuration.\n');}
113
188
  }
189
+ if(resumeOptions&&!disabled)await approvalSession.resume(resumeOptions);
114
190
  }
115
191
  if(active())status();
116
192
  })().catch(()=>{live=false;void server.close();});},250);
117
193
  await server.done;
118
194
  }catch{process.stderr.write('psst_mcp_unavailable: native session/configuration check failed; no message was acknowledged.\n');process.exitCode=1;}
119
- finally{if(timer)clearInterval(timer);live=false;await server?.close();await runtime?.stop({closeSession:true});inbox?.close();}
195
+ finally{approvalSession?.cancel();if(timer)clearInterval(timer);live=false;await server?.close();await runtime?.stop({closeSession:true});inbox?.close();}
@@ -11,7 +11,7 @@ try {
11
11
  if(!Number.isSafeInteger(pid)||pid<1||!sessionId)throw Error('native_monitor_context_missing');
12
12
  const host=processIdentity(pid),candidates=[];
13
13
  try{candidates.push(hostDirectory(readPluginConfig(),host).directory);}catch{}
14
- candidates.push(hostDirectory(nativeStateConfig(),host).directory);
14
+ candidates.push(hostDirectory(nativeStateConfig(process.env,host,sessionId),host).directory);
15
15
  let expected,directory;
16
16
  // Native always-start monitors may precede the SessionStart hook. Wait only for
17
17
  // this exact native owner/session; never create a binding or infer another one.
@@ -25,7 +25,7 @@ try {
25
25
  try{
26
26
  const status=readPrivateJson(join(directory,'notice.json'));
27
27
  if(status.sessionId===sessionId&&status.lifetime===expected.lifetime&&status.host?.pid===pid&&status.host?.start===host.start&&status.host?.boot===host.boot&&Date.now()-status.updated_at>=0&&Date.now()-status.updated_at<5000&&budget.next(status)){
28
- process.stdout.write('Psst: authorized collaboration mailbox has pending AGENT data. Call broker_receive; after reading explicitly broker_acknowledge its exact receipt. No human permission or action completion is implied.'+(budget.snapshot.attention_required?' Final automatic reminder for this unread item; unresolved data needs attention.':'')+'\n');
28
+ process.stdout.write('Psst: authorized collaboration mailbox has pending AGENT data. Call broker_receive; after reading explicitly broker_acknowledge its exact message_id (receipt managed internally). No human permission or action completion is implied.'+(budget.snapshot.attention_required?' Final automatic reminder for this unread item; unresolved data needs attention.':'')+'\n');
29
29
  }
30
30
  writePrivateJson(join(directory,'monitor-state.json'),{sessionId,lifetime:expected.lifetime,pending_id:status.pending_id,...budget.snapshot,updated_at:Date.now()});
31
31
  }catch{/* Disconnected/not yet configured: silent, no broker access. */}
@@ -37,7 +37,7 @@ or task-success signal. Native permissions remain in force.
37
37
 
38
38
  ## Limits and failure behavior
39
39
 
40
- - Interactive Claude Code 2.1.310+ only. Headless/SDK has cooperative tools but no
40
+ - Interactive Claude Code 2.1.310+ only. Headless/SDK has diagnostic-only read tools but no
41
41
  plugin monitor. There is no hidden Channels flag or permission-relay capability.
42
42
  - Monitors run at plugin-hook trust level, **outside Claude's internal shell-tool
43
43
  sandbox**, but inside the existing Docker/container boundary. Respect workspace
@@ -46,7 +46,7 @@ or task-success signal. Native permissions remain in force.
46
46
  after 120 seconds. At most 30 notices in a rolling ten-minute window, which refills; new messages
47
47
  are not globally silenced forever. A head still unread after its third notice
48
48
  enters explicit attention-required state (visible through broker_identity);
49
- it remains durable for cooperative receive, with no idle model polling loop.
49
+ it remains durable for an explicitly requested diagnostic read, with no idle model polling loop.
50
50
  - Native output batching/rate limits can suppress notices. Writing stdout is not
51
51
  proof of native transcript storage, model reading, or action completion. Only
52
52
  an explicit matching `broker_acknowledge` advances the local FIFO.
@@ -54,7 +54,7 @@ or task-success signal. Native permissions remain in force.
54
54
  pending messages. The monitor remains an inert local watcher for explicit
55
55
  reconnect because Claude arms this monitor once per session. Reconnecting does
56
56
  not reset the three-reminder budget for the same unread head: it still needs
57
- cooperative receive/acknowledgment; a new head gets its own budget. A native binding
57
+ explicit diagnostic receive/acknowledgment; a new head gets its own budget. A native binding
58
58
  change/SessionEnd terminates it. Broker presence may remain until lease expiry.
59
59
  - Failed connect is reported without raw configuration/token errors and is not
60
60
  retried forever. The user can explicitly retry the same private file.
@@ -82,3 +82,14 @@ and official docs establish the supported path, not an authenticated success cla
82
82
  References:
83
83
  - https://code.claude.com/docs/en/plugins/components#monitors
84
84
  - https://code.claude.com/docs/en/plugins/manifest-reference#monitors
85
+
86
+ ## Production requirement (2026-11-01)
87
+
88
+ Automatic receive is required for production. Headless/manual cooperative receive
89
+ is retained only for explicit diagnostics/tests and must not be reported as a
90
+ working production fallback. Missing native MCP or monitor capability is a blocked
91
+ state; preserve pending messages without auto-acknowledging or bypassing policy.
92
+ Receive now renders a compact AI-agent header and escaped message JSON. Wire
93
+ origin/authority validation and normal native permissions are unchanged. Explicit
94
+ read ACK needs only the exact message ID; its already exposed same-inbox receipt
95
+ is resolved internally, with supplied legacy tokens still validated strictly.
@@ -0,0 +1,25 @@
1
+ # Unreleased candidate verification — 2026-11-01
2
+
3
+ Related issue: #11; not closed by this partial delivery.
4
+
5
+ ## Checked locally
6
+
7
+ Syntax + 70/70 unit tests passed; integration 22 passed and one unavailable container gate skipped. Unit aggregate includes package validation.
8
+
9
+ Synthetic native parent + real TLS/broker/monitor notices, same-host MCP restart and 30 independent native hosts.
10
+
11
+ Runtime vendoring is exact Psst candidate `2de9cc3758b9fc593f5afca4fc501fd013d416ec`. Shared acceptance-journal
12
+ failure after a remote success reports an unknown effect, never known-not-applied.
13
+ Model retry/receipt keys remain private; message ID acknowledgment is still scoped
14
+ to a previously exposed exact inbox item. Native messaging calls abort and drain
15
+ before their journal closes; lifetime-control operations do not wait on themselves.
16
+
17
+ ## Not established
18
+
19
+ Managed plugin-MCP suppression and authenticated native idle/busy/monitor acceptance remain external gates. Preflight is diagnostic, not a supported policy bypass or complete durable source-upgrade solution.
20
+
21
+ New outgoing history/content/resend/cancel routes require the matching Psst broker.
22
+ Older broker send/receive compatibility does not imply availability of new APIs.
23
+ These are local candidate results, not published registry versions, deployed code,
24
+ remote CI, authenticated-provider acceptance or a release. No permissions, managed
25
+ policy, accounts or production host configuration were changed.