@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.
Files changed (86) hide show
  1. package/INSTALL.md +36 -24
  2. package/README.md +13 -13
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +4 -4
  5. package/dist/cli.js.map +1 -1
  6. package/dist/distribution.d.ts +2 -2
  7. package/dist/distribution.d.ts.map +1 -1
  8. package/dist/distribution.js +1 -1
  9. package/dist/distribution.js.map +1 -1
  10. package/dist/extension-identity.generated.d.ts +3 -3
  11. package/dist/extension-identity.generated.d.ts.map +1 -1
  12. package/dist/extension-identity.generated.js +3 -3
  13. package/dist/extension-identity.generated.js.map +1 -1
  14. package/dist/install-sp.sh +9 -9
  15. package/dist/release-manager.d.ts.map +1 -1
  16. package/dist/release-manager.js +2 -2
  17. package/dist/release-manager.js.map +1 -1
  18. package/dist/runtime-cli.d.ts.map +1 -1
  19. package/dist/runtime-cli.js +4 -4
  20. package/dist/runtime-cli.js.map +1 -1
  21. package/dist/sdk.d.ts +1 -1
  22. package/dist/sdk.d.ts.map +1 -1
  23. package/dist/sdk.js +5 -5
  24. package/dist/sdk.js.map +1 -1
  25. package/dist/session-manager.d.ts +1 -1
  26. package/dist/session-manager.d.ts.map +1 -1
  27. package/dist/session-manager.js +15 -15
  28. package/dist/session-manager.js.map +1 -1
  29. package/dist/sp-web.d.ts.map +1 -1
  30. package/dist/sp-web.js +2 -2
  31. package/dist/sp-web.js.map +1 -1
  32. package/dist/version.d.ts +1 -1
  33. package/dist/version.d.ts.map +1 -1
  34. package/dist/version.js +1 -1
  35. package/dist/version.js.map +1 -1
  36. package/node_modules/@senpiper/pi-coding-agent/dist/bundle/chunks/{chunk-LUSUF4ZH.js → chunk-VVKE4QZJ.js} +1 -1
  37. package/node_modules/@senpiper/pi-coding-agent/dist/bundle/cli.js +1 -1
  38. package/node_modules/@senpiper/pi-coding-agent/dist/bundle/index.js +1 -1
  39. package/node_modules/@senpiper/pi-coding-agent/dist/bundle/rpc-entry.js +1 -1
  40. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -1
  41. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/amazon-bedrock.json +1 -1
  42. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/baseten.json +1 -1
  43. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/fireworks.json +1 -1
  44. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/huggingface.json +1 -1
  45. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/kimi-coding.json +1 -1
  46. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/nvidia.json +1 -1
  47. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode-go.json +1 -1
  48. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
  49. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/qwen-token-plan-cn.json +1 -1
  50. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/qwen-token-plan.json +1 -1
  51. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/together.json +1 -1
  52. package/node_modules/@senpiper/pi-coding-agent/node_modules/@earendil-works/pi-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
  53. package/node_modules/@senpiper/pi-coding-agent/package.json +1 -1
  54. package/node_modules/@senpiper/sp-agent-extensions/CHANGELOG.md +17 -0
  55. package/node_modules/@senpiper/sp-agent-extensions/CONCURRENT_SESSIONS.md +9 -9
  56. package/node_modules/@senpiper/sp-agent-extensions/TERMINAL_WEB_BRIDGE.md +28 -9
  57. package/node_modules/@senpiper/sp-agent-extensions/dist/cli-commands.d.ts.map +1 -1
  58. package/node_modules/@senpiper/sp-agent-extensions/dist/cli-commands.js +5 -5
  59. package/node_modules/@senpiper/sp-agent-extensions/dist/cli-commands.js.map +1 -1
  60. package/node_modules/@senpiper/sp-agent-extensions/dist/gateway.d.ts +1 -1
  61. package/node_modules/@senpiper/sp-agent-extensions/dist/gateway.d.ts.map +1 -1
  62. package/node_modules/@senpiper/sp-agent-extensions/dist/gateway.js +18 -18
  63. package/node_modules/@senpiper/sp-agent-extensions/dist/gateway.js.map +1 -1
  64. package/node_modules/@senpiper/sp-agent-extensions/dist/index.d.ts +1 -1
  65. package/node_modules/@senpiper/sp-agent-extensions/dist/index.d.ts.map +1 -1
  66. package/node_modules/@senpiper/sp-agent-extensions/dist/index.js +1 -1
  67. package/node_modules/@senpiper/sp-agent-extensions/dist/index.js.map +1 -1
  68. package/node_modules/@senpiper/sp-agent-extensions/dist/login-command.d.ts.map +1 -1
  69. package/node_modules/@senpiper/sp-agent-extensions/dist/login-command.js +9 -9
  70. package/node_modules/@senpiper/sp-agent-extensions/dist/login-command.js.map +1 -1
  71. package/node_modules/@senpiper/sp-agent-extensions/dist/sp-web-launcher.d.ts +27 -0
  72. package/node_modules/@senpiper/sp-agent-extensions/dist/sp-web-launcher.d.ts.map +1 -1
  73. package/node_modules/@senpiper/sp-agent-extensions/dist/sp-web-launcher.js +270 -28
  74. package/node_modules/@senpiper/sp-agent-extensions/dist/sp-web-launcher.js.map +1 -1
  75. package/node_modules/@senpiper/sp-agent-extensions/dist/startup-header.d.ts.map +1 -1
  76. package/node_modules/@senpiper/sp-agent-extensions/dist/startup-header.js +12 -19
  77. package/node_modules/@senpiper/sp-agent-extensions/dist/startup-header.js.map +1 -1
  78. package/node_modules/@senpiper/sp-agent-extensions/dist/terminal-web-bridge.d.ts +1 -1
  79. package/node_modules/@senpiper/sp-agent-extensions/dist/terminal-web-bridge.d.ts.map +1 -1
  80. package/node_modules/@senpiper/sp-agent-extensions/dist/terminal-web-bridge.js +11 -9
  81. package/node_modules/@senpiper/sp-agent-extensions/dist/terminal-web-bridge.js.map +1 -1
  82. package/node_modules/@senpiper/sp-agent-extensions/dist/web-command.d.ts.map +1 -1
  83. package/node_modules/@senpiper/sp-agent-extensions/dist/web-command.js +36 -10
  84. package/node_modules/@senpiper/sp-agent-extensions/dist/web-command.js.map +1 -1
  85. package/node_modules/@senpiper/sp-agent-extensions/package.json +3 -2
  86. 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 sp-agent",
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 SP sessions
1
+ # Concurrent Senpiper Workbench sessions
2
2
 
