opencode-ghost 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-09-28
11
+
12
+ ### Added
13
+
14
+ - Direct stateless generation support for OpenAI-compatible and Anthropic endpoints, eliminating background OpenCode session creation, zombie sessions, and cmux workspace interference.
15
+
16
+ ### Fixed
17
+
18
+ - Avoid creating `.bak` backup file on install
19
+ - Enable returning to the home screen with Left arrow on an empty prompt by default and prevent key event leakage
20
+ ## [0.2.7] - 2026-09-25
21
+
22
+ ### Fixed
23
+
24
+ - Root package name in `bun.lock` now matches `package.json` (`opencode-ghost`).
10
25
  ## [0.2.6] - 2026-09-25
11
26
 
12
27
  ### Fixed
@@ -112,7 +127,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
112
127
  - Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
113
128
  `recentMessages` and `system`.
114
129
 
115
- [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.6...HEAD
130
+ [Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.3.0...HEAD
131
+ [0.3.0]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.7...v0.3.0
132
+ [0.2.7]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.6...v0.2.7
116
133
  [0.2.6]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.5...v0.2.6
117
134
  [0.2.5]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.4...v0.2.5
118
135
  [0.2.4]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.3...v0.2.4
package/README.md CHANGED
@@ -99,12 +99,14 @@ Pass an options object as the second element of the plugin tuple:
99
99
  | ---------------- | ---------- | --------------------- | --------------------------------------------------------------------- |
100
100
  | `enabled` | `boolean` | `true` | Initial state; `/suggest` toggles it at runtime (persisted). |
101
101
  | `model` | `string` | session `small_model` | `provider/model`. Falls back to `small_model`, then the session model. |
102
+ | `endpoint` | `string` | unset | Custom OpenAI-compatible or gateway endpoint (`baseURL` or `/chat/completions`). Enables stateless direct generation without creating OpenCode sessions. |
103
+ | `apiKey` | `string` | unset | API key for `endpoint`, supporting `{file:...}`, `{env:...}`, or raw token. |
102
104
  | `acceptKeys` | `string[]` | `["tab", "right"]` | Key names as reported by the terminal (`tab`, `right`, ...). |
103
105
  | `maxChars` | `number` | `120` | Maximum suggestion length. |
104
106
  | `idleDelayMs` | `number` | `500` | Debounce after a turn finishes before generating. |
105
107
  | `recentMessages` | `number` | `10` | How many recent messages feed the suggestion prompt. |
106
108
  | `system` | `string` | built-in | Override the system prompt sent to the suggestion model. |
107
- | `backOnEmptyLeft` | `boolean` | `false` | Return to the home screen with Left when the prompt is empty. |
109
+ | `backOnEmptyLeft` | `boolean` | `true` | Return to the home screen with Left when the prompt is empty. |
108
110
  | `internalSessionMarkerDir` | `string` | unset | Optional extra crash-recovery tracking. A configuration-free sweep already reaps leftover hidden sessions, so this is only an additional safety net. |
109
111
  | `argHints` | `Record<string, string[]>` | `{}` | Argument option lists per slash command, shown as `[a \| b \| c]` after a complete `/name ` and completed with Tab, e.g. `"/keepwarm": ["6h", "always", "off", "status"]`. Commands without an entry suggest no arguments. |
110
112
 
@@ -118,10 +120,12 @@ When enabling, a suggestion is generated immediately for the current session.
118
120
 
119
121
  - Listens for `session.idle`, debounces, and builds a short transcript from the
120
122
  session's recent messages.
121
- - Asks a model (your `small_model` by default) for one short next user message,
122
- in a throwaway hidden session with tools disabled; the session is deleted
123
- immediately afterwards. Every hidden-session call is scoped to the current
124
- session's project directory, hidden sessions carry a metadata tag that
123
+ - Asks a model (your `small_model` by default) for one short next user message.
124
+ When provider credentials or an `endpoint` are available, it generates suggestions
125
+ statelessly via direct HTTP fetch (zero server sessions created, zero cmux/session
126
+ hooks emitted). Otherwise, falls back to a throwaway hidden session with tools
127
+ disabled that is deleted immediately afterwards. Every hidden-session call is scoped
128
+ to the current session's project directory, hidden sessions carry a metadata tag that
125
129
  survives the server's automatic title generation, and a periodic sweep (plus
126
130
  shutdown cleanup) reaps any leftovers — including sessions orphaned by a
127
131
  crash — with no configuration required.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ghost",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "Claude Code-style next-prompt suggestions in the opencode TUI, shown as dimmed ghost text and accepted with Tab. An opencode plugin.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/options.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export type PromptSuggestOptions = {
2
2
  enabled?: boolean
3
3
  model?: string
4
+ endpoint?: string
5
+ apiKey?: string
4
6
  acceptKeys?: string[]
5
7
  backOnEmptyLeft?: boolean
6
8
  internalSessionMarkerDir?: string
@@ -14,6 +16,8 @@ export type PromptSuggestOptions = {
14
16
  export type ResolvedOptions = {
15
17
  enabled: boolean
16
18
  model: string | undefined
19
+ endpoint: string | undefined
20
+ apiKey: string | undefined
17
21
  acceptKeys: string[]
18
22
  backOnEmptyLeft: boolean
19
23
  internalSessionMarkerDir: string | undefined
@@ -49,6 +53,7 @@ export const DEFAULT_SYSTEM = [
49
53
  const DEFAULTS = {
50
54
  enabled: true,
51
55
  acceptKeys: ["tab", "right"],
56
+ backOnEmptyLeft: true,
52
57
  maxChars: 120,
53
58
  idleDelayMs: 500,
54
59
  recentMessages: 10,
@@ -62,11 +67,15 @@ export function resolveOptions(options: PromptSuggestOptions | undefined): Resol
62
67
  return {
63
68
  enabled: input.enabled ?? DEFAULTS.enabled,
64
69
  model: typeof input.model === "string" && input.model.trim() ? input.model.trim() : undefined,
70
+ endpoint:
71
+ typeof input.endpoint === "string" && input.endpoint.trim() ? input.endpoint.trim() : undefined,
72
+ apiKey:
73
+ typeof input.apiKey === "string" && input.apiKey.trim() ? input.apiKey.trim() : undefined,
65
74
  acceptKeys:
66
75
  Array.isArray(input.acceptKeys) && input.acceptKeys.length > 0
67
76
  ? input.acceptKeys
68
77
  : [...DEFAULTS.acceptKeys],
69
- backOnEmptyLeft: input.backOnEmptyLeft ?? false,
78
+ backOnEmptyLeft: input.backOnEmptyLeft ?? DEFAULTS.backOnEmptyLeft,
70
79
  internalSessionMarkerDir:
71
80
  typeof input.internalSessionMarkerDir === "string" && input.internalSessionMarkerDir.trim()
72
81
  ? input.internalSessionMarkerDir.trim()
@@ -0,0 +1,114 @@
1
+ import * as fs from "node:fs"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+ import type { ResolvedOptions } from "./options"
5
+
6
+ export function resolveApiKey(raw: string | undefined): string | undefined {
7
+ if (!raw) return undefined
8
+ const trimmed = raw.trim()
9
+ if (trimmed.startsWith("{file:") && trimmed.endsWith("}")) {
10
+ const filePath = trimmed.slice(6, -1).trim()
11
+ try {
12
+ const resolved = filePath.startsWith("~/")
13
+ ? path.join(os.homedir(), filePath.slice(2))
14
+ : filePath
15
+ return fs.readFileSync(resolved, "utf8").trim()
16
+ } catch {
17
+ return undefined
18
+ }
19
+ }
20
+ if (trimmed.startsWith("{env:") && trimmed.endsWith("}")) {
21
+ const envVar = trimmed.slice(5, -1).trim()
22
+ return process.env[envVar]
23
+ }
24
+ return trimmed
25
+ }
26
+
27
+ export async function fetchStatelessSuggestion(
28
+ transcript: string,
29
+ opts: ResolvedOptions,
30
+ modelRef: { providerID: string; modelID: string } | undefined,
31
+ config: any,
32
+ ): Promise<string | undefined> {
33
+ const provider = modelRef?.providerID ? config?.provider?.[modelRef.providerID] : undefined
34
+ const baseURL =
35
+ opts.endpoint ||
36
+ provider?.options?.baseURL ||
37
+ process.env.LITELLM_BASE_URL ||
38
+ process.env.OPENAI_BASE_URL
39
+ const rawKey =
40
+ opts.apiKey ||
41
+ provider?.options?.apiKey ||
42
+ process.env.LITELLM_API_KEY ||
43
+ process.env.OPENAI_API_KEY
44
+ const apiKey = resolveApiKey(rawKey)
45
+
46
+ const controller = new AbortController()
47
+ const timeoutMs = 4000
48
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
49
+
50
+ try {
51
+ if (baseURL) {
52
+ const url = baseURL.endsWith("/chat/completions")
53
+ ? baseURL
54
+ : `${baseURL.replace(/\/+$/, "")}/chat/completions`
55
+
56
+ const rawModel = modelRef?.modelID || "default"
57
+ const model = rawModel.includes("/")
58
+ ? rawModel.slice(rawModel.lastIndexOf("/") + 1)
59
+ : rawModel
60
+
61
+ const headers: Record<string, string> = { "Content-Type": "application/json" }
62
+ if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`
63
+
64
+ const res = await fetch(url, {
65
+ method: "POST",
66
+ headers,
67
+ signal: controller.signal,
68
+ body: JSON.stringify({
69
+ model,
70
+ messages: [
71
+ { role: "system", content: opts.system },
72
+ { role: "user", content: transcript },
73
+ ],
74
+ max_tokens: 120,
75
+ temperature: 0.2,
76
+ }),
77
+ })
78
+
79
+ if (!res.ok) return undefined
80
+ const data = (await res.json()) as any
81
+ return data?.choices?.[0]?.message?.content
82
+ }
83
+
84
+ if (modelRef?.providerID === "anthropic") {
85
+ const antKey = resolveApiKey(provider?.options?.apiKey) || process.env.ANTHROPIC_API_KEY
86
+ if (!antKey) return undefined
87
+ const res = await fetch("https://api.anthropic.com/v1/messages", {
88
+ method: "POST",
89
+ headers: {
90
+ "Content-Type": "application/json",
91
+ "x-api-key": antKey,
92
+ "anthropic-version": "2023-06-01",
93
+ },
94
+ signal: controller.signal,
95
+ body: JSON.stringify({
96
+ model: modelRef.modelID,
97
+ max_tokens: 120,
98
+ system: opts.system,
99
+ messages: [{ role: "user", content: transcript }],
100
+ }),
101
+ })
102
+
103
+ if (!res.ok) return undefined
104
+ const data = (await res.json()) as any
105
+ return data?.content?.[0]?.text
106
+ }
107
+ } catch {
108
+ return undefined
109
+ } finally {
110
+ clearTimeout(timer)
111
+ }
112
+
113
+ return undefined
114
+ }
package/src/tui.tsx CHANGED
@@ -7,6 +7,7 @@ import { resolveOptions, type PromptSuggestOptions } from "./options"
7
7
  import { clip, isEcho, normalize, parseModel, wrapCount } from "./text"
8
8
  import { BUILTIN_COMMANDS } from "./builtins"
9
9
  import { completeCommand, type CommandPool } from "./completion"
10
+ import { fetchStatelessSuggestion } from "./stateless"
10
11
 
11
12
  const HIDDEN_TITLE = "ghost-hidden"
12
13
  // Survives the server's automatic title generation, which renames the hidden
@@ -597,10 +598,48 @@ const tui: TuiPlugin = async (api, rawOptions) => {
597
598
  .join(" ")
598
599
  }
599
600
 
601
+ // HIDDEN_TOOLS only names built-in tools; anything else (MCP servers,
602
+ // other dynamically registered tools) stays enabled unless listed here too.
603
+ // Ask the server for the full set so a hidden session never gets to call
604
+ // unrelated tools (e.g. session search) and answer like a real agent
605
+ // instead of writing a suggested next message.
606
+ const fetchDisabledTools = async (
607
+ directory: string | undefined,
608
+ ): Promise<Record<string, boolean>> => {
609
+ const disabled: Record<string, boolean> = { ...HIDDEN_TOOLS }
610
+ try {
611
+ const result = await api.client.tool.ids(directory ? { directory } : undefined)
612
+ const ids = Array.isArray(result?.data) ? result.data : []
613
+ for (const id of ids) {
614
+ if (typeof id === "string" && id) disabled[id] = false
615
+ }
616
+ } catch {
617
+ // Best effort: the static list above still covers every built-in tool
618
+ // if the dynamic lookup fails.
619
+ }
620
+ return disabled
621
+ }
622
+
600
623
  const generate = async (sessionID: string): Promise<string | undefined> => {
601
624
  const transcript = buildTranscript(sessionID)
602
625
  if (!transcript) return undefined
603
626
 
627
+ const model = resolveModel()
628
+
629
+ const stateless = await fetchStatelessSuggestion(
630
+ transcript,
631
+ opts,
632
+ model,
633
+ api.state.config,
634
+ )
635
+ if (stateless) {
636
+ const suggestion = normalize(stateless, opts.maxChars)
637
+ if (!suggestion) return undefined
638
+ const previous = lastUserText(sessionID)
639
+ if (previous && isEcho(suggestion, previous)) return undefined
640
+ return suggestion
641
+ }
642
+
604
643
  const markerDir = opts.internalSessionMarkerDir
605
644
  const pending = markerDir ? path.join(markerDir, "pending") : undefined
606
645
  if (markerDir && pending) markPending(markerDir, pending)
@@ -612,11 +651,14 @@ const tui: TuiPlugin = async (api, rawOptions) => {
612
651
  // behind whenever the default differed.
613
652
  const parentDirectory = resolveParentDirectory(sessionID)
614
653
  try {
615
- const created = await api.client.session.create({
616
- title: HIDDEN_TITLE,
617
- metadata: { ...GHOST_METADATA },
618
- ...(parentDirectory ? { directory: parentDirectory } : {}),
619
- })
654
+ const [created, disabledTools] = await Promise.all([
655
+ api.client.session.create({
656
+ title: HIDDEN_TITLE,
657
+ metadata: { ...GHOST_METADATA },
658
+ ...(parentDirectory ? { directory: parentDirectory } : {}),
659
+ }),
660
+ fetchDisabledTools(parentDirectory),
661
+ ])
620
662
  tempID = created.data?.id
621
663
  if (!tempID) return undefined
622
664
  createdDirectory =
@@ -631,8 +673,8 @@ const tui: TuiPlugin = async (api, rawOptions) => {
631
673
  sessionID: tempID,
632
674
  ...(createdDirectory ? { directory: createdDirectory } : {}),
633
675
  system: opts.system,
634
- tools: { ...HIDDEN_TOOLS },
635
- model: resolveModel(),
676
+ tools: disabledTools,
677
+ model,
636
678
  parts: [{ type: "text", text: transcript }],
637
679
  })
638
680
  const parts = result.data?.parts ?? []
@@ -768,13 +810,13 @@ const tui: TuiPlugin = async (api, rawOptions) => {
768
810
  {
769
811
  key: "left",
770
812
  desc: "Return to the home screen when the prompt is empty",
771
- preventDefault: false,
772
- fallthrough: true,
773
813
  cmd: () => {
774
814
  if (!opts.backOnEmptyLeft) return false
775
815
  if (api.route.current.name !== "session") return false
776
- if (!promptRef) return false
777
- if (currentInput().trim().length > 0) return false
816
+ const input = currentInput()
817
+ if (input.trim().length > 0) return false
818
+ showGhost(undefined)
819
+ setInBox(undefined)
778
820
  api.route.navigate("home")
779
821
  return true
780
822
  },