@senpiper/sp-agent 0.1.10 → 0.1.11
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/INSTALL.md +36 -24
- package/README.md +13 -13
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +4 -4
- package/dist/cli.js.map +1 -1
- package/dist/distribution.d.ts +2 -2
- package/dist/distribution.d.ts.map +1 -1
- package/dist/distribution.js +1 -1
- package/dist/distribution.js.map +1 -1
- package/dist/extension-identity.generated.d.ts +3 -3
- package/dist/extension-identity.generated.d.ts.map +1 -1
- package/dist/extension-identity.generated.js +3 -3
- package/dist/extension-identity.generated.js.map +1 -1
- package/dist/install-sp.sh +9 -9
- package/dist/release-manager.d.ts.map +1 -1
- package/dist/release-manager.js +2 -2
- package/dist/release-manager.js.map +1 -1
- package/dist/runtime-cli.d.ts.map +1 -1
- package/dist/runtime-cli.js +4 -4
- package/dist/runtime-cli.js.map +1 -1
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +5 -5
- package/dist/sdk.js.map +1 -1
- package/dist/session-manager.d.ts +1 -1
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +15 -15
- package/dist/session-manager.js.map +1 -1
- package/dist/sp-web.d.ts.map +1 -1
- package/dist/sp-web.js +2 -2
- package/dist/sp-web.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/node_modules/@senpiper/pi-coding-agent/dist/bundle/chunks/{chunk-LUSUF4ZH.js → chunk-VVKE4QZJ.js} +1 -1
- package/node_modules/@senpiper/pi-coding-agent/dist/bundle/cli.js +1 -1
- package/node_modules/@senpiper/pi-coding-agent/dist/bundle/index.js +1 -1
- package/node_modules/@senpiper/pi-coding-agent/dist/bundle/rpc-entry.js +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/amazon-bedrock.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/baseten.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/fireworks.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/huggingface.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/kimi-coding.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/nvidia.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode-go.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/qwen-token-plan-cn.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/qwen-token-plan.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/together.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
- package/node_modules/@senpiper/pi-coding-agent/package.json +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/CHANGELOG.md +17 -0
- package/node_modules/@senpiper/sp-agent-extensions/CONCURRENT_SESSIONS.md +9 -9
- package/node_modules/@senpiper/sp-agent-extensions/TERMINAL_WEB_BRIDGE.md +28 -9
- package/node_modules/@senpiper/sp-agent-extensions/dist/cli-commands.d.ts.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/cli-commands.js +5 -5
- package/node_modules/@senpiper/sp-agent-extensions/dist/cli-commands.js.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/gateway.d.ts +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/gateway.d.ts.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/gateway.js +18 -18
- package/node_modules/@senpiper/sp-agent-extensions/dist/gateway.js.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/index.d.ts +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/index.d.ts.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/index.js +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/index.js.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/login-command.d.ts.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/login-command.js +9 -9
- package/node_modules/@senpiper/sp-agent-extensions/dist/login-command.js.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/sp-web-launcher.d.ts +27 -0
- package/node_modules/@senpiper/sp-agent-extensions/dist/sp-web-launcher.d.ts.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/sp-web-launcher.js +270 -28
- package/node_modules/@senpiper/sp-agent-extensions/dist/sp-web-launcher.js.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/startup-header.d.ts.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/startup-header.js +12 -19
- package/node_modules/@senpiper/sp-agent-extensions/dist/startup-header.js.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/terminal-web-bridge.d.ts +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/terminal-web-bridge.d.ts.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/terminal-web-bridge.js +11 -9
- package/node_modules/@senpiper/sp-agent-extensions/dist/terminal-web-bridge.js.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/web-command.d.ts.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/dist/web-command.js +36 -10
- package/node_modules/@senpiper/sp-agent-extensions/dist/web-command.js.map +1 -1
- package/node_modules/@senpiper/sp-agent-extensions/package.json +3 -2
- package/package.json +7 -6
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@senpiper/pi-coding-agent",
|
|
3
3
|
"version": "0.84.4",
|
|
4
|
-
"description": "Senpiper build of the generic Pi coding-agent runtime seams required by
|
|
4
|
+
"description": "Senpiper build of the generic Pi coding-agent runtime seams required by Senpiper Workbench",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"piConfig": {
|
|
7
7
|
"name": "sp",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [0.1.5] - 2026-09-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added `/web on` and `/web off` to the terminal agent. `/web off` stops the managed web sidecar for the current profile and waits up to eight seconds for the process to exit. Terminal sessions, agent sessions, and Senpiper Gateway keep running, and the next `/web` or interactive launch starts the sidecar again.
|
|
10
|
+
- Added a per-launch identity token to new sidecar records. Stopping verifies the live process against the token, falls back to an exact command line and start time for older records, and fails closed when the identity cannot be read or does not match.
|
|
11
|
+
- Added an ASCII WORKBENCH banner to the interactive startup header on wide terminals. Narrow terminals show a compact WORKBENCH wordmark. Both are followed by a `by senpiper` attribution.
|
|
12
|
+
- Added argument completions for `/web on` and `/web off`, and a warning at session start when another terminal is already starting or stopping the sidecar.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- Renamed the product to Senpiper Workbench in headers, prompts, notifications, and gateway messages. The model gateway now displays as Senpiper Gateway.
|
|
17
|
+
- `/web` rejects unknown arguments with usage text and reports clearer disabled, failed, and timed-out stop errors.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Concurrent
|
|
1
|
+
# Concurrent Senpiper Workbench sessions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Senpiper Workbench supports independent concurrent sessions within one instance. Browser selection does not replace the terminal session or stop another web session.
|
|
4
4
|
|
|
5
5
|
## Wire contract
|
|
6
6
|
|
|
@@ -14,7 +14,7 @@ Send these commands to any authenticated descriptor belonging to the instance:
|
|
|
14
14
|
|
|
15
15
|
The result is returned only after the child descriptor is published in the existing discovery directory. Find it by `sessionId`, authenticate with its token, and use the existing state, transcript, subscription, prompt, steer, follow-up, abort, model, thinking, title, and question commands. Each descriptor retains its own generation and replay sequence. No envelope or descriptor format changed.
|
|
16
16
|
|
|
17
|
-
`state.capabilities.newSession` and `resumeSession` are true when the
|
|
17
|
+
`state.capabilities.newSession` and `resumeSession` are true when the Senpiper Workbench distribution supplies its instance manager. An extension loaded without that manager keeps these capabilities false.
|
|
18
18
|
|
|
19
19
|
Repeated resumes of the same file return the existing session ID, including concurrent requests and symlink aliases. Resuming the terminal's current file returns the terminal session ID without opening another writer. Requests racing a terminal acquisition fail and may be retried after it settles.
|
|
20
20
|
|
|
@@ -22,7 +22,7 @@ Browser switching is client-local. Disconnecting from B and subscribing to C lea
|
|
|
22
22
|
|
|
23
23
|
## Ownership and lifetime
|
|
24
24
|
|
|
25
|
-
`packages/sp-agent/src/sdk.ts` supplies the verified
|
|
25
|
+
`packages/sp-agent/src/sdk.ts` supplies the verified Senpiper Workbench factory. It uses the same required-extension identity, provider policy, and resource ownership checks as `createSpAgentSession`; the bridge does not recreate provider policy.
|
|
26
26
|
|
|
27
27
|
`SpSessionManager` reserves canonical file paths synchronously before opening files or awaiting construction. Failed construction releases the reservation. Hard-linked files are rejected to avoid alternate names bypassing ownership.
|
|
28
28
|
|
|
@@ -34,25 +34,25 @@ Explicit manager disposal and terminal quit stop bridges, cancel questions, abor
|
|
|
34
34
|
|
|
35
35
|
## Permanent deletion and writer leases
|
|
36
36
|
|
|
37
|
-
`capabilities.deleteSession` is true only when the native
|
|
37
|
+
`capabilities.deleteSession` is true only when the native Senpiper Workbench distribution deletion provider is active. The extension alone advertises false and rejects the command. Deletion never starts an SDK writer for inactive history.
|
|
38
38
|
|
|
39
39
|
For a managed child, deletion checks prompt preflight, active runs, queued messages, questions, compaction, retries, bash, and pending bridge commands. A busy child returns `busy`; deletion does not abort it to make it eligible. The owner gates new mutations, retains its existing file reservation, disposes only that child, and unlinks only that JSONL. Parent, descendant, sibling, and terminal sessions remain untouched. Deleting the current terminal session returns `terminal_root`. Run `/new` in that terminal first; deletion never shuts down the whole instance.
|
|
40
40
|
|
|
41
41
|
Inactive history must be inside the canonical configured history root. CLI configuration includes `--session-dir`, the session-directory environment override, and the configured settings fallback. SDK managers default to `$SP_CODING_AGENT_DIR/sessions` or `SP_CODING_AGENT_SESSION_DIR`; hosts using a different history root must call `setHistoryRoot()` explicitly. An arbitrary selected session's parent directory does not become an authorized history root. Explicit paths must be canonical absolute `.jsonl` paths to regular files with one hard link. Symlink aliases, escapes, mismatched header IDs, and ambiguous ID-only lookups are rejected. Header validation is bounded to 1 MiB and does not repair files.
|
|
42
42
|
|
|
43
|
-
The generic `MainOptions.sessionWriterLeaseProvider` hook runs before CLI session opening. The
|
|
43
|
+
The generic `MainOptions.sessionWriterLeaseProvider` hook runs before CLI session opening. The Senpiper Workbench SDK installs the same provider on import, before callers construct persistent `SessionManager`s. Passing a persistent manager constructed before Senpiper Workbench participation is rejected. New files, resume, import destinations, branch creation, and JSONL export use the provider. Each persistent manager retains its lease until disposal, not merely until resume finishes. Failed construction releases its lease. Disposed or transaction-reserved writers reject further writes.
|
|
44
44
|
|
|
45
45
|
All participating processes use exclusive, non-expiring files under `.sp-writer-leases` beside the JSONL directory. Locks are independent of the JSONL inode and canonicalize aliases. A managed deletion retains the exact existing lease through writer disposal and unlink, with no unlock/relock gap. A deletion tombstone prevents a previously selected resume path from recreating the deleted file. Normal disposal and process exit release leases. A paused process never loses its lease to a heartbeat timeout.
|
|
46
46
|
|
|
47
|
-
Crash-orphaned locks fail closed. There is no automatic stale-lock takeover. If
|
|
47
|
+
Crash-orphaned locks fail closed. There is no automatic stale-lock takeover. If a Senpiper Workbench process is killed without exit cleanup, stop all Senpiper Workbench processes using that history before an operator removes its orphan `.lock` files. Do not remove `.deleted` tombstones or live locks. This availability tradeoff prevents paused writers from resuming after deletion.
|
|
48
48
|
|
|
49
49
|
Self-deletion preserves bridge connections until its success or error response drains, while rejecting new commands. Other deletions close target sockets during shutdown. Client disconnection does not cancel deletion or release the transaction reservation early.
|
|
50
50
|
|
|
51
51
|
### Required cutover
|
|
52
52
|
|
|
53
|
-
After installing this version, **restart every
|
|
53
|
+
After installing this version, **restart every Senpiper Workbench instance**, including terminal, print/RPC, managed-session, and SDK hosts. Updating files on disk does not upgrade a running writer. The owner rejects discoverable live legacy Senpiper Workbench descriptors without `writerLeaseVersion: 1`, even when they currently select another session. PID checks are limited to these discoverable owners; deletion does not attempt to prove the absence of every OS writer.
|
|
54
54
|
|
|
55
|
-
The guarantee covers cooperating updated
|
|
55
|
+
The guarantee covers cooperating updated Senpiper Workbench writers. Arbitrary external programs, nonparticipating SDK hosts, old processes that were not restarted and are not discoverable, and direct filesystem writes are outside it. Do not use native deletion during a mixed-version cutover.
|
|
56
56
|
|
|
57
57
|
Read-only web history browsing must not acquire writer leases. Use `SessionManager.open(path, undefined, undefined, { readOnly: true })` or `openReadonly(path)`. These paths do not repair or migrate files on disk and cannot persist mutations. Live browser commands still go through the native owner. Fork and arbitrary extension UI dialogs remain outside the web protocol.
|
|
58
58
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Senpiper Workbench terminal web bridge protocol
|
|
2
2
|
|
|
3
|
-
Interactive `sp` sessions expose their live state through same-user Unix sockets. The
|
|
3
|
+
Interactive `sp` sessions expose their live state through same-user Unix sockets. The Senpiper Workbench process owns the terminal session and any independent web sessions it creates. Each has its own descriptor, `AgentSession`, and JSONL writer. A web client must send live commands through the corresponding socket, not open another writer.
|
|
4
4
|
|
|
5
5
|
## Discovery
|
|
6
6
|
|
|
@@ -40,7 +40,7 @@ A response repeats `id` and has either `ok: true` with `result`, or `ok: false`
|
|
|
40
40
|
| `state` | none | Returns session, run, model, thinking, sequence, and capability state. |
|
|
41
41
|
| `transcript` | `cursor`, `limit` | Returns up to 100 read-only session entries. |
|
|
42
42
|
| `models` | none | Returns the models available within the terminal session's model scope. |
|
|
43
|
-
| `generate_title` | none | Generates an English title with the active
|
|
43
|
+
| `generate_title` | none | Generates an English title with the active Senpiper Gateway model and applies it through the terminal owner. |
|
|
44
44
|
| `subscribe` | `afterSequence` | Replays retained events after the sequence and subscribes to new events. Returns `event_gap` if the requested point is no longer retained. |
|
|
45
45
|
| `prompt` | `text`, optional `images`, optional `expandPromptTemplates` | Starts a turn while idle. Text may be omitted with images. |
|
|
46
46
|
| `steer` | `text`, optional `images` | Steers the active turn. Fails while idle. Text may be omitted with images. |
|
|
@@ -53,11 +53,11 @@ A response repeats `id` and has either `ok: true` with `result`, or `ok: false`
|
|
|
53
53
|
| `resume_session` | `sessionFile`, an absolute path | Acquires an independent web session or returns its existing `{ sessionId }`. Returns the terminal ID for its current file without reopening it. |
|
|
54
54
|
| `delete_session` | `sessionId`, optional canonical absolute `sessionFile`, `confirmed: true` | Permanently deletes only the selected idle managed child or inactive history file. Returns `{ sessionId, deleted: true }`. |
|
|
55
55
|
|
|
56
|
-
Creation and resume require the instance-owned factory supplied by
|
|
56
|
+
Creation and resume require the instance-owned factory supplied by Senpiper Workbench. `state.capabilities.newSession` and `resumeSession` advertise availability. Browser selection and disconnection do not stop runs. See [concurrent sessions](CONCURRENT_SESSIONS.md) for acquisition and lifetime rules. Fork, direct JSONL mutation, credential changes, and arbitrary model construction remain unsupported.
|
|
57
57
|
|
|
58
58
|
`state.capabilities.deleteSession` is true only with the native deletion provider. Deletion rejects busy, queued, or question-owning targets. The current terminal root returns `terminal_root`; the user must run `/new` there first. Explicit confirmation is mandatory. `writer_owned`, `legacy_owner`, `invalid_path`, `invalid_session`, and `session_deleted` describe ownership, cutover, validation, and tombstone failures. Filesystem failures can also be returned. No descendants or sibling sessions are deleted. Self-delete responses drain before their socket closes, and disconnecting does not cancel the transaction.
|
|
59
59
|
|
|
60
|
-
Restart **all
|
|
60
|
+
Restart **all Senpiper Workbench instances after installation** before using deletion. Updated writers share lifetime leases; discoverable live legacy owners are rejected. External/nonparticipating SDK and direct filesystem writers are outside the guarantee. See the required cutover and orphan-lock procedure in [concurrent sessions](CONCURRENT_SESSIONS.md).
|
|
61
61
|
|
|
62
62
|
Events use `type: "event"` and include `generation`, monotonic `sequence`, `category`, `event`, and `payload`. Categories cover session, agent, message, tool, model, and thinking lifecycle events. The replay buffer and client count are bounded. The bridge disconnects clients that exceed frame or output-buffer limits.
|
|
63
63
|
|
|
@@ -164,9 +164,19 @@ Questions stay pending across web disconnects. Read a snapshot on reconnect and
|
|
|
164
164
|
|
|
165
165
|
This protocol covers `ask_user_question`, not arbitrary extension UI prompts such as login. Oversized question events follow the existing frame limit; terminal dialogs remain available, but the web snapshot can return `frame_too_large`. The bridge does not split questions into multiple frames.
|
|
166
166
|
|
|
167
|
-
##
|
|
167
|
+
## Senpiper Workbench web sidecar
|
|
168
168
|
|
|
169
|
-
The extension starts one detached sidecar per configured
|
|
169
|
+
The extension starts one detached sidecar per configured agent directory and reuses its private PID record while that process is alive.
|
|
170
|
+
|
|
171
|
+
`/web` and `/web on` ensure that sidecar is running and open it in the browser.
|
|
172
|
+
`/web off` stops only the managed sidecar for the current profile, without
|
|
173
|
+
stopping the terminal bridges, agent sessions, or Gateway. This is not a
|
|
174
|
+
persistent disable: another interactive agent launch can start the sidecar
|
|
175
|
+
again. All browsers connected to that profile's shared server disconnect;
|
|
176
|
+
workspace shell processes owned by the web server may end. Manually launched
|
|
177
|
+
servers must be stopped in their own terminal.
|
|
178
|
+
|
|
179
|
+
Configuration:
|
|
170
180
|
|
|
171
181
|
| Variable | Meaning |
|
|
172
182
|
|---|---|
|
|
@@ -174,10 +184,19 @@ The extension starts one detached sidecar per configured SP agent directory and
|
|
|
174
184
|
| `SP_WEB_ARGS_JSON` | JSON string array of arguments. The launcher does not use a shell. |
|
|
175
185
|
| `SP_WEB_DISABLE` | `1`, `true`, or `yes` disables launch. |
|
|
176
186
|
|
|
177
|
-
Each `SP_WEB_*` setting takes precedence over its old `SP_PI_WEB_*` alias, including an explicitly empty value. Without an executable override, the launcher resolves `@senpiper/sp-web/bin/sp-web.js` from the extension module, then from the
|
|
187
|
+
Each `SP_WEB_*` setting takes precedence over its old `SP_PI_WEB_*` alias, including an explicitly empty value. Without an executable override, the launcher resolves `@senpiper/sp-web/bin/sp-web.js` from the extension module, then from the Senpiper Workbench executable location. The current source pins `@senpiper/sp-web@0.1.1` as a normal registry dependency, installed automatically by the same global Senpiper Workbench install command, not bundled in the Senpiper Workbench tarball. The resolved absolute entry runs with the current Node executable, or external `node` from PATH when Senpiper Workbench runs under Bun. Compiled Bun's executable anchor locates the npm dependency tree even when its module URL is virtual. Resolution does not use the project working directory or a global `pi-web` command.
|
|
178
188
|
|
|
179
189
|
Standalone archives without the npm package fall back to `sp-web` on PATH. Missing executables produce a warning with `SP_WEB_EXECUTABLE` setup guidance. Bun launches of the installed entry require external Node on PATH. A successful launch result records a spawned PID, not HTTP readiness.
|
|
180
190
|
|
|
181
|
-
The launcher always adds `--no-open`, sets `SP_WEB_NO_OPEN=1` and `SP_TERMINAL_BRIDGE_ONLY=1`, and passes `SP_TERMINAL_BRIDGE_DISCOVERY_DIR` and `SP_TERMINAL_BRIDGE_PROTOCOL` to the child.
|
|
191
|
+
The launcher always adds `--no-open`, sets `SP_WEB_NO_OPEN=1` and `SP_TERMINAL_BRIDGE_ONLY=1`, and passes `SP_TERMINAL_BRIDGE_DISCOVERY_DIR` and `SP_TERMINAL_BRIDGE_PROTOCOL` to the child. The Senpiper Workbench web workspace must use the authenticated descriptors for live commands, never construct another writer for the same session.
|
|
182
192
|
|
|
183
193
|
The canonical process record is `sp-web-sidecar.json`. Live records under either that name or `pi-web-sidecar.json` are reused without rewriting them or restarting their processes. The cooperative lock remains `pi-web-sidecar.lock`; new launches also mirror their private PID record to `pi-web-sidecar.json` so older launchers reuse them. Both process records are `0600` and neither represents a session writer. This launcher migration does not change writer leases, IPC authentication, question ownership, or historical JSONL.
|
|
194
|
+
|
|
195
|
+
New records include a random per-launch identity token, also passed to the
|
|
196
|
+
sidecar in `SP_WEB_SIDECAR_TOKEN`. Stop verifies the live process against that
|
|
197
|
+
token before sending `SIGTERM`; older records require an exact command-line
|
|
198
|
+
and process-start-time match. Unreadable or mismatched identity fails closed.
|
|
199
|
+
Start and stop share the cooperative lock. Stop waits up to eight seconds for
|
|
200
|
+
the wrapper to exit before removing both records; timeouts retain the records
|
|
201
|
+
and report failure. Custom executable wrappers must forward `SIGTERM` and wait
|
|
202
|
+
for their server children, as the bundled `sp-web` wrapper does.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli-commands.d.ts","sourceRoot":"","sources":["../src/cli-commands.ts"],"names":[],"mappings":"AAUA,UAAU,YAAY;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;CACf;AAWD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,gBAqB/C;AAkBD,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,oBAkCnD","sourcesContent":["import { stdin as input, stdout as output } from \"node:process\";\nimport { createInterface } from \"node:readline/promises\";\nimport {\n\tfetchSpGatewayModels,\n\tnormalizeSpGatewayBaseUrl,\n\tremoveSpGatewayLogin,\n\twriteSpGatewayLogin,\n\twriteSpGatewayState,\n} from \"./gateway.ts\";\n\ninterface LoginOptions {\n\tgatewayUrl?: string;\n\tapiKey?: string;\n\thelp?: boolean;\n}\n\nfunction printHelp() {\n\tconsole.log(`
|
|
1
|
+
{"version":3,"file":"cli-commands.d.ts","sourceRoot":"","sources":["../src/cli-commands.ts"],"names":[],"mappings":"AAUA,UAAU,YAAY;IACrB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,OAAO,CAAC;CACf;AAWD,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,gBAqB/C;AAkBD,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,oBAkCnD","sourcesContent":["import { stdin as input, stdout as output } from \"node:process\";\nimport { createInterface } from \"node:readline/promises\";\nimport {\n\tfetchSpGatewayModels,\n\tnormalizeSpGatewayBaseUrl,\n\tremoveSpGatewayLogin,\n\twriteSpGatewayLogin,\n\twriteSpGatewayState,\n} from \"./gateway.ts\";\n\ninterface LoginOptions {\n\tgatewayUrl?: string;\n\tapiKey?: string;\n\thelp?: boolean;\n}\n\nfunction printHelp() {\n\tconsole.log(`Senpiper Workbench gateway setup\n\nUsage:\n sp login [--gateway-url <url>] [--api-key <key>]\n sp logout\n`);\n}\n\nexport function parseLoginOptions(args: string[]) {\n\tconst options: LoginOptions = {};\n\tfor (let index = 0; index < args.length; index++) {\n\t\tconst argument = args[index];\n\t\tif (argument === \"-h\" || argument === \"--help\") {\n\t\t\toptions.help = true;\n\t\t\tcontinue;\n\t\t}\n\t\tif (argument === \"--gateway-url\") {\n\t\t\toptions.gatewayUrl = args[++index];\n\t\t\tif (!options.gatewayUrl) throw new Error(\"--gateway-url requires a value\");\n\t\t\tcontinue;\n\t\t}\n\t\tif (argument === \"--api-key\") {\n\t\t\toptions.apiKey = args[++index];\n\t\t\tif (!options.apiKey) throw new Error(\"--api-key requires a value\");\n\t\t\tcontinue;\n\t\t}\n\t\tthrow new Error(`Unknown login option: ${argument}`);\n\t}\n\treturn options;\n}\n\nasync function promptForLogin(options: LoginOptions) {\n\tif (options.gatewayUrl && options.apiKey) return { gatewayUrl: options.gatewayUrl, apiKey: options.apiKey };\n\tif (!input.isTTY || !output.isTTY) {\n\t\tthrow new Error(\"Missing gateway credentials. Pass --gateway-url and --api-key in non-interactive mode.\");\n\t}\n\tconst prompt = createInterface({ input, output });\n\ttry {\n\t\treturn {\n\t\t\tgatewayUrl: options.gatewayUrl ?? (await prompt.question(\"Senpiper Gateway URL: \")),\n\t\t\tapiKey: options.apiKey ?? (await prompt.question(\"Senpiper Gateway API key: \")),\n\t\t};\n\t} finally {\n\t\tprompt.close();\n\t}\n}\n\nexport async function handleSpCommand(args: string[]) {\n\tconst [command, ...rest] = args;\n\tif (command !== \"login\" && command !== \"logout\") return false;\n\ttry {\n\t\tif (command === \"logout\") {\n\t\t\tif (rest.some((argument) => argument === \"-h\" || argument === \"--help\")) {\n\t\t\t\tprintHelp();\n\t\t\t\treturn true;\n\t\t\t}\n\t\t\tif (rest.length > 0) throw new Error(`Unknown logout option: ${rest[0]}`);\n\t\t\tremoveSpGatewayLogin();\n\t\t\tconsole.log(\"Removed stored Senpiper Gateway API key.\");\n\t\t\treturn true;\n\t\t}\n\n\t\tconst options = parseLoginOptions(rest);\n\t\tif (options.help) {\n\t\t\tprintHelp();\n\t\t\treturn true;\n\t\t}\n\t\tconst { gatewayUrl, apiKey } = await promptForLogin(options);\n\t\tconst baseUrl = normalizeSpGatewayBaseUrl(gatewayUrl);\n\t\tconst models = await fetchSpGatewayModels(baseUrl, apiKey);\n\t\twriteSpGatewayState(baseUrl, models);\n\t\twriteSpGatewayLogin(apiKey, models[0]!.id);\n\t\tconsole.log(`Connected to Senpiper Gateway at ${baseUrl}`);\n\t\tconsole.log(`Synced ${models.length} model${models.length === 1 ? \"\" : \"s\"}.`);\n\t\tconsole.log(`Selected model: ${models[0]!.id}`);\n\t\treturn true;\n\t} catch (error) {\n\t\tconsole.error(`Error: ${error instanceof Error ? error.message : String(error)}`);\n\t\tprocess.exitCode = 1;\n\t\treturn true;\n\t}\n}\n"]}
|
|
@@ -2,7 +2,7 @@ import { stdin as input, stdout as output } from "node:process";
|
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import { fetchSpGatewayModels, normalizeSpGatewayBaseUrl, removeSpGatewayLogin, writeSpGatewayLogin, writeSpGatewayState, } from "./gateway.js";
|
|
4
4
|
function printHelp() {
|
|
5
|
-
console.log(`
|
|
5
|
+
console.log(`Senpiper Workbench gateway setup
|
|
6
6
|
|
|
7
7
|
Usage:
|
|
8
8
|
sp login [--gateway-url <url>] [--api-key <key>]
|
|
@@ -42,8 +42,8 @@ async function promptForLogin(options) {
|
|
|
42
42
|
const prompt = createInterface({ input, output });
|
|
43
43
|
try {
|
|
44
44
|
return {
|
|
45
|
-
gatewayUrl: options.gatewayUrl ?? (await prompt.question("
|
|
46
|
-
apiKey: options.apiKey ?? (await prompt.question("
|
|
45
|
+
gatewayUrl: options.gatewayUrl ?? (await prompt.question("Senpiper Gateway URL: ")),
|
|
46
|
+
apiKey: options.apiKey ?? (await prompt.question("Senpiper Gateway API key: ")),
|
|
47
47
|
};
|
|
48
48
|
}
|
|
49
49
|
finally {
|
|
@@ -63,7 +63,7 @@ export async function handleSpCommand(args) {
|
|
|
63
63
|
if (rest.length > 0)
|
|
64
64
|
throw new Error(`Unknown logout option: ${rest[0]}`);
|
|
65
65
|
removeSpGatewayLogin();
|
|
66
|
-
console.log("Removed stored
|
|
66
|
+
console.log("Removed stored Senpiper Gateway API key.");
|
|
67
67
|
return true;
|
|
68
68
|
}
|
|
69
69
|
const options = parseLoginOptions(rest);
|
|
@@ -76,7 +76,7 @@ export async function handleSpCommand(args) {
|
|
|
76
76
|
const models = await fetchSpGatewayModels(baseUrl, apiKey);
|
|
77
77
|
writeSpGatewayState(baseUrl, models);
|
|
78
78
|
writeSpGatewayLogin(apiKey, models[0].id);
|
|
79
|
-
console.log(`Connected to
|
|
79
|
+
console.log(`Connected to Senpiper Gateway at ${baseUrl}`);
|
|
80
80
|
console.log(`Synced ${models.length} model${models.length === 1 ? "" : "s"}.`);
|
|
81
81
|
console.log(`Selected model: ${models[0].id}`);
|
|
82
82
|
return true;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli-commands.js","sourceRoot":"","sources":["../src/cli-commands.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,IAAI,KAAK,EAAE,MAAM,IAAI,MAAM,EAAE,MAAM,cAAc,CAAC;AAChE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EACN,oBAAoB,EACpB,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACnB,mBAAmB,GACnB,MAAM,cAAc,CAAC;AAQtB,SAAS,SAAS,GAAG;IACpB,OAAO,CAAC,GAAG,CAAC;;;;;CAKZ,CAAC,CAAC;AAAA,CACF;AAED,MAAM,UAAU,iBAAiB,CAAC,IAAc,EAAE;IACjD,MAAM,OAAO,GAAiB,EAAE,CAAC;IACjC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC;QAClD,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;QAC7B,IAAI,QAAQ,KAAK,IAAI,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAChD,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;YACpB,SAAS;QACV,CAAC;QACD,IAAI,QAAQ,KAAK,eAAe,EAAE,CAAC;YAClC,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,EAAE,KAAK,CAAC,CAAC;YACnC,IAAI,CAAC,OAAO,CAAC,UAAU;gBAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;YAC3E,SAAS;QACV,CAAC;QACD,IAAI,QAAQ,KAAK,WAAW,EAAE,CAAC;YAC9B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,KAAK,CAAC,CAAC;YAC/B,IAAI,CAAC,OAAO,CAAC,MAAM;gBAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAAC;YACnE,SAAS;QACV,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,yBAAyB,QAAQ,EAAE,CAAC,CAAC;IACtD,CAAC;IACD,OAAO,OAAO,CAAC;AAAA,CACf;AAED,KAAK,UAAU,cAAc,CAAC,OAAqB,EAAE;IACpD,IAAI,OAAO,CAAC,UAAU,IAAI,OAAO,CAAC,MAAM;QAAE,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC;IAC5G,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CAAC,wFAAwF,CAAC,CAAC;IAC3G,CAAC;IACD,MAAM,MAAM,GAAG,eAAe,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;IAClD,IAAI,CAAC;QACJ,OAAO;YACN,UAAU,EAAE,OAAO,CAAC,UAAU,IAAI,CAAC,MAAM,MAAM,CAAC,QAAQ,CAAC,
|
|
1
|
+
{"version":3,"file":"cli-commands.js","sourceRoot":"","sources":["../src/cli-commands.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,IAAI,KAAK,EAAE,MAAM,IAAI,MAAM,EAAE,MAAM,cAAc,CAAC;AAChE,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,OAAO,EACN,oBAAoB,EACpB,yBAAyB,EACzB,oBAAoB,EACpB,mBAAmB,EACnB,mBAAmB,GACnB,MAAM,cAAc,CAAC;AAQtB,SAAS,SAAS,GAAG;IACpB,OAAO,CAAC,GAAG,CAAC;;;;;CAKZ,CAAC,CAAC;AAAA,CACF;AAED,MAAM,UAAU,iBAAiB,CAAC,IAAc,EAAE;IACjD,MAAM,OAAO,GAAiB,EAAE,CAAC;IACjC,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,GAAG,IAAI,CAAC,MAAM,EAAE,KAAK,EAAE,EAAE,CAAC;QAClD,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC;QAC7B,IAAI,QAAQ,KAAK,IAAI,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAChD,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC;YACpB,SAAS;QACV,CAAC;QACD,IAAI,QAAQ,KAAK,eAAe,EAAE,CAAC;YAClC,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,EAAE,KAAK,CAAC,CAAC;YACnC,IAAI,CAAC,OAAO,CAAC,UAAU;gBAAE,MAAM,IAAI,KAAK,CAAC,gCAAgC,CAAC,CAAC;YAC3E,SAAS;QACV,CAAC;QACD,IAAI,QAAQ,KAAK,WAAW,EAAE,CAAC;YAC9B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,EAAE,KAAK,CAAC,CAAC;YAC/B,IAAI,CAAC,OAAO,CAAC,MAAM;gBAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,CAAC,CAAC;YACnE,SAAS;QACV,CAAC;QACD,MAAM,IAAI,KAAK,CAAC,yBAAyB,QAAQ,EAAE,CAAC,CAAC;IACtD,CAAC;IACD,OAAO,OAAO,CAAC;AAAA,CACf;AAED,KAAK,UAAU,cAAc,CAAC,OAAqB,EAAE;IACpD,IAAI,OAAO,CAAC,UAAU,IAAI,OAAO,CAAC,MAAM;QAAE,OAAO,EAAE,UAAU,EAAE,OAAO,CAAC,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,CAAC;IAC5G,IAAI,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CAAC,wFAAwF,CAAC,CAAC;IAC3G,CAAC;IACD,MAAM,MAAM,GAAG,eAAe,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,CAAC,CAAC;IAClD,IAAI,CAAC;QACJ,OAAO;YACN,UAAU,EAAE,OAAO,CAAC,UAAU,IAAI,CAAC,MAAM,MAAM,CAAC,QAAQ,CAAC,wBAAwB,CAAC,CAAC;YACnF,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,CAAC,MAAM,MAAM,CAAC,QAAQ,CAAC,4BAA4B,CAAC,CAAC;SAC/E,CAAC;IACH,CAAC;YAAS,CAAC;QACV,MAAM,CAAC,KAAK,EAAE,CAAC;IAChB,CAAC;AAAA,CACD;AAED,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAc,EAAE;IACrD,MAAM,CAAC,OAAO,EAAE,GAAG,IAAI,CAAC,GAAG,IAAI,CAAC;IAChC,IAAI,OAAO,KAAK,OAAO,IAAI,OAAO,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC9D,IAAI,CAAC;QACJ,IAAI,OAAO,KAAK,QAAQ,EAAE,CAAC;YAC1B,IAAI,IAAI,CAAC,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,KAAK,IAAI,IAAI,QAAQ,KAAK,QAAQ,CAAC,EAAE,CAAC;gBACzE,SAAS,EAAE,CAAC;gBACZ,OAAO,IAAI,CAAC;YACb,CAAC;YACD,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC;gBAAE,MAAM,IAAI,KAAK,CAAC,0BAA0B,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;YAC1E,oBAAoB,EAAE,CAAC;YACvB,OAAO,CAAC,GAAG,CAAC,0CAA0C,CAAC,CAAC;YACxD,OAAO,IAAI,CAAC;QACb,CAAC;QAED,MAAM,OAAO,GAAG,iBAAiB,CAAC,IAAI,CAAC,CAAC;QACxC,IAAI,OAAO,CAAC,IAAI,EAAE,CAAC;YAClB,SAAS,EAAE,CAAC;YACZ,OAAO,IAAI,CAAC;QACb,CAAC;QACD,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,MAAM,cAAc,CAAC,OAAO,CAAC,CAAC;QAC7D,MAAM,OAAO,GAAG,yBAAyB,CAAC,UAAU,CAAC,CAAC;QACtD,MAAM,MAAM,GAAG,MAAM,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QAC3D,mBAAmB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;QACrC,mBAAmB,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAE,CAAC,EAAE,CAAC,CAAC;QAC3C,OAAO,CAAC,GAAG,CAAC,oCAAoC,OAAO,EAAE,CAAC,CAAC;QAC3D,OAAO,CAAC,GAAG,CAAC,UAAU,MAAM,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC;QAC/E,OAAO,CAAC,GAAG,CAAC,mBAAmB,MAAM,CAAC,CAAC,CAAE,CAAC,EAAE,EAAE,CAAC,CAAC;QAChD,OAAO,IAAI,CAAC;IACb,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QAChB,OAAO,CAAC,KAAK,CAAC,UAAU,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;QAClF,OAAO,CAAC,QAAQ,GAAG,CAAC,CAAC;QACrB,OAAO,IAAI,CAAC;IACb,CAAC;AAAA,CACD","sourcesContent":["import { stdin as input, stdout as output } from \"node:process\";\nimport { createInterface } from \"node:readline/promises\";\nimport {\n\tfetchSpGatewayModels,\n\tnormalizeSpGatewayBaseUrl,\n\tremoveSpGatewayLogin,\n\twriteSpGatewayLogin,\n\twriteSpGatewayState,\n} from \"./gateway.ts\";\n\ninterface LoginOptions {\n\tgatewayUrl?: string;\n\tapiKey?: string;\n\thelp?: boolean;\n}\n\nfunction printHelp() {\n\tconsole.log(`Senpiper Workbench gateway setup\n\nUsage:\n sp login [--gateway-url <url>] [--api-key <key>]\n sp logout\n`);\n}\n\nexport function parseLoginOptions(args: string[]) {\n\tconst options: LoginOptions = {};\n\tfor (let index = 0; index < args.length; index++) {\n\t\tconst argument = args[index];\n\t\tif (argument === \"-h\" || argument === \"--help\") {\n\t\t\toptions.help = true;\n\t\t\tcontinue;\n\t\t}\n\t\tif (argument === \"--gateway-url\") {\n\t\t\toptions.gatewayUrl = args[++index];\n\t\t\tif (!options.gatewayUrl) throw new Error(\"--gateway-url requires a value\");\n\t\t\tcontinue;\n\t\t}\n\t\tif (argument === \"--api-key\") {\n\t\t\toptions.apiKey = args[++index];\n\t\t\tif (!options.apiKey) throw new Error(\"--api-key requires a value\");\n\t\t\tcontinue;\n\t\t}\n\t\tthrow new Error(`Unknown login option: ${argument}`);\n\t}\n\treturn options;\n}\n\nasync function promptForLogin(options: LoginOptions) {\n\tif (options.gatewayUrl && options.apiKey) return { gatewayUrl: options.gatewayUrl, apiKey: options.apiKey };\n\tif (!input.isTTY || !output.isTTY) {\n\t\tthrow new Error(\"Missing gateway credentials. Pass --gateway-url and --api-key in non-interactive mode.\");\n\t}\n\tconst prompt = createInterface({ input, output });\n\ttry {\n\t\treturn {\n\t\t\tgatewayUrl: options.gatewayUrl ?? (await prompt.question(\"Senpiper Gateway URL: \")),\n\t\t\tapiKey: options.apiKey ?? (await prompt.question(\"Senpiper Gateway API key: \")),\n\t\t};\n\t} finally {\n\t\tprompt.close();\n\t}\n}\n\nexport async function handleSpCommand(args: string[]) {\n\tconst [command, ...rest] = args;\n\tif (command !== \"login\" && command !== \"logout\") return false;\n\ttry {\n\t\tif (command === \"logout\") {\n\t\t\tif (rest.some((argument) => argument === \"-h\" || argument === \"--help\")) {\n\t\t\t\tprintHelp();\n\t\t\t\treturn true;\n\t\t\t}\n\t\t\tif (rest.length > 0) throw new Error(`Unknown logout option: ${rest[0]}`);\n\t\t\tremoveSpGatewayLogin();\n\t\t\tconsole.log(\"Removed stored Senpiper Gateway API key.\");\n\t\t\treturn true;\n\t\t}\n\n\t\tconst options = parseLoginOptions(rest);\n\t\tif (options.help) {\n\t\t\tprintHelp();\n\t\t\treturn true;\n\t\t}\n\t\tconst { gatewayUrl, apiKey } = await promptForLogin(options);\n\t\tconst baseUrl = normalizeSpGatewayBaseUrl(gatewayUrl);\n\t\tconst models = await fetchSpGatewayModels(baseUrl, apiKey);\n\t\twriteSpGatewayState(baseUrl, models);\n\t\twriteSpGatewayLogin(apiKey, models[0]!.id);\n\t\tconsole.log(`Connected to Senpiper Gateway at ${baseUrl}`);\n\t\tconsole.log(`Synced ${models.length} model${models.length === 1 ? \"\" : \"s\"}.`);\n\t\tconsole.log(`Selected model: ${models[0]!.id}`);\n\t\treturn true;\n\t} catch (error) {\n\t\tconsole.error(`Error: ${error instanceof Error ? error.message : String(error)}`);\n\t\tprocess.exitCode = 1;\n\t\treturn true;\n\t}\n}\n"]}
|
|
@@ -3,7 +3,7 @@ export type SpExtensionApi = ExtensionAPI;
|
|
|
3
3
|
export type SpProviderModelConfig = ProviderModelConfig;
|
|
4
4
|
export declare const SP_GATEWAY_PROVIDER_ID = "sp-gateway";
|
|
5
5
|
export declare const LEGACY_SP_GATEWAY_PROVIDER_ID = "sengateway";
|
|
6
|
-
export declare const SP_GATEWAY_PROVIDER_NAME = "
|
|
6
|
+
export declare const SP_GATEWAY_PROVIDER_NAME = "Senpiper Gateway";
|
|
7
7
|
export declare const SP_USER_AGENT = "sp";
|
|
8
8
|
export declare const SP_CLIENT_NAME = "sp-agent";
|
|
9
9
|
export interface SpGatewayState {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAGrH,MAAM,MAAM,cAAc,GAAG,YAAY,CAAC;AAC1C,MAAM,MAAM,qBAAqB,GAAG,mBAAmB,CAAC;AAExD,eAAO,MAAM,sBAAsB,eAAe,CAAC;AACnD,eAAO,MAAM,6BAA6B,eAAe,CAAC;AAC1D,eAAO,MAAM,wBAAwB,eAAe,CAAC;AACrD,eAAO,MAAM,aAAa,OAAO,CAAC;AAClC,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC,MAAM,WAAW,cAAc;IAC9B,aAAa,EAAE,CAAC,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,qBAAqB,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,gBAAgB,GAAG,uBAAuB,CAAC;IACjD,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;CACX;AAgCD,wBAAgB,aAAa,WAE5B;AAED,wBAAgB,qBAAqB,CAAC,QAAQ,SAAkB,UAE/D;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,SAAkB,UAE7D;AAED,wBAAgB,aAAa,CAAC,QAAQ,SAAkB,UAEvD;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,SAAkB,UAE3D;AAoBD,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,MAAM,UAkBtD;AA4GD,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,kCAoB/F;AAiBD,wBAAgB,kBAAkB,CAAC,QAAQ,SAAkB,GAAG,cAAc,GAAG,SAAS,CAsBzF;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,qBAAqB,EAAE,EAAE,QAAQ,SAAkB,QAO/G;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,SAAkB,sBAa7D;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,SAAkB,QAYnG;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,SAAkB,QAM9D;AAeD,wBAAgB,mBAAmB,iCAElC;AAED,wBAAgB,qBAAqB,SAEpC;AAED,wBAAgB,6BAA6B,CAC5C,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,qBAAqB,EAAE,EAC/B,QAAQ,SAAkB,GACxB,cAAc,CAsChB;AAED,wBAAgB,wBAAwB,IAAI,gBAAgB,CAuC3D","sourcesContent":["import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport type { ExtensionAPI, ExtensionFactory, ProviderConfig, ProviderModelConfig } from \"@senpiper/pi-coding-agent\";\nimport type { Api, Model, OpenAICompletionsCompat, RefreshModelsContext } from \"@senpiper/pi-coding-agent/runtime-deps\";\n\nexport type SpExtensionApi = ExtensionAPI;\nexport type SpProviderModelConfig = ProviderModelConfig;\n\nexport const SP_GATEWAY_PROVIDER_ID = \"sp-gateway\";\nexport const LEGACY_SP_GATEWAY_PROVIDER_ID = \"sengateway\";\nexport const SP_GATEWAY_PROVIDER_NAME = \"sp-gateway\";\nexport const SP_USER_AGENT = \"sp\";\nexport const SP_CLIENT_NAME = \"sp-agent\";\n\nexport interface SpGatewayState {\n\tschemaVersion: 1;\n\tbaseUrl: string;\n\tmodels: SpProviderModelConfig[];\n}\n\nexport interface SpGatewayWarning {\n\tkind: \"refresh_failed\" | \"legacy_cache_imported\";\n\tmessage: string;\n\tat: string;\n}\n\ntype JsonRecord = Record<string, unknown>;\n\nlet gatewayWarning: SpGatewayWarning | undefined;\n\nfunction isRecord(value: unknown): value is JsonRecord {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction stringValue(value: unknown) {\n\treturn typeof value === \"string\" && value.trim() ? value.trim() : undefined;\n}\n\nfunction finiteNumber(value: unknown) {\n\treturn typeof value === \"number\" && Number.isFinite(value) ? value : undefined;\n}\n\nfunction positiveInteger(value: unknown) {\n\tconst number = finiteNumber(value);\n\treturn number !== undefined && number > 0 ? Math.floor(number) : undefined;\n}\n\nfunction nonNegativeNumber(value: unknown) {\n\tconst number = finiteNumber(value);\n\treturn number !== undefined && number >= 0 ? number : undefined;\n}\n\nfunction booleanValue(value: unknown) {\n\treturn typeof value === \"boolean\" ? value : undefined;\n}\n\nexport function getSpAgentDir() {\n\treturn process.env.SP_CODING_AGENT_DIR || join(homedir(), \".sp\", \"agent\");\n}\n\nexport function getSpGatewayStatePath(agentDir = getSpAgentDir()) {\n\treturn join(agentDir, \"sp-gateway.json\");\n}\n\nexport function getLegacyModelsPath(agentDir = getSpAgentDir()) {\n\treturn join(agentDir, \"models.json\");\n}\n\nexport function getSpAuthPath(agentDir = getSpAgentDir()) {\n\treturn join(agentDir, \"auth.json\");\n}\n\nexport function getSpSettingsPath(agentDir = getSpAgentDir()) {\n\treturn join(agentDir, \"settings.json\");\n}\n\nfunction readJsonFile(path: string): JsonRecord {\n\tif (!existsSync(path)) return {};\n\tconst content = readFileSync(path, \"utf8\").trim();\n\tif (!content) return {};\n\tconst parsed = JSON.parse(content) as unknown;\n\tif (!isRecord(parsed)) throw new Error(`Expected an object in ${path}`);\n\treturn parsed;\n}\n\nfunction writePrivateJson(path: string, value: unknown) {\n\tmkdirSync(dirname(path), { recursive: true });\n\tconst temporaryPath = `${path}.${process.pid}.tmp`;\n\twriteFileSync(temporaryPath, `${JSON.stringify(value, null, \"\\t\")}\\n`, { encoding: \"utf8\", mode: 0o600 });\n\tif (process.platform !== \"win32\") chmodSync(temporaryPath, 0o600);\n\trenameSync(temporaryPath, path);\n\tif (process.platform !== \"win32\") chmodSync(path, 0o600);\n}\n\nexport function normalizeSpGatewayBaseUrl(input: string) {\n\tconst trimmed = input.trim();\n\tif (!trimmed) throw new Error(\"Gateway URL cannot be empty.\");\n\tconst withScheme = /^[a-z][a-z0-9+.-]*:\\/\\//iu.test(trimmed) ? trimmed : `https://${trimmed}`;\n\tlet url: URL;\n\ttry {\n\t\turl = new URL(withScheme);\n\t} catch {\n\t\tthrow new Error(`Invalid gateway URL: ${input}`);\n\t}\n\tif (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n\t\tthrow new Error(\"Gateway URL must use HTTPS or HTTP.\");\n\t}\n\turl.hash = \"\";\n\turl.search = \"\";\n\turl.pathname = url.pathname.replace(/\\/+$/u, \"\");\n\tif (!url.pathname.endsWith(\"/v1\")) url.pathname = `${url.pathname}/v1`.replace(/\\/+/gu, \"/\");\n\treturn url.toString().replace(/\\/$/u, \"\");\n}\n\nfunction parseInput(value: unknown): (\"text\" | \"image\")[] | undefined {\n\tif (!Array.isArray(value)) return undefined;\n\tconst input = value.filter((entry): entry is \"text\" | \"image\" => entry === \"text\" || entry === \"image\");\n\treturn input.length > 0 ? [...new Set(input)] : undefined;\n}\n\nfunction parseCost(value: unknown): SpProviderModelConfig[\"cost\"] | undefined {\n\tif (!isRecord(value)) return undefined;\n\tconst input = nonNegativeNumber(value.input);\n\tconst output = nonNegativeNumber(value.output);\n\tconst cacheRead = nonNegativeNumber(value.cacheRead);\n\tconst cacheWrite = nonNegativeNumber(value.cacheWrite);\n\tif (input === undefined && output === undefined && cacheRead === undefined && cacheWrite === undefined)\n\t\treturn undefined;\n\treturn { input: input ?? 0, output: output ?? 0, cacheRead: cacheRead ?? 0, cacheWrite: cacheWrite ?? 0 };\n}\n\nfunction parseThinkingLevelMap(value: unknown): SpProviderModelConfig[\"thinkingLevelMap\"] {\n\tif (!isRecord(value)) return undefined;\n\tconst result: NonNullable<SpProviderModelConfig[\"thinkingLevelMap\"]> = {};\n\tfor (const key of [\"off\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const) {\n\t\tconst entry = value[key];\n\t\tif (typeof entry === \"string\" || entry === null) result[key] = entry;\n\t}\n\treturn Object.keys(result).length > 0 ? result : undefined;\n}\n\nfunction thinkingFormat(value: unknown): OpenAICompletionsCompat[\"thinkingFormat\"] {\n\tswitch (value) {\n\t\tcase \"openai\":\n\t\tcase \"openrouter\":\n\t\tcase \"deepseek\":\n\t\tcase \"together\":\n\t\tcase \"zai\":\n\t\tcase \"qwen\":\n\t\tcase \"chat-template\":\n\t\tcase \"qwen-chat-template\":\n\t\tcase \"string-thinking\":\n\t\tcase \"ant-ling\":\n\t\t\treturn value;\n\t\tdefault:\n\t\t\treturn undefined;\n\t}\n}\n\nfunction sessionAffinityFormat(value: unknown): OpenAICompletionsCompat[\"sessionAffinityFormat\"] {\n\treturn value === \"openai\" || value === \"openai-nosession\" || value === \"openrouter\" ? value : undefined;\n}\n\nfunction parseCompat(value: unknown): OpenAICompletionsCompat {\n\tconst record = isRecord(value) ? value : {};\n\tconst maxTokensField =\n\t\trecord.maxTokensField === \"max_completion_tokens\" || record.maxTokensField === \"max_tokens\"\n\t\t\t? record.maxTokensField\n\t\t\t: undefined;\n\tconst compat = {\n\t\tsupportsStore: booleanValue(record.supportsStore) ?? false,\n\t\tsupportsDeveloperRole: booleanValue(record.supportsDeveloperRole),\n\t\tsupportsReasoningEffort: booleanValue(record.supportsReasoningEffort),\n\t\tsupportsUsageInStreaming: booleanValue(record.supportsUsageInStreaming),\n\t\tmaxTokensField,\n\t\trequiresToolResultName: booleanValue(record.requiresToolResultName),\n\t\trequiresAssistantAfterToolResult: booleanValue(record.requiresAssistantAfterToolResult),\n\t\trequiresThinkingAsText: booleanValue(record.requiresThinkingAsText),\n\t\trequiresReasoningContentOnAssistantMessages: booleanValue(record.requiresReasoningContentOnAssistantMessages),\n\t\tthinkingFormat: thinkingFormat(record.thinkingFormat),\n\t\tsupportsStrictMode: booleanValue(record.supportsStrictMode),\n\t\tsendSessionAffinityHeaders: booleanValue(record.sendSessionAffinityHeaders),\n\t\tsessionAffinityFormat: sessionAffinityFormat(record.sessionAffinityFormat),\n\t\tsupportsLongCacheRetention: booleanValue(record.supportsLongCacheRetention),\n\t} satisfies OpenAICompletionsCompat;\n\treturn compat;\n}\n\nfunction parseGatewayModel(value: unknown): SpProviderModelConfig | undefined {\n\tconst record = typeof value === \"string\" ? { id: value } : isRecord(value) ? value : undefined;\n\tif (!record) return undefined;\n\tconst id = stringValue(record.id);\n\tif (!id || id.length > 256 || /[\\u0000-\\u001f\\u007f]/u.test(id)) return undefined;\n\tconst metadata = isRecord(record.metadata) ? record.metadata : {};\n\tconst field = (name: string) => metadata[name] ?? record[name];\n\tconst reasoning = field(\"reasoning\");\n\treturn {\n\t\tid,\n\t\tname: stringValue(field(\"name\")) ?? id,\n\t\treasoning: typeof reasoning === \"boolean\" ? reasoning : false,\n\t\tthinkingLevelMap: parseThinkingLevelMap(field(\"thinkingLevelMap\")),\n\t\tinput: parseInput(field(\"input\")) ?? [\"text\"],\n\t\tcost: parseCost(field(\"cost\")) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcontextWindow: positiveInteger(field(\"contextWindow\")) ?? 128_000,\n\t\tmaxTokens: positiveInteger(field(\"maxTokens\")) ?? 32_000,\n\t\tcompat: parseCompat(field(\"compat\")),\n\t};\n}\n\nfunction parseModelCatalog(values: readonly unknown[], source: string) {\n\tconst models = values.map(parseGatewayModel).filter((model): model is SpProviderModelConfig => model !== undefined);\n\tif (models.length === 0) throw new Error(`${source} contains no valid models.`);\n\tconst ids = new Set<string>();\n\tfor (const model of models) {\n\t\tif (ids.has(model.id)) throw new Error(`${source} contains duplicate model id ${model.id}.`);\n\t\tids.add(model.id);\n\t}\n\treturn models;\n}\n\nexport async function fetchSpGatewayModels(baseUrl: string, apiKey: string, signal?: AbortSignal) {\n\tconst response = await fetch(`${baseUrl.replace(/\\/$/u, \"\")}/models`, {\n\t\tsignal,\n\t\theaders: {\n\t\t\tAuthorization: `Bearer ${apiKey}`,\n\t\t\tAccept: \"application/json\",\n\t\t\t\"User-Agent\": SP_USER_AGENT,\n\t\t\t\"X-Senpiper-Client\": SP_CLIENT_NAME,\n\t\t},\n\t});\n\tif (!response.ok) {\n\t\tconst body = await response.text().catch(() => \"\");\n\t\tconst suffix = body.trim() ? `: ${body.trim().slice(0, 300)}` : \"\";\n\t\tthrow new Error(`sp-gateway rejected /v1/models (${response.status} ${response.statusText})${suffix}`);\n\t}\n\tconst payload = (await response.json()) as unknown;\n\tif (!isRecord(payload) || !Array.isArray(payload.data)) {\n\t\tthrow new Error(\"sp-gateway returned an invalid /v1/models envelope.\");\n\t}\n\treturn parseModelCatalog(payload.data, \"sp-gateway /v1/models response\");\n}\n\nfunction readLegacySpGatewayState(agentDir: string): SpGatewayState | undefined {\n\tconst path = getLegacyModelsPath(agentDir);\n\tif (!existsSync(path)) return undefined;\n\tconst root = readJsonFile(path);\n\tconst providers = isRecord(root.providers) ? root.providers : undefined;\n\tconst providerValue = providers?.[SP_GATEWAY_PROVIDER_ID] ?? providers?.[LEGACY_SP_GATEWAY_PROVIDER_ID];\n\tconst provider = isRecord(providerValue) ? providerValue : undefined;\n\tif (!provider || typeof provider.baseUrl !== \"string\" || !Array.isArray(provider.models)) return undefined;\n\treturn {\n\t\tschemaVersion: 1,\n\t\tbaseUrl: normalizeSpGatewayBaseUrl(provider.baseUrl),\n\t\tmodels: parseModelCatalog(provider.models, `legacy sp-gateway cache ${path}`),\n\t};\n}\n\nexport function readSpGatewayState(agentDir = getSpAgentDir()): SpGatewayState | undefined {\n\tconst path = getSpGatewayStatePath(agentDir);\n\tif (!existsSync(path)) {\n\t\tconst legacy = readLegacySpGatewayState(agentDir);\n\t\tif (!legacy) return undefined;\n\t\twriteSpGatewayState(legacy.baseUrl, legacy.models, agentDir);\n\t\tgatewayWarning = {\n\t\t\tkind: \"legacy_cache_imported\",\n\t\t\tmessage: `Imported the legacy sp-gateway model cache from ${getLegacyModelsPath(agentDir)}.`,\n\t\t\tat: new Date().toISOString(),\n\t\t};\n\t\treturn legacy;\n\t}\n\tconst value = readJsonFile(path);\n\tif (value.schemaVersion !== 1 || typeof value.baseUrl !== \"string\" || !Array.isArray(value.models)) {\n\t\tthrow new Error(`Invalid sp-gateway configuration: ${path}`);\n\t}\n\treturn {\n\t\tschemaVersion: 1,\n\t\tbaseUrl: normalizeSpGatewayBaseUrl(value.baseUrl),\n\t\tmodels: parseModelCatalog(value.models, `sp-gateway configuration ${path}`),\n\t};\n}\n\nexport function writeSpGatewayState(baseUrl: string, models: SpProviderModelConfig[], agentDir = getSpAgentDir()) {\n\tconst validatedModels = parseModelCatalog(models, \"sp-gateway state\");\n\twritePrivateJson(getSpGatewayStatePath(agentDir), {\n\t\tschemaVersion: 1,\n\t\tbaseUrl: normalizeSpGatewayBaseUrl(baseUrl),\n\t\tmodels: validatedModels,\n\t} satisfies SpGatewayState);\n}\n\nexport function readSpGatewayApiKey(agentDir = getSpAgentDir()) {\n\tconst path = getSpAuthPath(agentDir);\n\tconst auth = readJsonFile(path);\n\tlet credential = auth[SP_GATEWAY_PROVIDER_ID];\n\tif (!credential && auth[LEGACY_SP_GATEWAY_PROVIDER_ID]) {\n\t\tcredential = auth[LEGACY_SP_GATEWAY_PROVIDER_ID];\n\t\tauth[SP_GATEWAY_PROVIDER_ID] = credential;\n\t\tdelete auth[LEGACY_SP_GATEWAY_PROVIDER_ID];\n\t\twritePrivateJson(path, auth);\n\t}\n\treturn isRecord(credential) && credential.type === \"api_key\" && typeof credential.key === \"string\"\n\t\t? credential.key\n\t\t: undefined;\n}\n\nexport function writeSpGatewayLogin(apiKey: string, firstModelId: string, agentDir = getSpAgentDir()) {\n\tconst authPath = getSpAuthPath(agentDir);\n\tconst auth = readJsonFile(authPath);\n\tauth[SP_GATEWAY_PROVIDER_ID] = { type: \"api_key\", key: apiKey };\n\tdelete auth[LEGACY_SP_GATEWAY_PROVIDER_ID];\n\twritePrivateJson(authPath, auth);\n\n\tconst settingsPath = getSpSettingsPath(agentDir);\n\tconst settings = readJsonFile(settingsPath);\n\tsettings.defaultProvider = SP_GATEWAY_PROVIDER_ID;\n\tsettings.defaultModel = firstModelId;\n\twritePrivateJson(settingsPath, settings);\n}\n\nexport function removeSpGatewayLogin(agentDir = getSpAgentDir()) {\n\tconst path = getSpAuthPath(agentDir);\n\tconst auth = readJsonFile(path);\n\tdelete auth[SP_GATEWAY_PROVIDER_ID];\n\tdelete auth[LEGACY_SP_GATEWAY_PROVIDER_ID];\n\twritePrivateJson(path, auth);\n}\n\nfunction credentialKey(context: RefreshModelsContext) {\n\treturn context.credential?.type === \"api_key\" ? context.credential.key : undefined;\n}\n\nfunction storedModels(models: readonly SpProviderModelConfig[], baseUrl: string): Model<Api>[] {\n\treturn models.map((model) => ({\n\t\t...model,\n\t\tprovider: SP_GATEWAY_PROVIDER_ID,\n\t\tbaseUrl,\n\t\tapi: \"openai-completions\",\n\t}));\n}\n\nexport function getSpGatewayWarning() {\n\treturn gatewayWarning;\n}\n\nexport function clearSpGatewayWarning() {\n\tgatewayWarning = undefined;\n}\n\nexport function createSpGatewayProviderConfig(\n\tbaseUrl: string,\n\tmodels: SpProviderModelConfig[],\n\tagentDir = getSpAgentDir(),\n): ProviderConfig {\n\tlet currentModels = parseModelCatalog(models, \"sp-gateway provider configuration\");\n\treturn {\n\t\tname: SP_GATEWAY_PROVIDER_NAME,\n\t\tbaseUrl,\n\t\tapi: \"openai-completions\",\n\t\tauthHeader: true,\n\t\theaders: {\n\t\t\t\"User-Agent\": SP_USER_AGENT,\n\t\t\t\"X-Senpiper-Client\": SP_CLIENT_NAME,\n\t\t},\n\t\tmodels: currentModels,\n\t\tasync refreshModels(context) {\n\t\t\tif (!context.allowNetwork) return currentModels;\n\t\t\tconst apiKey = credentialKey(context);\n\t\t\tif (!apiKey) return currentModels;\n\t\t\ttry {\n\t\t\t\tconst refreshed = await fetchSpGatewayModels(baseUrl, apiKey, context.signal);\n\t\t\t\tif (context.signal.aborted) return currentModels;\n\t\t\t\tawait context.publish({\n\t\t\t\t\tpersist: { models: storedModels(refreshed, baseUrl), checkedAt: Date.now() },\n\t\t\t\t\tupdate: () => {\n\t\t\t\t\t\twriteSpGatewayState(baseUrl, refreshed, agentDir);\n\t\t\t\t\t\tcurrentModels = refreshed;\n\t\t\t\t\t\tgatewayWarning = undefined;\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t\treturn currentModels;\n\t\t\t} catch (error) {\n\t\t\t\tgatewayWarning = {\n\t\t\t\t\tkind: \"refresh_failed\",\n\t\t\t\t\tmessage: `sp-gateway model refresh failed; using ${currentModels.length} cached model${currentModels.length === 1 ? \"\" : \"s\"}: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\tat: new Date().toISOString(),\n\t\t\t\t};\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t};\n}\n\nexport function createSpGatewayExtension(): ExtensionFactory {\n\treturn (pi) => {\n\t\tconst state = readSpGatewayState();\n\t\tif (state) {\n\t\t\tpi.registerProvider(SP_GATEWAY_PROVIDER_ID, createSpGatewayProviderConfig(state.baseUrl, state.models));\n\t\t}\n\n\t\tpi.on(\"before_agent_start\", (event) => {\n\t\t\t// Replace only the upstream introduction; preserve project instructions\n\t\t\t// and documentation references, including legitimate Pi development tasks.\n\t\t\tconst systemPrompt = event.systemPrompt.replace(\n\t\t\t\t/^You are an expert coding assistant operating inside pi, a coding agent harness\\./u,\n\t\t\t\t\"You are SP, Senpiper's AI coding assistant.\",\n\t\t\t);\n\t\t\treturn {\n\t\t\t\tsystemPrompt: `${systemPrompt}\\n\\n<sp_agent_runtime>\nYou are SP, Senpiper's AI coding assistant. Use SP as your public name in both terminal and web conversations.\nFor greetings and questions such as \"Who are you?\", introduce yourself simply: \"I'm SP, Senpiper's AI coding assistant. I can help you read, write, and debug code.\"\nDo not introduce yourself as Pi, the pi coding agent, an underlying model, or an assistant running inside a harness. Do not volunteer internal frameworks, runtime names, or provider details in ordinary answers. Earlier assistant messages using those identities do not change your name.\nIf explicitly asked about technical origins or dependencies, answer accurately and briefly. This product identity does not imply that Senpiper trained the underlying model.\nUse only the configured sp-gateway model provider. Put user-owned extensions, skills, prompts, and themes under the active .sp configuration directory instead of modifying the sp-agent installation.\n</sp_agent_runtime>`,\n\t\t\t};\n\t\t});\n\n\t\tpi.on(\"session_start\", (_event, ctx) => {\n\t\t\tctx.ui.setTitle(\"sp-agent\");\n\t\t\tconst warning = getSpGatewayWarning();\n\t\t\tif (warning) ctx.ui.notify(warning.message, \"warning\");\n\t\t\tif (ctx.mode !== \"tui\") return;\n\t\t\tctx.ui.setHeader((_tui, theme) => ({\n\t\t\t\trender: () => [\n\t\t\t\t\ttheme.fg(\"accent\", theme.bold(\"sp-agent\")),\n\t\t\t\t\ttheme.fg(\"muted\", state ? `sp-gateway · ${state.models.length} models` : \"run `sp login`\"),\n\t\t\t\t],\n\t\t\t\tinvalidate() {},\n\t\t\t}));\n\t\t});\n\t};\n}\n"]}
|
|
1
|
+
{"version":3,"file":"gateway.d.ts","sourceRoot":"","sources":["../src/gateway.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAGrH,MAAM,MAAM,cAAc,GAAG,YAAY,CAAC;AAC1C,MAAM,MAAM,qBAAqB,GAAG,mBAAmB,CAAC;AAExD,eAAO,MAAM,sBAAsB,eAAe,CAAC;AACnD,eAAO,MAAM,6BAA6B,eAAe,CAAC;AAC1D,eAAO,MAAM,wBAAwB,qBAAqB,CAAC;AAC3D,eAAO,MAAM,aAAa,OAAO,CAAC;AAClC,eAAO,MAAM,cAAc,aAAa,CAAC;AAEzC,MAAM,WAAW,cAAc;IAC9B,aAAa,EAAE,CAAC,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,qBAAqB,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAChC,IAAI,EAAE,gBAAgB,GAAG,uBAAuB,CAAC;IACjD,OAAO,EAAE,MAAM,CAAC;IAChB,EAAE,EAAE,MAAM,CAAC;CACX;AAgCD,wBAAgB,aAAa,WAE5B;AAED,wBAAgB,qBAAqB,CAAC,QAAQ,SAAkB,UAE/D;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,SAAkB,UAE7D;AAED,wBAAgB,aAAa,CAAC,QAAQ,SAAkB,UAEvD;AAED,wBAAgB,iBAAiB,CAAC,QAAQ,SAAkB,UAE3D;AAoBD,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,MAAM,UAkBtD;AA4GD,wBAAsB,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,kCAoB/F;AAiBD,wBAAgB,kBAAkB,CAAC,QAAQ,SAAkB,GAAG,cAAc,GAAG,SAAS,CAsBzF;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,qBAAqB,EAAE,EAAE,QAAQ,SAAkB,QAO/G;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,SAAkB,sBAa7D;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,QAAQ,SAAkB,QAYnG;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,SAAkB,QAM9D;AAeD,wBAAgB,mBAAmB,iCAElC;AAED,wBAAgB,qBAAqB,SAEpC;AAED,wBAAgB,6BAA6B,CAC5C,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,qBAAqB,EAAE,EAC/B,QAAQ,SAAkB,GACxB,cAAc,CAsChB;AAED,wBAAgB,wBAAwB,IAAI,gBAAgB,CAuC3D","sourcesContent":["import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport type { ExtensionAPI, ExtensionFactory, ProviderConfig, ProviderModelConfig } from \"@senpiper/pi-coding-agent\";\nimport type { Api, Model, OpenAICompletionsCompat, RefreshModelsContext } from \"@senpiper/pi-coding-agent/runtime-deps\";\n\nexport type SpExtensionApi = ExtensionAPI;\nexport type SpProviderModelConfig = ProviderModelConfig;\n\nexport const SP_GATEWAY_PROVIDER_ID = \"sp-gateway\";\nexport const LEGACY_SP_GATEWAY_PROVIDER_ID = \"sengateway\";\nexport const SP_GATEWAY_PROVIDER_NAME = \"Senpiper Gateway\";\nexport const SP_USER_AGENT = \"sp\";\nexport const SP_CLIENT_NAME = \"sp-agent\";\n\nexport interface SpGatewayState {\n\tschemaVersion: 1;\n\tbaseUrl: string;\n\tmodels: SpProviderModelConfig[];\n}\n\nexport interface SpGatewayWarning {\n\tkind: \"refresh_failed\" | \"legacy_cache_imported\";\n\tmessage: string;\n\tat: string;\n}\n\ntype JsonRecord = Record<string, unknown>;\n\nlet gatewayWarning: SpGatewayWarning | undefined;\n\nfunction isRecord(value: unknown): value is JsonRecord {\n\treturn typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction stringValue(value: unknown) {\n\treturn typeof value === \"string\" && value.trim() ? value.trim() : undefined;\n}\n\nfunction finiteNumber(value: unknown) {\n\treturn typeof value === \"number\" && Number.isFinite(value) ? value : undefined;\n}\n\nfunction positiveInteger(value: unknown) {\n\tconst number = finiteNumber(value);\n\treturn number !== undefined && number > 0 ? Math.floor(number) : undefined;\n}\n\nfunction nonNegativeNumber(value: unknown) {\n\tconst number = finiteNumber(value);\n\treturn number !== undefined && number >= 0 ? number : undefined;\n}\n\nfunction booleanValue(value: unknown) {\n\treturn typeof value === \"boolean\" ? value : undefined;\n}\n\nexport function getSpAgentDir() {\n\treturn process.env.SP_CODING_AGENT_DIR || join(homedir(), \".sp\", \"agent\");\n}\n\nexport function getSpGatewayStatePath(agentDir = getSpAgentDir()) {\n\treturn join(agentDir, \"sp-gateway.json\");\n}\n\nexport function getLegacyModelsPath(agentDir = getSpAgentDir()) {\n\treturn join(agentDir, \"models.json\");\n}\n\nexport function getSpAuthPath(agentDir = getSpAgentDir()) {\n\treturn join(agentDir, \"auth.json\");\n}\n\nexport function getSpSettingsPath(agentDir = getSpAgentDir()) {\n\treturn join(agentDir, \"settings.json\");\n}\n\nfunction readJsonFile(path: string): JsonRecord {\n\tif (!existsSync(path)) return {};\n\tconst content = readFileSync(path, \"utf8\").trim();\n\tif (!content) return {};\n\tconst parsed = JSON.parse(content) as unknown;\n\tif (!isRecord(parsed)) throw new Error(`Expected an object in ${path}`);\n\treturn parsed;\n}\n\nfunction writePrivateJson(path: string, value: unknown) {\n\tmkdirSync(dirname(path), { recursive: true });\n\tconst temporaryPath = `${path}.${process.pid}.tmp`;\n\twriteFileSync(temporaryPath, `${JSON.stringify(value, null, \"\\t\")}\\n`, { encoding: \"utf8\", mode: 0o600 });\n\tif (process.platform !== \"win32\") chmodSync(temporaryPath, 0o600);\n\trenameSync(temporaryPath, path);\n\tif (process.platform !== \"win32\") chmodSync(path, 0o600);\n}\n\nexport function normalizeSpGatewayBaseUrl(input: string) {\n\tconst trimmed = input.trim();\n\tif (!trimmed) throw new Error(\"Gateway URL cannot be empty.\");\n\tconst withScheme = /^[a-z][a-z0-9+.-]*:\\/\\//iu.test(trimmed) ? trimmed : `https://${trimmed}`;\n\tlet url: URL;\n\ttry {\n\t\turl = new URL(withScheme);\n\t} catch {\n\t\tthrow new Error(`Invalid gateway URL: ${input}`);\n\t}\n\tif (url.protocol !== \"https:\" && url.protocol !== \"http:\") {\n\t\tthrow new Error(\"Gateway URL must use HTTPS or HTTP.\");\n\t}\n\turl.hash = \"\";\n\turl.search = \"\";\n\turl.pathname = url.pathname.replace(/\\/+$/u, \"\");\n\tif (!url.pathname.endsWith(\"/v1\")) url.pathname = `${url.pathname}/v1`.replace(/\\/+/gu, \"/\");\n\treturn url.toString().replace(/\\/$/u, \"\");\n}\n\nfunction parseInput(value: unknown): (\"text\" | \"image\")[] | undefined {\n\tif (!Array.isArray(value)) return undefined;\n\tconst input = value.filter((entry): entry is \"text\" | \"image\" => entry === \"text\" || entry === \"image\");\n\treturn input.length > 0 ? [...new Set(input)] : undefined;\n}\n\nfunction parseCost(value: unknown): SpProviderModelConfig[\"cost\"] | undefined {\n\tif (!isRecord(value)) return undefined;\n\tconst input = nonNegativeNumber(value.input);\n\tconst output = nonNegativeNumber(value.output);\n\tconst cacheRead = nonNegativeNumber(value.cacheRead);\n\tconst cacheWrite = nonNegativeNumber(value.cacheWrite);\n\tif (input === undefined && output === undefined && cacheRead === undefined && cacheWrite === undefined)\n\t\treturn undefined;\n\treturn { input: input ?? 0, output: output ?? 0, cacheRead: cacheRead ?? 0, cacheWrite: cacheWrite ?? 0 };\n}\n\nfunction parseThinkingLevelMap(value: unknown): SpProviderModelConfig[\"thinkingLevelMap\"] {\n\tif (!isRecord(value)) return undefined;\n\tconst result: NonNullable<SpProviderModelConfig[\"thinkingLevelMap\"]> = {};\n\tfor (const key of [\"off\", \"minimal\", \"low\", \"medium\", \"high\", \"xhigh\", \"max\"] as const) {\n\t\tconst entry = value[key];\n\t\tif (typeof entry === \"string\" || entry === null) result[key] = entry;\n\t}\n\treturn Object.keys(result).length > 0 ? result : undefined;\n}\n\nfunction thinkingFormat(value: unknown): OpenAICompletionsCompat[\"thinkingFormat\"] {\n\tswitch (value) {\n\t\tcase \"openai\":\n\t\tcase \"openrouter\":\n\t\tcase \"deepseek\":\n\t\tcase \"together\":\n\t\tcase \"zai\":\n\t\tcase \"qwen\":\n\t\tcase \"chat-template\":\n\t\tcase \"qwen-chat-template\":\n\t\tcase \"string-thinking\":\n\t\tcase \"ant-ling\":\n\t\t\treturn value;\n\t\tdefault:\n\t\t\treturn undefined;\n\t}\n}\n\nfunction sessionAffinityFormat(value: unknown): OpenAICompletionsCompat[\"sessionAffinityFormat\"] {\n\treturn value === \"openai\" || value === \"openai-nosession\" || value === \"openrouter\" ? value : undefined;\n}\n\nfunction parseCompat(value: unknown): OpenAICompletionsCompat {\n\tconst record = isRecord(value) ? value : {};\n\tconst maxTokensField =\n\t\trecord.maxTokensField === \"max_completion_tokens\" || record.maxTokensField === \"max_tokens\"\n\t\t\t? record.maxTokensField\n\t\t\t: undefined;\n\tconst compat = {\n\t\tsupportsStore: booleanValue(record.supportsStore) ?? false,\n\t\tsupportsDeveloperRole: booleanValue(record.supportsDeveloperRole),\n\t\tsupportsReasoningEffort: booleanValue(record.supportsReasoningEffort),\n\t\tsupportsUsageInStreaming: booleanValue(record.supportsUsageInStreaming),\n\t\tmaxTokensField,\n\t\trequiresToolResultName: booleanValue(record.requiresToolResultName),\n\t\trequiresAssistantAfterToolResult: booleanValue(record.requiresAssistantAfterToolResult),\n\t\trequiresThinkingAsText: booleanValue(record.requiresThinkingAsText),\n\t\trequiresReasoningContentOnAssistantMessages: booleanValue(record.requiresReasoningContentOnAssistantMessages),\n\t\tthinkingFormat: thinkingFormat(record.thinkingFormat),\n\t\tsupportsStrictMode: booleanValue(record.supportsStrictMode),\n\t\tsendSessionAffinityHeaders: booleanValue(record.sendSessionAffinityHeaders),\n\t\tsessionAffinityFormat: sessionAffinityFormat(record.sessionAffinityFormat),\n\t\tsupportsLongCacheRetention: booleanValue(record.supportsLongCacheRetention),\n\t} satisfies OpenAICompletionsCompat;\n\treturn compat;\n}\n\nfunction parseGatewayModel(value: unknown): SpProviderModelConfig | undefined {\n\tconst record = typeof value === \"string\" ? { id: value } : isRecord(value) ? value : undefined;\n\tif (!record) return undefined;\n\tconst id = stringValue(record.id);\n\tif (!id || id.length > 256 || /[\\u0000-\\u001f\\u007f]/u.test(id)) return undefined;\n\tconst metadata = isRecord(record.metadata) ? record.metadata : {};\n\tconst field = (name: string) => metadata[name] ?? record[name];\n\tconst reasoning = field(\"reasoning\");\n\treturn {\n\t\tid,\n\t\tname: stringValue(field(\"name\")) ?? id,\n\t\treasoning: typeof reasoning === \"boolean\" ? reasoning : false,\n\t\tthinkingLevelMap: parseThinkingLevelMap(field(\"thinkingLevelMap\")),\n\t\tinput: parseInput(field(\"input\")) ?? [\"text\"],\n\t\tcost: parseCost(field(\"cost\")) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },\n\t\tcontextWindow: positiveInteger(field(\"contextWindow\")) ?? 128_000,\n\t\tmaxTokens: positiveInteger(field(\"maxTokens\")) ?? 32_000,\n\t\tcompat: parseCompat(field(\"compat\")),\n\t};\n}\n\nfunction parseModelCatalog(values: readonly unknown[], source: string) {\n\tconst models = values.map(parseGatewayModel).filter((model): model is SpProviderModelConfig => model !== undefined);\n\tif (models.length === 0) throw new Error(`${source} contains no valid models.`);\n\tconst ids = new Set<string>();\n\tfor (const model of models) {\n\t\tif (ids.has(model.id)) throw new Error(`${source} contains duplicate model id ${model.id}.`);\n\t\tids.add(model.id);\n\t}\n\treturn models;\n}\n\nexport async function fetchSpGatewayModels(baseUrl: string, apiKey: string, signal?: AbortSignal) {\n\tconst response = await fetch(`${baseUrl.replace(/\\/$/u, \"\")}/models`, {\n\t\tsignal,\n\t\theaders: {\n\t\t\tAuthorization: `Bearer ${apiKey}`,\n\t\t\tAccept: \"application/json\",\n\t\t\t\"User-Agent\": SP_USER_AGENT,\n\t\t\t\"X-Senpiper-Client\": SP_CLIENT_NAME,\n\t\t},\n\t});\n\tif (!response.ok) {\n\t\tconst body = await response.text().catch(() => \"\");\n\t\tconst suffix = body.trim() ? `: ${body.trim().slice(0, 300)}` : \"\";\n\t\tthrow new Error(`Senpiper Gateway rejected /v1/models (${response.status} ${response.statusText})${suffix}`);\n\t}\n\tconst payload = (await response.json()) as unknown;\n\tif (!isRecord(payload) || !Array.isArray(payload.data)) {\n\t\tthrow new Error(\"Senpiper Gateway returned an invalid /v1/models envelope.\");\n\t}\n\treturn parseModelCatalog(payload.data, \"Senpiper Gateway /v1/models response\");\n}\n\nfunction readLegacySpGatewayState(agentDir: string): SpGatewayState | undefined {\n\tconst path = getLegacyModelsPath(agentDir);\n\tif (!existsSync(path)) return undefined;\n\tconst root = readJsonFile(path);\n\tconst providers = isRecord(root.providers) ? root.providers : undefined;\n\tconst providerValue = providers?.[SP_GATEWAY_PROVIDER_ID] ?? providers?.[LEGACY_SP_GATEWAY_PROVIDER_ID];\n\tconst provider = isRecord(providerValue) ? providerValue : undefined;\n\tif (!provider || typeof provider.baseUrl !== \"string\" || !Array.isArray(provider.models)) return undefined;\n\treturn {\n\t\tschemaVersion: 1,\n\t\tbaseUrl: normalizeSpGatewayBaseUrl(provider.baseUrl),\n\t\tmodels: parseModelCatalog(provider.models, `legacy Senpiper Gateway cache ${path}`),\n\t};\n}\n\nexport function readSpGatewayState(agentDir = getSpAgentDir()): SpGatewayState | undefined {\n\tconst path = getSpGatewayStatePath(agentDir);\n\tif (!existsSync(path)) {\n\t\tconst legacy = readLegacySpGatewayState(agentDir);\n\t\tif (!legacy) return undefined;\n\t\twriteSpGatewayState(legacy.baseUrl, legacy.models, agentDir);\n\t\tgatewayWarning = {\n\t\t\tkind: \"legacy_cache_imported\",\n\t\t\tmessage: `Imported the legacy Senpiper Gateway model cache from ${getLegacyModelsPath(agentDir)}.`,\n\t\t\tat: new Date().toISOString(),\n\t\t};\n\t\treturn legacy;\n\t}\n\tconst value = readJsonFile(path);\n\tif (value.schemaVersion !== 1 || typeof value.baseUrl !== \"string\" || !Array.isArray(value.models)) {\n\t\tthrow new Error(`Invalid Senpiper Gateway configuration: ${path}`);\n\t}\n\treturn {\n\t\tschemaVersion: 1,\n\t\tbaseUrl: normalizeSpGatewayBaseUrl(value.baseUrl),\n\t\tmodels: parseModelCatalog(value.models, `Senpiper Gateway configuration ${path}`),\n\t};\n}\n\nexport function writeSpGatewayState(baseUrl: string, models: SpProviderModelConfig[], agentDir = getSpAgentDir()) {\n\tconst validatedModels = parseModelCatalog(models, \"Senpiper Gateway state\");\n\twritePrivateJson(getSpGatewayStatePath(agentDir), {\n\t\tschemaVersion: 1,\n\t\tbaseUrl: normalizeSpGatewayBaseUrl(baseUrl),\n\t\tmodels: validatedModels,\n\t} satisfies SpGatewayState);\n}\n\nexport function readSpGatewayApiKey(agentDir = getSpAgentDir()) {\n\tconst path = getSpAuthPath(agentDir);\n\tconst auth = readJsonFile(path);\n\tlet credential = auth[SP_GATEWAY_PROVIDER_ID];\n\tif (!credential && auth[LEGACY_SP_GATEWAY_PROVIDER_ID]) {\n\t\tcredential = auth[LEGACY_SP_GATEWAY_PROVIDER_ID];\n\t\tauth[SP_GATEWAY_PROVIDER_ID] = credential;\n\t\tdelete auth[LEGACY_SP_GATEWAY_PROVIDER_ID];\n\t\twritePrivateJson(path, auth);\n\t}\n\treturn isRecord(credential) && credential.type === \"api_key\" && typeof credential.key === \"string\"\n\t\t? credential.key\n\t\t: undefined;\n}\n\nexport function writeSpGatewayLogin(apiKey: string, firstModelId: string, agentDir = getSpAgentDir()) {\n\tconst authPath = getSpAuthPath(agentDir);\n\tconst auth = readJsonFile(authPath);\n\tauth[SP_GATEWAY_PROVIDER_ID] = { type: \"api_key\", key: apiKey };\n\tdelete auth[LEGACY_SP_GATEWAY_PROVIDER_ID];\n\twritePrivateJson(authPath, auth);\n\n\tconst settingsPath = getSpSettingsPath(agentDir);\n\tconst settings = readJsonFile(settingsPath);\n\tsettings.defaultProvider = SP_GATEWAY_PROVIDER_ID;\n\tsettings.defaultModel = firstModelId;\n\twritePrivateJson(settingsPath, settings);\n}\n\nexport function removeSpGatewayLogin(agentDir = getSpAgentDir()) {\n\tconst path = getSpAuthPath(agentDir);\n\tconst auth = readJsonFile(path);\n\tdelete auth[SP_GATEWAY_PROVIDER_ID];\n\tdelete auth[LEGACY_SP_GATEWAY_PROVIDER_ID];\n\twritePrivateJson(path, auth);\n}\n\nfunction credentialKey(context: RefreshModelsContext) {\n\treturn context.credential?.type === \"api_key\" ? context.credential.key : undefined;\n}\n\nfunction storedModels(models: readonly SpProviderModelConfig[], baseUrl: string): Model<Api>[] {\n\treturn models.map((model) => ({\n\t\t...model,\n\t\tprovider: SP_GATEWAY_PROVIDER_ID,\n\t\tbaseUrl,\n\t\tapi: \"openai-completions\",\n\t}));\n}\n\nexport function getSpGatewayWarning() {\n\treturn gatewayWarning;\n}\n\nexport function clearSpGatewayWarning() {\n\tgatewayWarning = undefined;\n}\n\nexport function createSpGatewayProviderConfig(\n\tbaseUrl: string,\n\tmodels: SpProviderModelConfig[],\n\tagentDir = getSpAgentDir(),\n): ProviderConfig {\n\tlet currentModels = parseModelCatalog(models, \"Senpiper Gateway provider configuration\");\n\treturn {\n\t\tname: SP_GATEWAY_PROVIDER_NAME,\n\t\tbaseUrl,\n\t\tapi: \"openai-completions\",\n\t\tauthHeader: true,\n\t\theaders: {\n\t\t\t\"User-Agent\": SP_USER_AGENT,\n\t\t\t\"X-Senpiper-Client\": SP_CLIENT_NAME,\n\t\t},\n\t\tmodels: currentModels,\n\t\tasync refreshModels(context) {\n\t\t\tif (!context.allowNetwork) return currentModels;\n\t\t\tconst apiKey = credentialKey(context);\n\t\t\tif (!apiKey) return currentModels;\n\t\t\ttry {\n\t\t\t\tconst refreshed = await fetchSpGatewayModels(baseUrl, apiKey, context.signal);\n\t\t\t\tif (context.signal.aborted) return currentModels;\n\t\t\t\tawait context.publish({\n\t\t\t\t\tpersist: { models: storedModels(refreshed, baseUrl), checkedAt: Date.now() },\n\t\t\t\t\tupdate: () => {\n\t\t\t\t\t\twriteSpGatewayState(baseUrl, refreshed, agentDir);\n\t\t\t\t\t\tcurrentModels = refreshed;\n\t\t\t\t\t\tgatewayWarning = undefined;\n\t\t\t\t\t},\n\t\t\t\t});\n\t\t\t\treturn currentModels;\n\t\t\t} catch (error) {\n\t\t\t\tgatewayWarning = {\n\t\t\t\t\tkind: \"refresh_failed\",\n\t\t\t\t\tmessage: `Senpiper Gateway model refresh failed; using ${currentModels.length} cached model${currentModels.length === 1 ? \"\" : \"s\"}: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\tat: new Date().toISOString(),\n\t\t\t\t};\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t},\n\t};\n}\n\nexport function createSpGatewayExtension(): ExtensionFactory {\n\treturn (pi) => {\n\t\tconst state = readSpGatewayState();\n\t\tif (state) {\n\t\t\tpi.registerProvider(SP_GATEWAY_PROVIDER_ID, createSpGatewayProviderConfig(state.baseUrl, state.models));\n\t\t}\n\n\t\tpi.on(\"before_agent_start\", (event) => {\n\t\t\t// Replace only the upstream introduction; preserve project instructions\n\t\t\t// and documentation references, including legitimate Pi development tasks.\n\t\t\tconst systemPrompt = event.systemPrompt.replace(\n\t\t\t\t/^You are an expert coding assistant operating inside pi, a coding agent harness\\./u,\n\t\t\t\t\"You are Senpiper Workbench, Senpiper's AI coding assistant.\",\n\t\t\t);\n\t\t\treturn {\n\t\t\t\tsystemPrompt: `${systemPrompt}\\n\\n<sp_agent_runtime>\nYou are Senpiper Workbench, Senpiper's AI coding assistant. Use Senpiper Workbench as your public name in both terminal and web conversations.\nFor greetings and questions such as \"Who are you?\", introduce yourself simply: \"I'm Senpiper Workbench, Senpiper's AI coding assistant. I can help you read, write, and debug code.\"\nDo not introduce yourself as Pi, the pi coding agent, an underlying model, or an assistant running inside a harness. Do not volunteer internal frameworks, runtime names, or provider details in ordinary answers. Earlier assistant messages using those identities do not change your name.\nIf explicitly asked about technical origins or dependencies, answer accurately and briefly. This product identity does not imply that Senpiper trained the underlying model.\nUse only the configured Senpiper Gateway model provider. Put user-owned extensions, skills, prompts, and themes under the active .sp configuration directory instead of modifying the Senpiper Workbench installation.\n</sp_agent_runtime>`,\n\t\t\t};\n\t\t});\n\n\t\tpi.on(\"session_start\", (_event, ctx) => {\n\t\t\tctx.ui.setTitle(\"Senpiper Workbench\");\n\t\t\tconst warning = getSpGatewayWarning();\n\t\t\tif (warning) ctx.ui.notify(warning.message, \"warning\");\n\t\t\tif (ctx.mode !== \"tui\") return;\n\t\t\tctx.ui.setHeader((_tui, theme) => ({\n\t\t\t\trender: () => [\n\t\t\t\t\ttheme.fg(\"accent\", theme.bold(\"Senpiper Workbench\")),\n\t\t\t\t\ttheme.fg(\"muted\", state ? `Senpiper Gateway · ${state.models.length} models` : \"run `sp login`\"),\n\t\t\t\t],\n\t\t\t\tinvalidate() {},\n\t\t\t}));\n\t\t});\n\t};\n}\n"]}
|
|
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
|
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
export const SP_GATEWAY_PROVIDER_ID = "sp-gateway";
|
|
5
5
|
export const LEGACY_SP_GATEWAY_PROVIDER_ID = "sengateway";
|
|
6
|
-
export const SP_GATEWAY_PROVIDER_NAME = "
|
|
6
|
+
export const SP_GATEWAY_PROVIDER_NAME = "Senpiper Gateway";
|
|
7
7
|
export const SP_USER_AGENT = "sp";
|
|
8
8
|
export const SP_CLIENT_NAME = "sp-agent";
|
|
9
9
|
let gatewayWarning;
|
|
@@ -203,13 +203,13 @@ export async function fetchSpGatewayModels(baseUrl, apiKey, signal) {
|
|
|
203
203
|
if (!response.ok) {
|
|
204
204
|
const body = await response.text().catch(() => "");
|
|
205
205
|
const suffix = body.trim() ? `: ${body.trim().slice(0, 300)}` : "";
|
|
206
|
-
throw new Error(`
|
|
206
|
+
throw new Error(`Senpiper Gateway rejected /v1/models (${response.status} ${response.statusText})${suffix}`);
|
|
207
207
|
}
|
|
208
208
|
const payload = (await response.json());
|
|
209
209
|
if (!isRecord(payload) || !Array.isArray(payload.data)) {
|
|
210
|
-
throw new Error("
|
|
210
|
+
throw new Error("Senpiper Gateway returned an invalid /v1/models envelope.");
|
|
211
211
|
}
|
|
212
|
-
return parseModelCatalog(payload.data, "
|
|
212
|
+
return parseModelCatalog(payload.data, "Senpiper Gateway /v1/models response");
|
|
213
213
|
}
|
|
214
214
|
function readLegacySpGatewayState(agentDir) {
|
|
215
215
|
const path = getLegacyModelsPath(agentDir);
|
|
@@ -224,7 +224,7 @@ function readLegacySpGatewayState(agentDir) {
|
|
|
224
224
|
return {
|
|
225
225
|
schemaVersion: 1,
|
|
226
226
|
baseUrl: normalizeSpGatewayBaseUrl(provider.baseUrl),
|
|
227
|
-
models: parseModelCatalog(provider.models, `legacy
|
|
227
|
+
models: parseModelCatalog(provider.models, `legacy Senpiper Gateway cache ${path}`),
|
|
228
228
|
};
|
|
229
229
|
}
|
|
230
230
|
export function readSpGatewayState(agentDir = getSpAgentDir()) {
|
|
@@ -236,23 +236,23 @@ export function readSpGatewayState(agentDir = getSpAgentDir()) {
|
|
|
236
236
|
writeSpGatewayState(legacy.baseUrl, legacy.models, agentDir);
|
|
237
237
|
gatewayWarning = {
|
|
238
238
|
kind: "legacy_cache_imported",
|
|
239
|
-
message: `Imported the legacy
|
|
239
|
+
message: `Imported the legacy Senpiper Gateway model cache from ${getLegacyModelsPath(agentDir)}.`,
|
|
240
240
|
at: new Date().toISOString(),
|
|
241
241
|
};
|
|
242
242
|
return legacy;
|
|
243
243
|
}
|
|
244
244
|
const value = readJsonFile(path);
|
|
245
245
|
if (value.schemaVersion !== 1 || typeof value.baseUrl !== "string" || !Array.isArray(value.models)) {
|
|
246
|
-
throw new Error(`Invalid
|
|
246
|
+
throw new Error(`Invalid Senpiper Gateway configuration: ${path}`);
|
|
247
247
|
}
|
|
248
248
|
return {
|
|
249
249
|
schemaVersion: 1,
|
|
250
250
|
baseUrl: normalizeSpGatewayBaseUrl(value.baseUrl),
|
|
251
|
-
models: parseModelCatalog(value.models, `
|
|
251
|
+
models: parseModelCatalog(value.models, `Senpiper Gateway configuration ${path}`),
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
254
|
export function writeSpGatewayState(baseUrl, models, agentDir = getSpAgentDir()) {
|
|
255
|
-
const validatedModels = parseModelCatalog(models, "
|
|
255
|
+
const validatedModels = parseModelCatalog(models, "Senpiper Gateway state");
|
|
256
256
|
writePrivateJson(getSpGatewayStatePath(agentDir), {
|
|
257
257
|
schemaVersion: 1,
|
|
258
258
|
baseUrl: normalizeSpGatewayBaseUrl(baseUrl),
|
|
@@ -310,7 +310,7 @@ export function clearSpGatewayWarning() {
|
|
|
310
310
|
gatewayWarning = undefined;
|
|
311
311
|
}
|
|
312
312
|
export function createSpGatewayProviderConfig(baseUrl, models, agentDir = getSpAgentDir()) {
|
|
313
|
-
let currentModels = parseModelCatalog(models, "
|
|
313
|
+
let currentModels = parseModelCatalog(models, "Senpiper Gateway provider configuration");
|
|
314
314
|
return {
|
|
315
315
|
name: SP_GATEWAY_PROVIDER_NAME,
|
|
316
316
|
baseUrl,
|
|
@@ -344,7 +344,7 @@ export function createSpGatewayProviderConfig(baseUrl, models, agentDir = getSpA
|
|
|
344
344
|
catch (error) {
|
|
345
345
|
gatewayWarning = {
|
|
346
346
|
kind: "refresh_failed",
|
|
347
|
-
message: `
|
|
347
|
+
message: `Senpiper Gateway model refresh failed; using ${currentModels.length} cached model${currentModels.length === 1 ? "" : "s"}: ${error instanceof Error ? error.message : String(error)}`,
|
|
348
348
|
at: new Date().toISOString(),
|
|
349
349
|
};
|
|
350
350
|
throw error;
|
|
@@ -361,19 +361,19 @@ export function createSpGatewayExtension() {
|
|
|
361
361
|
pi.on("before_agent_start", (event) => {
|
|
362
362
|
// Replace only the upstream introduction; preserve project instructions
|
|
363
363
|
// and documentation references, including legitimate Pi development tasks.
|
|
364
|
-
const systemPrompt = event.systemPrompt.replace(/^You are an expert coding assistant operating inside pi, a coding agent harness\./u, "You are
|
|
364
|
+
const systemPrompt = event.systemPrompt.replace(/^You are an expert coding assistant operating inside pi, a coding agent harness\./u, "You are Senpiper Workbench, Senpiper's AI coding assistant.");
|
|
365
365
|
return {
|
|
366
366
|
systemPrompt: `${systemPrompt}\n\n<sp_agent_runtime>
|
|
367
|
-
You are
|
|
368
|
-
For greetings and questions such as "Who are you?", introduce yourself simply: "I'm
|
|
367
|
+
You are Senpiper Workbench, Senpiper's AI coding assistant. Use Senpiper Workbench as your public name in both terminal and web conversations.
|
|
368
|
+
For greetings and questions such as "Who are you?", introduce yourself simply: "I'm Senpiper Workbench, Senpiper's AI coding assistant. I can help you read, write, and debug code."
|
|
369
369
|
Do not introduce yourself as Pi, the pi coding agent, an underlying model, or an assistant running inside a harness. Do not volunteer internal frameworks, runtime names, or provider details in ordinary answers. Earlier assistant messages using those identities do not change your name.
|
|
370
370
|
If explicitly asked about technical origins or dependencies, answer accurately and briefly. This product identity does not imply that Senpiper trained the underlying model.
|
|
371
|
-
Use only the configured
|
|
371
|
+
Use only the configured Senpiper Gateway model provider. Put user-owned extensions, skills, prompts, and themes under the active .sp configuration directory instead of modifying the Senpiper Workbench installation.
|
|
372
372
|
</sp_agent_runtime>`,
|
|
373
373
|
};
|
|
374
374
|
});
|
|
375
375
|
pi.on("session_start", (_event, ctx) => {
|
|
376
|
-
ctx.ui.setTitle("
|
|
376
|
+
ctx.ui.setTitle("Senpiper Workbench");
|
|
377
377
|
const warning = getSpGatewayWarning();
|
|
378
378
|
if (warning)
|
|
379
379
|
ctx.ui.notify(warning.message, "warning");
|
|
@@ -381,8 +381,8 @@ Use only the configured sp-gateway model provider. Put user-owned extensions, sk
|
|
|
381
381
|
return;
|
|
382
382
|
ctx.ui.setHeader((_tui, theme) => ({
|
|
383
383
|
render: () => [
|
|
384
|
-
theme.fg("accent", theme.bold("
|
|
385
|
-
theme.fg("muted", state ? `
|
|
384
|
+
theme.fg("accent", theme.bold("Senpiper Workbench")),
|
|
385
|
+
theme.fg("muted", state ? `Senpiper Gateway · ${state.models.length} models` : "run `sp login`"),
|
|
386
386
|
],
|
|
387
387
|
invalidate() { },
|
|
388
388
|
}));
|