opencode-ghost 0.2.7 → 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 +12 -1
- package/README.md +9 -5
- package/package.json +1 -1
- package/src/options.ts +10 -1
- package/src/stateless.ts +114 -0
- package/src/tui.tsx +53 -11
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ 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
|
|
10
20
|
## [0.2.7] - 2026-09-25
|
|
11
21
|
|
|
12
22
|
### Fixed
|
|
@@ -117,7 +127,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
117
127
|
- Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
|
|
118
128
|
`recentMessages` and `system`.
|
|
119
129
|
|
|
120
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.
|
|
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
|
|
121
132
|
[0.2.7]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.6...v0.2.7
|
|
122
133
|
[0.2.6]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.5...v0.2.6
|
|
123
134
|
[0.2.5]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.2.4...v0.2.5
|
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` | `
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
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 ??
|
|
78
|
+
backOnEmptyLeft: input.backOnEmptyLeft ?? DEFAULTS.backOnEmptyLeft,
|
|
70
79
|
internalSessionMarkerDir:
|
|
71
80
|
typeof input.internalSessionMarkerDir === "string" && input.internalSessionMarkerDir.trim()
|
|
72
81
|
? input.internalSessionMarkerDir.trim()
|
package/src/stateless.ts
ADDED
|
@@ -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
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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:
|
|
635
|
-
model
|
|
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
|
-
|
|
777
|
-
if (
|
|
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
|
},
|