@matuto-ai/opencode-psst-connector 0.6.0 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## 0.6.0 — unreleased source candidate
3
+ ## 0.7.0 — unreleased source candidate
4
4
 
5
5
  Publish the connector under `@matuto-ai/opencode-psst-connector` and rename the GitHub repository to `opencode-psst-connector`. Preserve native identity, saved installation credentials, journals and tool names. The old npm package remains available; migration requires an explicit package-source change. See [migration checklist](docs/migration-0.6.0.md).
6
6
 
7
- ## Unreleased
7
+ ### Included source changes
8
+
9
+ - Add explicit native `/psst-reauthorize`, configured-origin reuse and exact-request/selection confirmation; replacement does not revoke the old credential.
10
+ - Preserve setup candidates across focus interruptions, reconcile durable outcomes, and isolate unrelated chats without requiring them to close.
8
11
 
9
12
  - Add native URL-only setup/status/cancel dialogs, exact-request replacement confirmation, durable owner/status recovery and cancellation/CAS fencing.
10
13
  - Attach approved replacement before selecting it while retaining the previous working authorization and private journals; preserve strict identity recovery for expired authorization so setup can repair it.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Psst for OpenCode
2
2
 
3
- **0.6.0 source candidate — not published yet.**
3
+ **0.7.0 source candidate — not published yet.**
4
4
  One npm package, one current native plugin, registered as **`matuto.psst`**.
5
5
  The same npm package provides official server and TUI entries. There is no separate installation, Git wrapper or legacy entry.
6
6
 
@@ -9,7 +9,7 @@ The same npm package provides official server and TUI entries. There is no separ
9
9
  After this version is published:
10
10
 
11
11
  ```sh
12
- opencode plugin add @matuto-ai/opencode-psst-connector@0.6.0
12
+ opencode plugin add @matuto-ai/opencode-psst-connector@0.7.0
13
13
  ```
14
14
 
15
15
  For an existing installation, first inspect `opencode plugin list` and remove
@@ -33,8 +33,31 @@ A deliberate new authorization has new credential-bound addresses; it does not
33
33
  adopt the previous authorization's mailbox. Old local data remains intact.
34
34
  Ordinary upgrades retain the same authorization, addresses and unread mailbox.
35
35
 
36
+ ### Request a replacement authorization
37
+
38
+ Run **`/psst-reauthorize`** in the intended chat, or choose **Psst: Request
39
+ replacement authorization** in the palette. It reuses the configured HTTPS broker
40
+ origin (or asks for one if none exists), displays that target, and asks for native
41
+ confirmation before creating a new request. Approve the displayed public request
42
+ ID in the broker `/admin` page, then use `/psst-status` to distinguish pending,
43
+ approved but not connected, and connected. A pending candidate is resumed, not
44
+ replaced; an approved candidate whose attachment failed is retried intact.
45
+
46
+ This is deterministic native TUI-to-server dispatch, with no model conversation
47
+ or model/provider tokens required by the connector on supported OpenCode 2.2.5.
48
+ It is not reconnect: reopening the selected conversation reuses saved credentials
49
+ and existing addresses. Reauthorization creates new credential-bound addresses
50
+ only after approval, attachment and safe selection. Old unread data is preserved,
51
+ not replayed into the new identity. Existing access continues only while valid.
52
+
53
+ **Replacement does not revoke the old credential.** Revocation is a separate
54
+ human action in Psst admin; do not revoke working access first as a prerequisite.
55
+ Cancellation, focus loss or an uncertain response never authorizes another new
56
+ request: return to the original chat, inspect `/psst-status`, then repeat the
57
+ command to reconcile the same candidate. Do not delete private state or locks.
58
+
36
59
  The URL-only `broker_setup` tool remains available for an explicitly requested
37
- initial setup, but an agent tool cannot confirm replacement. Use the native dialog
60
+ initial setup, but an agent tool cannot confirm replacement. Use `/psst-reauthorize`
38
61
  for that decision. See [setup, persistence and acceptance](docs/setup-acceptance.md).
39
62
 
40
63
  ## Messages
@@ -124,7 +147,7 @@ MIT. Vendored Psst code keeps its license and exact integrity pins.
124
147
 
125
148
  ## Migrating to the Psst package name
126
149
 
127
- Version 0.6.0 uses a new npm package and GitHub repository name. Follow the [migration checklist](docs/migration-0.6.0.md) before publication or upgrading an existing installation. Preserve all private Psst state and do not load both old and new sources.
150
+ Version 0.7.0 uses a new npm package and GitHub repository name. Follow the [migration checklist](docs/migration-0.6.0.md) before publication or upgrading an existing installation. Preserve all private Psst state and do not load both old and new sources.
128
151
 
