@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 +5 -2
- package/README.md +29 -4
- package/docs/migration-0.6.0.md +3 -3
- package/docs/publishing.md +2 -2
- package/docs/setup-acceptance.md +1 -1
- package/docs/setup-context-recovery.md +55 -0
- package/package.json +1 -1
- package/plugin/tui.mjs +45 -17
- package/src/native-plugin.mjs +2 -1
- package/src/native-setup.mjs +49 -8
- package/src/setup-rpc.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
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`.
|
package/docs/migration-0.6.0.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
# 0.
|
|
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.
|
|
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.
|
|
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
|
package/docs/publishing.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
package/docs/setup-acceptance.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Install, configure, use
|
|
4
4
|
|
|
5
|
-
The scoped 0.
|
|
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
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-
|
|
44
|
-
expired: 'The approval request expired. A new request requires confirmation in /psst-
|
|
45
|
-
revoked: 'The authorization was revoked. A new request requires confirmation in /psst-
|
|
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-
|
|
65
|
-
if (result?.authorization_state === 'rejected') message = 'The broker rejected the current authorization. Use /psst-
|
|
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 (
|
|
100
|
-
|
|
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
|
|
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 (
|
|
123
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
] }));
|
package/src/native-plugin.mjs
CHANGED
|
@@ -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
|
-
|
|
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),
|
package/src/native-setup.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
117
|
-
|
|
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 { ...
|
|
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),
|
|
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: {},
|