3
- SP supports independent concurrent sessions within one instance. Browser selection does not replace the terminal session or stop another web session.
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 SP distribution supplies its instance manager. An extension loaded without that manager keeps these capabilities false.
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 SP factory. It uses the same required-extension identity, provider policy, and resource ownership checks as `createSpAgentSession`; the bridge does not recreate provider policy.
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 SP distribution deletion provider is active. The extension alone advertises false and rejects the command. Deletion never starts an SDK writer for inactive history.
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 SP SDK installs the same provider on import, before callers construct persistent `SessionManager`s. Passing a persistent manager constructed before SP 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.
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 an SP process is killed without exit cleanup, stop all SP 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.
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 SP 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 SP 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.
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 SP 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.
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
- # SP terminal web bridge protocol
1
+ # Senpiper Workbench terminal web bridge protocol
2
2
 
3
- Interactive `sp` sessions expose their live state through same-user Unix sockets. The SP 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.
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 SP Gateway model and applies it through the terminal owner. |
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 SP. `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.
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 SP 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).
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
- ## SP Web sidecar
167
+ ## Senpiper Workbench web sidecar
168
168
 
169
- The extension starts one detached sidecar per configured SP agent directory and reuses its private PID record while that process is alive. Configuration:
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 SP executable location. The current source pins `@senpiper/sp-web@0.1.1` as a normal registry dependency, installed automatically by the same global SP install command, not bundled in the SP tarball. The resolved absolute entry runs with the current Node executable, or external `node` from PATH when SP 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.
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. SP Web must use the authenticated descriptors for live commands, never construct another writer for the same session.
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(`sp-agent 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(\"sp-gateway URL: \")),\n\t\t\tapiKey: options.apiKey ?? (await prompt.question(\"sp-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 sp-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 sp-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"]}
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(`sp-agent gateway setup
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("sp-gateway URL: ")),
46
- apiKey: options.apiKey ?? (await prompt.question("sp-gateway API key: ")),
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 sp-gateway API key.");
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 sp-gateway at ${baseUrl}`);
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,kBAAkB,CAAC,CAAC;YAC7E,MAAM,EAAE,OAAO,CAAC,MAAM,IAAI,CAAC,MAAM,MAAM,CAAC,QAAQ,CAAC,sBAAsB,CAAC,CAAC;SACzE,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,oCAAoC,CAAC,CAAC;YAClD,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,8BAA8B,OAAO,EAAE,CAAC,CAAC;QACrD,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(`sp-agent 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(\"sp-gateway URL: \")),\n\t\t\tapiKey: options.apiKey ?? (await prompt.question(\"sp-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 sp-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 sp-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"]}
1
+ {"version":3,"file":"cli-commands.js","sourceRoot":"","sources":["../src/cli-commands.ts"],"names":[],"mappings":"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","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 = "sp-gateway";
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 = "sp-gateway";
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(`sp-gateway rejected /v1/models (${response.status} ${response.statusText})${suffix}`);
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("sp-gateway returned an invalid /v1/models envelope.");
210
+ throw new Error("Senpiper Gateway returned an invalid /v1/models envelope.");
211
211
  }
212
- return parseModelCatalog(payload.data, "sp-gateway /v1/models response");
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 sp-gateway cache ${path}`),
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 sp-gateway model cache from ${getLegacyModelsPath(agentDir)}.`,
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 sp-gateway configuration: ${path}`);
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, `sp-gateway configuration ${path}`),
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, "sp-gateway state");
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, "sp-gateway provider configuration");
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: `sp-gateway model refresh failed; using ${currentModels.length} cached model${currentModels.length === 1 ? "" : "s"}: ${error instanceof Error ? error.message : String(error)}`,
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 SP, Senpiper's AI coding assistant.");
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 SP, Senpiper's AI coding assistant. Use SP 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 SP, Senpiper's AI coding assistant. I can help you read, write, and debug code."
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 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.
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("sp-agent");
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("sp-agent")),
385
- theme.fg("muted", state ? `sp-gateway · ${state.models.length} models` : "run `sp login`"),
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
  }));