129
152
  ### Opt-in registration v2 and retained native conversations
130
153
 
@@ -156,3 +179,5 @@ connector intentionally continues exposing its existing eight tools, not those t
156
179
  new tools; unknown future SDK catalog changes still fail closed.
157
180
 
158
181
  Native offline transport notices and their exact-context ACK semantics are documented in [presence-notices](docs/presence-notices.md).
182
+
183
+ For interrupted URL setup or multiple open chats, see [setup context and focus recovery](docs/setup-context-recovery.md). Keep unrelated chats open; return to the original chat and reconcile its saved request with `/psst-status`.
@@ -1,13 +1,13 @@
1
- # 0.6.0 package and repository migration
1
+ # 0.7.0 package and repository migration
2
2
 
3
- This is an unreleased source candidate, not proof of npm publication. The new package is `@matuto-ai/opencode-psst-connector@0.6.0`; the old package is `@matuto-ai/opencode-broker-connector`. No prerelease/RC package is required.
3
+ This is an unreleased source candidate, not proof of npm publication. The new package is `@matuto-ai/opencode-psst-connector@0.7.0`; the old package is `@matuto-ai/opencode-broker-connector`. No prerelease/RC package is required.
4
4
 
5
5
  ## Owner publication order
6
6
 
7
7
  1. Review and merge the code in the existing repository; retain its history and branch protections. CI checks out this repository and runs its unit suite; it does not fetch a second repository.
8
8
  2. Rename the GitHub repository to `matuto-ai/opencode-psst-connector`. Do not create a replacement repository. Update local remotes and verify the `verify` check and repository settings after the rename. Package metadata already points at this final name.
9
9
  3. Establish the new public npm package through npm's supported owner/account setup. Configure its own trusted publisher for GitHub owner `matuto-ai`, repository `opencode-psst-connector`, workflow `npm-publish.yml`, allowing direct `npm publish`. The old package's trusted publisher does not transfer. Validate new configuration within npm's two-day first-publish window. Account and publishing actions belong to the owner, not this code change.
10
- 4. Publish the GitHub release tagged `v0.6.0` only when the new package's publishing access is ready. The workflow checks tag/version parity and tests before publishing. Never publish the same package-name/version twice: if initial owner setup already published the final `0.6.0` artifact, do not trigger a second publish of it; reconcile the artifact and workflow plan first.
10
+ 4. Publish the GitHub release tagged `v0.7.0` only when the new package's publishing access is ready. The workflow checks tag/version parity and tests before publishing. Never publish the same package-name/version twice: if initial owner setup already published the final `0.7.0` artifact, do not trigger a second publish of it; reconcile the artifact and workflow plan first.
11
11
  5. Verify the new npm artifact, provenance, exact version and a clean native install plus upgrade. Only then deprecate the old package with a message naming its replacement. Keep old versions downloadable; do not unpublish them.
12
12
 
13
13
  ## Existing installation migration
@@ -1,7 +1,7 @@
1
1
  # Publishing the current OpenCode plugin
2
2
 
3
3
  The root npm package is `@matuto-ai/opencode-psst-connector`; the next version
4
- is **0.6.0**. Both `.` and `./server` resolve to the same native server object.
4
+ is **0.7.0**. Both `.` and `./server` resolve to the same native server object.
5
5
  The official ./tui entry is included in that same package. No alternate package, Git source dependency or host installer is included.
6
6
 
7
7
  The owner publishes the matching GitHub release after review/merge. The release
@@ -12,7 +12,7 @@ release exists. Do not instruct installation of an unpublished version.
12
12
  After npm publication is verified, the supported command is:
13
13
 
14
14
  ```sh
15
- opencode plugin add @matuto-ai/opencode-psst-connector@0.6.0
15
+ opencode plugin add @matuto-ai/opencode-psst-connector@0.7.0
16
16
  ```
17
17
 
18
18
  Keep current 2.2.5 native-runtime acceptance separate from the unit and package file-list checks.
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Install, configure, use
4
4
 
5
- The scoped 0.6.0 package is an unreleased source candidate. After publication,
5
+ The scoped 0.7.0 package is an unreleased source candidate. After publication,
6
6
  install it using the single native plugin-manager command in the README. The
7
7
  same package loads its server and TUI entries; do not install a second wrapper.
8
8
 
@@ -0,0 +1,55 @@
1
+ # Setup context and focus recovery
2
+
3
+ Keep unrelated chats open. `/psst-setup` and `/psst-status` use the selected
4
+ conversation and location captured by that TUI, not the number of open chats.
5
+ Another TUI's selection or blur does not cancel this TUI's command.
6
+
7
+ The supported OpenCode 2.2.5 boundary supplies renderer blur and selection
8
+ events, but blur does not prove whether the user went to a browser, another
9
+ terminal, or another native dialog. The connector therefore suspends the current
10
+ action on blur instead of trusting a confirmation returned after losing focus.
11
+ It also fences actual conversation/location changes, including away-and-back.
12
+
13
+ - If you need a URL from another window, copy it first. Return to the intended
14
+ chat, dismiss the old dialog, and reopen `/psst-setup`.
15
+ - After a request is saved, opening the broker `/admin` page is expected. Return
16
+ to the original chat and run `/psst-status`; any interrupted call does not erase
17
+ the durable request. If paused, `/psst-setup` with the same URL reconciles/resumes
18
+ that request. A running owner retry returns its existing request without a
19
+ second enrollment call. Never replay an old replacement confirmation.
20
+ - `/psst-reauthorize` uses the same context guards and saved candidate. Its native
21
+ confirmation shows the broker origin and is bound to both the previous request
22
+ and selected-installation revision. Reconnect is automatic reuse of the saved
23
+ credential; reauthorization is a new human-approved candidate; revoke remains
24
+ a separate human action in Psst admin. Neither an agent nor a peer message may
25
+ silently substitute one action for another.
26
+ - `request=not_submitted` means this action never called setup. It does not mean
27
+ there was no earlier request. `saved` means a durable request identity exists,
28
+ not proof that the broker has received or approved it. `unknown` means a reply
29
+ was lost or persistence could not be established; inspect status before retry.
30
+ - Setup owned elsewhere must be resumed in its original chat. Pausing a pending
31
+ request does not authorize a different conversation to adopt it. Existing
32
+ explicit replacement of a terminal authorization remains available with a
33
+ fresh confirmation bound to its exact request.
34
+
35
+ Warnings include only fixed phase/cause/request-state enums. Record those fields,
36
+ host version, and whether the two chats were views of one TUI or independent
37
+ TUIs/servers. Do not include messages, tokens, private paths or credentials. Do
38
+ not delete locks, reset identity, terminate other CLIs or change permission policy.
39
+
40
+ ## Evidence and limits for issue #27
41
+
42
+ Native-boundary unit doubles reproduce cancellation when this renderer emits
43
+ blur during URL entry, preflight, replacement confirmation, setup, status or
44
+ pause. A second independent TUI sharing the project can change its selection
45
+ and emit blur without interrupting the stable first TUI. These tests establish
46
+ connector event handling, not the original incident's host topology or trigger.
47
+ The report that closing another chat helped remains a workaround observation,
48
+ not evidence that two chats cause cancellation. No real-host reproduction,
49
+ container/provider suite, live setup change or release is claimed.
50
+
51
+ Regressions also cover selection change after receipt persistence, lost setup
52
+ reply after replacement preflight, same-owner retry while polling, cancellation
53
+ before/after persistence, durable read-only status and cross-chat ownership.
54
+ The existing selection CAS, credentials, journal, native permission, active-chat
55
+ routing and model/agent selection paths remain in place.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matuto-ai/opencode-psst-connector",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24.19.0 <25"
package/plugin/tui.mjs CHANGED
@@ -40,12 +40,13 @@ const stateMessages = {
40
40
  setup_needed: 'Enter your broker URL with /psst-setup.',
41
41
  pending: 'Waiting for human approval in the broker admin UI. Use /psst-status to check.',
42
42
  approved: 'Broker authorization approved.',
43
- denied: 'The approval request was denied. A new request requires confirmation in /psst-setup.',
44
- expired: 'The approval request expired. A new request requires confirmation in /psst-setup.',
45
- revoked: 'The authorization was revoked. A new request requires confirmation in /psst-setup.',
43
+ denied: 'The approval request was denied. A new request requires confirmation in /psst-reauthorize.',
44
+ expired: 'The approval request expired. A new request requires confirmation in /psst-reauthorize.',
45
+ revoked: 'The authorization was revoked. A new request requires confirmation in /psst-reauthorize.',
46
46
  paused: 'Local setup is paused. Existing authorization and local data are retained.',
47
47
  setup_blocked: 'Setup could not continue. Check /psst-status before retrying.',
48
48
  setup_owner_recovery_required: 'Setup ownership needs recovery. Existing authorization and local data are retained.',
49
+ setup_owned_elsewhere: 'Setup belongs to another chat. Return to that chat and use /psst-status; do not close other chats or reset setup.',
49
50
  };
50
51
  function brokerOrigin(value) {
51
52
  if (typeof value !== 'string' || value.length > 2048 || /[\s\x00-\x1f\x7f-\x9f]/u.test(value)) throw Error('invalid_url');
@@ -61,8 +62,11 @@ function statusMessage(result) {
61
62
  let message = result?.connected === true
62
63
  ? 'Psst is connected for this selected chat.' + (setupMessage && result.state !== 'setup_needed' ? ` Existing connection stays active. Setup: ${setupMessage}` : '')
63
64
  : setupMessage ?? 'Psst is not connected. Use /psst-setup to configure it.';
64
- if (result?.authorization_state === 'expired') message = 'The current authorization expired. Use /psst-setup for an explicitly approved replacement. Existing local data is retained.';
65
- if (result?.authorization_state === 'rejected') message = 'The broker rejected the current authorization. Use /psst-setup for an explicitly approved replacement. Existing local data is retained.';
65
+ if (result?.authorization_state === 'expired') message = 'The current authorization expired. Use /psst-reauthorize for an explicitly approved replacement. Existing local data is retained.';
66
+ if (result?.authorization_state === 'rejected') message = 'The broker rejected the current authorization. Use /psst-reauthorize for an explicitly approved replacement. Existing local data is retained.';
67
+ const approvedCandidate = result?.state === 'approved'
68
+ || ['paused', 'setup_blocked'].includes(result?.state) && result?.request_state === 'approved';
69
+ if (approvedCandidate && (result?.candidate_selected !== true || result?.connected !== true)) message += ' The approved candidate is not connected yet.' + (result?.candidate_selected !== true ? ' Its attachment has not been committed.' : '');
66
70
  if (typeof result?.approval_url === 'string' && result.approval_url.length <= 2048) {
67
71
  try {
68
72
  const url = new URL(result.approval_url);
@@ -70,6 +74,8 @@ function statusMessage(result) {
70
74
  } catch {}
71
75
  }
72
76
  if (typeof result?.request_id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(result.request_id)) message += ` Request: ${result.request_id}.`;
77
+ if (result?.request_outcome === 'unknown') message += ' The request outcome is unknown. Check /psst-status in the original chat before retrying; the saved request must be reconciled.';
78
+ else if (result?.request_outcome === 'saved') message += ' The exact request is saved and will be reused.';
73
79
  return message;
74
80
  }
75
81
  function registerSetupUI(ctx, available) {
@@ -88,18 +94,23 @@ function registerSetupUI(ctx, available) {
88
94
  if (disposed) return;
89
95
  if (active) { toast('Finish or dismiss the current Psst dialog first.', 'warning'); return; }
90
96
  const controller = new AbortController();
97
+ const configuring = kind === 'setup' || kind === 'reauthorize';
91
98
  active = controller;
92
- let timer, check, blur;
99
+ let timer, check, blur, phase = 'selection', reason, submitted = false, received;
100
+ const abort = cause => { reason ??= cause; controller.abort(); };
93
101
  try {
94
102
  const selected = selection();
95
103
  if (!available()) throw Error('local_unavailable');
96
104
  const location = { ...ctx.location };
97
105
  check = () => {
98
106
  try { const current = selection();
99
- if (JSON.stringify(current) !== JSON.stringify(selected)) controller.abort();
100
- } catch { controller.abort(); }
107
+ if (current.sessionID !== selected.sessionID) abort('session_changed');
108
+ else if (current.directory !== selected.directory || current.workspaceID !== selected.workspaceID) abort('location_changed');
109
+ } catch { abort('selection_unavailable'); }
101
110
  };
102
- blur = () => controller.abort();
111
+ // A blur cannot prove where native confirmation input will go. Suspend
112
+ // this action, not other TUIs or the persisted enrollment request.
113
+ blur = () => abort('terminal_blur');
103
114
  ctx.renderer?.on?.('blur', blur);
104
115
  ctx.renderer?.on?.('frame', check);
105
116
  timer = setInterval(check, 50); timer.unref?.();
@@ -116,33 +127,49 @@ function registerSetupUI(ctx, available) {
116
127
  const rpc = ctx.client.rpc(SETUP_RPC);
117
128
  const call = async (method, input) => {
118
129
  verify();
130
+ phase = method === 'status' && configuring ? 'preflight' : method;
131
+ if (method === 'setup') { submitted = true; received = undefined; }
119
132
  const result = await rpc[method]({ sessionID: selected.sessionID, ...input }, { location, signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15000)]) });
133
+ received = result;
120
134
  verify(); return result;
121
135
  };
122
- if (kind === 'setup') {
123
- const value = await ctx.ui.dialog.prompt({ title: 'Set up Psst', description: 'Enter only your HTTPS broker URL. Approval happens in the broker admin UI; never paste a token or credential.', placeholder: 'https://broker.example.com' });
136
+ if (configuring) {
137
+ const configured = kind === 'reauthorize' ? await call('status', {}) : undefined;
138
+ if (['setup_owned_elsewhere', 'setup_owner_recovery_required', 'setup_blocked'].includes(configured?.state)) { toast(statusMessage(configured), 'warning'); return; }
139
+ phase = 'url_prompt';
140
+ const value = configured?.broker_url ?? await ctx.ui.dialog.prompt({ title: kind === 'reauthorize' ? 'Reauthorize Psst: broker URL' : 'Set up Psst', description: 'Enter only your HTTPS broker URL. Approval happens in the broker admin UI; never paste a token or credential.', placeholder: 'https://broker.example.com' });
124
141
  if (value === undefined) return;
125
142
  verify();
126
143
  const broker_url = brokerOrigin(value);
127
144
  const status = await call('status', { broker_url });
145
+ if (['setup_owned_elsewhere', 'setup_owner_recovery_required', 'setup_blocked'].includes(status?.state)) { toast(statusMessage(status), 'warning'); return; }
128
146
  let replacement_confirmed = false;
129
- if (status?.replacement_confirmation_required === true) {
130
- if (typeof status.request_id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(status.request_id)) throw Error('invalid_status');
131
- replacement_confirmed = await ctx.ui.dialog.confirm({ title: 'Replace Psst authorization?', message: 'This creates a new approval request and, after approval, new credential-bound addresses. Old addresses will not become the new identity. Old local data is retained. Continue?', label: { confirm: 'Create new request', cancel: 'Keep existing' } }) === true;
147
+ if (status?.replacement_confirmation_required === true || kind === 'reauthorize' && status?.state === 'setup_needed') {
148
+ if (status?.replacement_confirmation_required === true && (typeof status.request_id !== 'string' || !/^[A-Za-z0-9_-]{1,128}$/.test(status.request_id))) throw Error('invalid_status');
149
+ phase = 'replacement_confirmation';
150
+ replacement_confirmed = await ctx.ui.dialog.confirm({ title: 'Replace Psst authorization?', message: `Broker: ${broker_url}. This creates a new approval request and, after approval and safe attachment, replaces the selected installation authorization with new credential-bound addresses. Old addresses will not become the new identity. Old local data is retained. This does not revoke the old credential; manual revocation in Psst admin is a separate human action. Existing access is retained where still valid. Continue?`, label: { confirm: 'Create new request', cancel: 'Keep existing' } }) === true;
132
151
  verify();
133
152
  if (!replacement_confirmed) return;
134
153
  }
135
- const result = await call('setup', { broker_url, ...(replacement_confirmed ? { replacement_confirmed: true, replacement_request_id: status.request_id } : {}) });
154
+ const result = await call('setup', { broker_url,
155
+ ...(Object.hasOwn(status ?? {}, 'selection_revision') ? { selection_revision: status.selection_revision } : {}),
156
+ ...(replacement_confirmed && status?.replacement_confirmation_required === true ? { replacement_confirmed: true, replacement_request_id: status.request_id } : {}) });
136
157
  toast(statusMessage(result));
137
158
  } else toast(statusMessage(await call(kind, {})));
138
159
  } catch (error) {
139
- const message = error?.message === 'invalid_url'
160
+ let message = error?.message === 'invalid_url'
140
161
  ? 'Enter an HTTPS broker origin only, without credentials, path, query or fragment.'
141
162
  : controller.signal.aborted || error?.message === 'selection_changed'
142
- ? 'Psst action stopped because the selected chat or terminal focus changed. Run it again in the intended chat.'
163
+ ? 'Psst action suspended. Return focus to the original chat, dismiss the old dialog, then use /psst-status. Reopen /psst-setup there if needed; confirmations must be given again. Other chats can stay open.'
143
164
  : error?.message === 'local_unavailable'
144
165
  ? 'Setup requires a local TUI sharing the server private directory.'
145
166
  : 'Psst action could not complete. Check /psst-status before retrying; do not paste credentials.';
167
+ const outcome = submitted ? received?.request_outcome !== 'unknown' && (received?.request_outcome === 'saved' || phase === 'setup' && received?.request_id) ? 'saved' : 'unknown' : 'not_submitted';
168
+ message += outcome === 'not_submitted' ? ' This action did not submit a setup request.'
169
+ : outcome === 'saved' ? ' The exact request was saved; check its status rather than creating another.'
170
+ : ' Setup may already have saved a request; its outcome is unknown. Retry only in the original chat to reconcile it.';
171
+ // Fixed enums only: no native IDs, private paths, dialog input or errors.
172
+ message += ` [phase=${phase}; cause=${reason ?? (controller.signal.aborted ? 'cancelled' : 'operation_failed')}; request=${outcome}]`;
146
173
  toast(message, 'warning');
147
174
  } finally {
148
175
  clearInterval(timer);
@@ -153,6 +180,7 @@ function registerSetupUI(ctx, available) {
153
180
  };
154
181
  ctx.keymap.layer(() => ({ mode: 'global', commands: [
155
182
  { id: 'psst-setup', title: 'Psst: Set up broker', group: 'Psst', palette: true, slash: { name: 'psst-setup' }, run: () => run('setup') },
183
+ { id: 'psst-reauthorize', title: 'Psst: Request replacement authorization', group: 'Psst', palette: true, slash: { name: 'psst-reauthorize' }, run: () => run('reauthorize') },
156
184
  { id: 'psst-status', title: 'Psst: Check status', group: 'Psst', palette: true, slash: { name: 'psst-status' }, run: () => run('status') },
157
185
  { id: 'psst-cancel-setup', title: 'Psst: Pause setup', group: 'Psst', palette: true, slash: { name: 'psst-cancel-setup' }, run: () => run('cancel') },
158
186
  ] }));
@@ -212,7 +212,8 @@ export async function setupNativePlugin({ ctx, reference, tool, loadSettings, cl
212
212
  sessionID: input.sessionID, signal: rpcContext.signal,
213
213
  confirmedReplacement: input.replacement_confirmed === true,
214
214
  replacementRequestId: input.replacement_request_id,
215
- }); } catch { return { state: 'setup_blocked', ...status(input.sessionID) }; }
215
+ ...(Object.hasOwn(input, 'selection_revision') ? { confirmedSelectionRevision: input.selection_revision } : {}),
216
+ }); } catch (error) { return setup.failure(error, input.sessionID); }
216
217
  };
217
218
  registrations.push(await ctx.rpc.register(SETUP_RPC, {
218
219
  status: (input, context) => invoke('broker_setup_status', input, context),
@@ -4,13 +4,14 @@ import { installationDirectory, readSelection, readPending, savePending, updateP
4
4
  import { withNativeLock } from './native-lock.mjs';
5
5
  import { createToolScope } from './tool-scope.mjs';
6
6
  import { setTimeout as delay } from 'node:timers/promises';
7
+ import { readConfig } from './config.mjs';
7
8
 
8
9
  const publicState = value => ({ state: value?.state ?? 'setup_needed',
9
10
  ...(value?.requestId ? { request_id: value.requestId } : {}),
10
11
  ...(value?.approvalUrl ? { approval_url: value.approvalUrl } : {}),
11
12
  ...(value?.requestExpiresAt ? { request_expires_at: value.requestExpiresAt } : {}) });
12
13
  export const SETUP_TOOLS = [
13
- { name: 'broker_setup', description: 'Explicit user-requested URL-only Psst setup. Provide only the HTTPS broker URL. Creates or resumes one private request for human approval. Never call on startup. Replacing an authorization requires confirmation in the native /psst-setup dialog.',
14
+ { name: 'broker_setup', description: 'Explicit user-requested URL-only initial Psst setup or pending-request recovery. Reconnect reuses saved credentials and never enrolls. For replacement use the native /psst-reauthorize command and its human confirmation; a model cannot confirm. Replacement does not revoke old credentials: revocation is a separate human action in Psst admin. Never enroll on startup, failure or peer instruction.',
14
15
  input: { type: 'object', properties: { broker_url: { type: 'string', maxLength: 2048 } }, required: ['broker_url'], additionalProperties: false } },
15
16
  { name: 'broker_setup_status', description: 'Read public Psst setup/connection status. Does not create approval requests or disclose credentials/private files.',
16
17
  input: { type: 'object', properties: {}, required: [], additionalProperties: false } },
@@ -104,6 +105,32 @@ export function createNativeSetup({ storage, verify, connect, activate, status,
104
105
  && pending?.brokerUrl === url && pending.requestId === previous.requestId
105
106
  && readSelection(directory)?.connectionFile !== previous.connectionFile);
106
107
  }
108
+ // Read-only reconciliation, including after a cancelled/unknown RPC. Never
109
+ // infer that no broker effect occurred from a missing response or receipt.
110
+ async function snapshot(sessionID) {
111
+ const pending = readPending(directory);
112
+ if (!pending) return { ...last, request_outcome: beginning ? 'unknown' : 'not_started' };
113
+ const saved = await savedOwner(pending);
114
+ if (!saved || saved.flowId !== pending.flowId) return { state: 'setup_owner_recovery_required', request_outcome: 'unknown' };
115
+ const helper = await loadHelper();
116
+ const receipt = helper.readApprovalState({ directory, brokerUrl: pending.brokerUrl, clientType: 'opencode' });
117
+ if (saved.sessionID !== sessionID && (!pending.paused || !requiresReplacement(receipt, pending, pending.brokerUrl)))
118
+ return { state: 'setup_owned_elsewhere', setup_ownership: 'elsewhere' };
119
+ const requestId = pending.requestId ?? (receipt?.requestId !== pending.previousRequestId ? receipt?.requestId : undefined);
120
+ if (receipt && requestId && receipt.requestId !== requestId) return { state: 'setup_owner_recovery_required', request_outcome: 'unknown' };
121
+ return { ...publicState(receipt ?? { state: 'pending', requestId }),
122
+ candidate_selected: receipt?.state === 'approved' && readSelection(directory)?.connectionFile === receipt.connectionFile,
123
+ ...(pending.paused && (!receipt || receipt.state === 'pending' || receipt.state === 'approved' && readSelection(directory)?.connectionFile !== receipt.connectionFile) ? { state: 'paused', request_state: receipt?.state ?? 'pending' } : {}),
124
+ setup_ownership: saved.sessionID === sessionID ? 'current' : 'elsewhere', request_outcome: requestId ? 'saved' : 'unknown' };
125
+ }
126
+ async function failure(error, sessionID) {
127
+ try {
128
+ const current = await snapshot(sessionID);
129
+ return { ...current, ...status(sessionID),
130
+ state: error?.message === 'setup_owner_conflict' ? 'setup_owned_elsewhere' : current.state === 'setup_owned_elsewhere' ? current.state : 'setup_blocked',
131
+ request_outcome: error?.effect === 'unknown' ? 'unknown' : current.request_outcome };
132
+ } catch { return { state: 'setup_blocked', request_outcome: 'unknown', ...status(sessionID) }; }
133
+ }
107
134
  async function invoke(name, args, context) {
108
135
  return scope.run(async operationSignal => {
109
136
  if (!(context?.signal instanceof AbortSignal) || context.signal.aborted || !await bound(context.sessionID)) throw new Error('session_mismatch');
@@ -113,17 +140,28 @@ export function createNativeSetup({ storage, verify, connect, activate, status,
113
140
  return { ...last, ...status(context.sessionID) };
114
141
  }
115
142
  if (name === 'broker_setup_status') {
116
- if (args?.broker_url !== undefined) {
117
- const url = origin(args.broker_url).href, helper = await loadHelper();
143
+ let current;
144
+ try { current = await snapshot(context.sessionID); }
145
+ catch { current = { state: 'setup_blocked', request_outcome: 'unknown' }; }
146
+ if (['setup_owned_elsewhere', 'setup_owner_recovery_required', 'setup_blocked'].includes(current.state)) return { ...current, ...status(context.sessionID) };
147
+ const selection = readSelection(directory);
148
+ const broker = args?.broker_url ?? readPending(directory)?.brokerUrl ?? (selection ? readConfig(selection.connectionFile).broker.url : undefined);
149
+ if (broker !== undefined) {
150
+ const url = origin(broker).href, helper = await loadHelper();
118
151
  const previous = helper.readApprovalState({ directory, brokerUrl: url, clientType: 'opencode' });
119
- return { ...publicState(previous), ...status(context.sessionID),
152
+ return { ...current, ...publicState(previous), ...status(context.sessionID),
153
+ ...(readPending(directory)?.brokerUrl === url && current.state === 'paused' ? { state: 'paused', request_state: previous?.state ?? current.request_state } : {}),
154
+ broker_url: origin(url).origin, selection_revision: selection?.revision ?? null,
155
+ candidate_selected: previous?.state === 'approved' && selection?.connectionFile === previous.connectionFile,
120
156
  replacement_confirmation_required: requiresReplacement(previous, readPending(directory), url) };
121
157
  }
122
- return { ...last, ...status(context.sessionID) };
158
+ return { ...current, ...status(context.sessionID), selection_revision: selection?.revision ?? null };
123
159
  }
124
160
  if (name !== 'broker_setup' || !args || Object.keys(args).join(',') !== 'broker_url') throw new Error('invalid_configuration');
125
161
  const url = origin(args.broker_url);
126
162
  const ownedPending = readPending(directory);
163
+ if (running && !beginning && owner?.sessionID === context.sessionID && ownedPending?.flowId === owner.flowId && ownedPending.brokerUrl === url.href && !ownedPending.paused)
164
+ return { ...await snapshot(context.sessionID), ...status(context.sessionID) };
127
165
  if (beginning || running || owner && owner.sessionID !== context.sessionID && ownedPending?.flowId === owner.flowId && !ownedPending.paused) throw new Error('setup_owner_conflict');
128
166
  beginning = true; beginningController = new AbortController(); beginningSessionID = context.sessionID;
129
167
  const beginController = beginningController;
@@ -135,11 +173,13 @@ export function createNativeSetup({ storage, verify, connect, activate, status,
135
173
  if (!info || priorOwner?.sessionID === context.sessionID && priorOwner.binding !== binding(info)) throw new Error('session_mismatch');
136
174
  const helper = await loadHelper(), options = { directory, brokerUrl: url.href, clientType: 'opencode' };
137
175
  const previous = helper.readApprovalState(options), selected = readSelection(directory);
176
+ if (Object.hasOwn(context, 'confirmedSelectionRevision') && context.confirmedSelectionRevision !== (selected?.revision ?? null)) throw new Error('setup_selection_changed');
138
177
  // An approved but uncommitted candidate is resumed, never replaced. This
139
178
  // also reconciles a crash after private receipt write or failed attachment.
140
179
  const resumeApproved = previous?.state === 'approved' && activePending?.brokerUrl === url.href
141
180
  && activePending.requestId === previous.requestId && selected?.connectionFile !== previous.connectionFile;
142
181
  const replace = previous && previous.state !== 'pending' && !resumeApproved;
182
+ if (activePending && priorOwner?.sessionID !== context.sessionID && (!replace || activePending.brokerUrl !== url.href)) throw new Error('setup_owner_conflict');
143
183
  if (replace && (context.confirmedReplacement !== true || context.replacementRequestId !== previous.requestId)) return { ...publicState(previous), ...status(context.sessionID), replacement_confirmation_required: true };
144
184
  if (beginController.signal.aborted || operationSignal.aborted) throw new Error('setup_cancelled');
145
185
  const sameRequest = !replace && previous?.requestId && activePending?.brokerUrl === url.href && activePending.requestId === previous.requestId;
@@ -165,11 +205,12 @@ export function createNativeSetup({ storage, verify, connect, activate, status,
165
205
  if (!replace && previous?.requestId && result.requestId !== previous.requestId) throw new Error('approval_request_mismatch');
166
206
  updatePending({ directory, flowId: pending.flowId, requestId: result.requestId });
167
207
  const current = { ...pending, requestId: result.requestId };
168
- if (!await bound(context.sessionID) || cancelled()) { await pauseOwned(); return { state: 'paused', request_id: result.requestId }; }
208
+ if (!await bound(context.sessionID) || cancelled()) { await pauseOwned(); return { state: 'paused', request_id: result.requestId, request_outcome: 'saved', setup_ownership: 'current' }; }
169
209
  try { await finish(result, current, context.sessionID, operationSignal, flowController); }
170
210
  catch (error) { if (!error.retryable) block(current); throw error; }
171
211
  if (result.state === 'pending') watch(current, context.sessionID);
172
- return { ...publicState(result), ...status(context.sessionID) };
212
+ return { ...publicState(result), request_outcome: 'saved', setup_ownership: 'current',
213
+ candidate_selected: result.state === 'approved' && readSelection(directory)?.connectionFile === result.connectionFile, ...status(context.sessionID) };
173
214
  }); } finally { beginning = false; beginningController = undefined; beginningSessionID = undefined; }
174
215
  }, context?.signal);
175
216
  }
@@ -200,7 +241,7 @@ export function createNativeSetup({ storage, verify, connect, activate, status,
200
241
  if (!pending.paused && pending.requestId && await bound(saved.sessionID)) watch(pending, saved.sessionID);
201
242
  } catch { last = { state: 'setup_blocked' }; throw new Error('setup_recovery_blocked'); }
202
243
  }
203
- return { invoke, resume, event(event) {
244
+ return { invoke, resume, failure, event(event) {
204
245
  if (owner?.sessionID === event?.data?.sessionID && ['session.deleted', 'session.archived', 'session.moved'].includes(event.type)) {
205
246
  ownerController.abort(); void pauseOwned().catch(() => {});
206
247
  }
package/src/setup-rpc.mjs CHANGED
@@ -8,7 +8,7 @@ export const SETUP_RPC = {
8
8
  id: 'matuto.psst.setup',
9
9
  methods: {
10
10
  status: { input: input({ broker_url: brokerURL }), output },
11
- setup: { input: input({ broker_url: brokerURL, replacement_confirmed: { type: 'boolean' }, replacement_request_id: { type: 'string', minLength: 1, maxLength: 128 } }, ['broker_url']), output },
11
+ setup: { input: input({ broker_url: brokerURL, replacement_confirmed: { type: 'boolean' }, replacement_request_id: { type: 'string', minLength: 1, maxLength: 128 }, selection_revision: { type: ['string', 'null'], maxLength: 128 } }, ['broker_url']), output },
12
12
  cancel: { input: input(), output },
13
13
  },
14
14
  events: {},