opencode-ghost 0.1.0 → 0.1.1
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 +48 -0
- package/README.md +7 -1
- package/package.json +9 -2
- package/src/options.ts +26 -5
- package/src/text.ts +16 -0
- package/src/tui.tsx +316 -6
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.1] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Optional Left-arrow navigation back to the home screen from an empty prompt.
|
|
15
|
+
- Recovery of abandoned hidden suggestion sessions via a periodic sweep that
|
|
16
|
+
needs no configuration (an optional marker directory adds extra
|
|
17
|
+
crash-recovery tracking).
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Prevent suggestions from repeating the user's previous message.
|
|
22
|
+
- Trigger suggestions from the current session-status idle event.
|
|
23
|
+
- Remove hidden suggestion sessions and their markers when generation fails:
|
|
24
|
+
deletion now passes the session directory, and a periodic sweep retries
|
|
25
|
+
abandoned sessions (including corrupt or zero-byte markers) instead of
|
|
26
|
+
leaving them behind for the life of the TUI process.
|
|
27
|
+
- Delete hidden suggestion sessions reliably: every hidden-session call is
|
|
28
|
+
scoped to the visible session's project directory, hidden sessions carry a
|
|
29
|
+
metadata tag that survives the server's automatic title generation, a
|
|
30
|
+
configuration-free sweep reaps leftovers by listing sessions, and shutdown
|
|
31
|
+
deletes in-flight generations. Marker-tracked sessions are removed even
|
|
32
|
+
after being retitled.
|
|
33
|
+
## [0.1.0] - 2026-09-21
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- Next-prompt suggestions for the opencode TUI: after each turn a model reads
|
|
38
|
+
the recent conversation and writes one short message in the user's voice.
|
|
39
|
+
- The suggestion renders under the prompt and is accepted with `Tab` or `→`,
|
|
40
|
+
dropping it into the input to edit or send.
|
|
41
|
+
- `/suggest` toggles suggestions on and off; the state is persisted in the
|
|
42
|
+
plugin KV.
|
|
43
|
+
- Options for `model`, `acceptKeys`, `maxChars`, `idleDelayMs`,
|
|
44
|
+
`recentMessages` and `system`.
|
|
45
|
+
|
|
46
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.1...HEAD
|
|
47
|
+
[0.1.1]: https://github.com/ozandogrultan/opencode-ghost/compare/v0.1.0...v0.1.1
|
|
48
|
+
[0.1.0]: https://github.com/ozandogrultan/opencode-ghost/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -87,6 +87,8 @@ Pass an options object as the second element of the plugin tuple:
|
|
|
87
87
|
| `idleDelayMs` | `number` | `500` | Debounce after a turn finishes before generating. |
|
|
88
88
|
| `recentMessages` | `number` | `10` | How many recent messages feed the suggestion prompt. |
|
|
89
89
|
| `system` | `string` | built-in | Override the system prompt sent to the suggestion model. |
|
|
90
|
+
| `backOnEmptyLeft` | `boolean` | `false` | Return to the home screen with Left when the prompt is empty. |
|
|
91
|
+
| `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. |
|
|
90
92
|
|
|
91
93
|
## Commands
|
|
92
94
|
|
|
@@ -100,7 +102,11 @@ When enabling, a suggestion is generated immediately for the current session.
|
|
|
100
102
|
session's recent messages.
|
|
101
103
|
- Asks a model (your `small_model` by default) for one short next user message,
|
|
102
104
|
in a throwaway hidden session with tools disabled; the session is deleted
|
|
103
|
-
immediately afterwards.
|
|
105
|
+
immediately afterwards. Every hidden-session call is scoped to the current
|
|
106
|
+
session's project directory, hidden sessions carry a metadata tag that
|
|
107
|
+
survives the server's automatic title generation, and a periodic sweep (plus
|
|
108
|
+
shutdown cleanup) reaps any leftovers — including sessions orphaned by a
|
|
109
|
+
crash — with no configuration required.
|
|
104
110
|
- Renders the result through the `session_prompt` host slot into the prompt's
|
|
105
111
|
`hint` row, and accepts it into the input via the prompt ref.
|
|
106
112
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-ghost",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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",
|
|
@@ -18,12 +18,19 @@
|
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
|
+
"CHANGELOG.md",
|
|
21
22
|
"LICENSE",
|
|
22
23
|
"README.md"
|
|
23
24
|
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
24
28
|
"scripts": {
|
|
25
29
|
"typecheck": "tsc --noEmit",
|
|
26
|
-
"test": "bun test"
|
|
30
|
+
"test": "bun test && bash tests/changelog.sh",
|
|
31
|
+
"test:unit": "bun test",
|
|
32
|
+
"test:changelog": "bash tests/changelog.sh",
|
|
33
|
+
"prepublishOnly": "bun run test && bun run typecheck"
|
|
27
34
|
},
|
|
28
35
|
"engines": {
|
|
29
36
|
"opencode": ">=1.18.0"
|
package/src/options.ts
CHANGED
|
@@ -2,6 +2,8 @@ export type PromptSuggestOptions = {
|
|
|
2
2
|
enabled?: boolean
|
|
3
3
|
model?: string
|
|
4
4
|
acceptKeys?: string[]
|
|
5
|
+
backOnEmptyLeft?: boolean
|
|
6
|
+
internalSessionMarkerDir?: string
|
|
5
7
|
maxChars?: number
|
|
6
8
|
idleDelayMs?: number
|
|
7
9
|
recentMessages?: number
|
|
@@ -12,6 +14,8 @@ export type ResolvedOptions = {
|
|
|
12
14
|
enabled: boolean
|
|
13
15
|
model: string | undefined
|
|
14
16
|
acceptKeys: string[]
|
|
17
|
+
backOnEmptyLeft: boolean
|
|
18
|
+
internalSessionMarkerDir: string | undefined
|
|
15
19
|
maxChars: number
|
|
16
20
|
idleDelayMs: number
|
|
17
21
|
recentMessages: number
|
|
@@ -20,11 +24,23 @@ export type ResolvedOptions = {
|
|
|
20
24
|
|
|
21
25
|
export const DEFAULT_SYSTEM = [
|
|
22
26
|
"You write the next message that the USER of a coding agent would send.",
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
"
|
|
27
|
+
"Write what the user says AFTER the assistant's latest reply.",
|
|
28
|
+
"",
|
|
29
|
+
"Rules:",
|
|
30
|
+
"- Move the conversation forward. Never repeat, quote, or paraphrase the user's own previous message.",
|
|
31
|
+
"- Sound like the user: short and direct, one line, plain text only.",
|
|
32
|
+
'- If the assistant proposed a next step, a brief affirmation such as "Yes." or "Go ahead." is best.',
|
|
33
|
+
"- If the assistant asked a question, answer it briefly.",
|
|
34
|
+
"- No markdown, no quotes, no backticks, no preamble.",
|
|
35
|
+
"",
|
|
36
|
+
"Examples:",
|
|
37
|
+
"Assistant: I ran the tests - 13 passed, 0 failed.",
|
|
38
|
+
"Next user message: great, thanks",
|
|
39
|
+
"Assistant: That changes the hook contract. Want me to proceed?",
|
|
40
|
+
"Next user message: yes, go ahead",
|
|
41
|
+
"Assistant: Which model should the small tasks use?",
|
|
42
|
+
"Next user message: the fast free one",
|
|
43
|
+
"",
|
|
28
44
|
"If no reply makes sense, output exactly: NONE",
|
|
29
45
|
].join("\n")
|
|
30
46
|
|
|
@@ -48,6 +64,11 @@ export function resolveOptions(options: PromptSuggestOptions | undefined): Resol
|
|
|
48
64
|
Array.isArray(input.acceptKeys) && input.acceptKeys.length > 0
|
|
49
65
|
? input.acceptKeys
|
|
50
66
|
: [...DEFAULTS.acceptKeys],
|
|
67
|
+
backOnEmptyLeft: input.backOnEmptyLeft ?? false,
|
|
68
|
+
internalSessionMarkerDir:
|
|
69
|
+
typeof input.internalSessionMarkerDir === "string" && input.internalSessionMarkerDir.trim()
|
|
70
|
+
? input.internalSessionMarkerDir.trim()
|
|
71
|
+
: undefined,
|
|
51
72
|
maxChars: typeof maxChars === "number" && maxChars > 0 ? maxChars : DEFAULTS.maxChars,
|
|
52
73
|
idleDelayMs:
|
|
53
74
|
typeof idleDelayMs === "number" && idleDelayMs >= 0 ? idleDelayMs : DEFAULTS.idleDelayMs,
|
package/src/text.ts
CHANGED
|
@@ -32,3 +32,19 @@ export function normalize(raw: string, maxChars: number): string | undefined {
|
|
|
32
32
|
if (cleaned.length <= maxChars) return cleaned
|
|
33
33
|
return cleaned.slice(0, maxChars - 1).trimEnd() + "…"
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
export function isEcho(suggestion: string, previous: string): boolean {
|
|
37
|
+
const strip = (value: string) =>
|
|
38
|
+
value
|
|
39
|
+
.replace(/\s+/g, " ")
|
|
40
|
+
.trim()
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/[.!?,;:]+$/, "")
|
|
43
|
+
const next = strip(suggestion)
|
|
44
|
+
const last = strip(previous)
|
|
45
|
+
if (!next || !last) return false
|
|
46
|
+
if (next === last) return true
|
|
47
|
+
if (next.length >= 12 && last.includes(next)) return true
|
|
48
|
+
if (last.length >= 12 && next.includes(last)) return true
|
|
49
|
+
return false
|
|
50
|
+
}
|
package/src/tui.tsx
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import type { TuiPlugin, TuiPromptRef } from "@opencode-ai/plugin/tui"
|
|
3
3
|
import { createSignal } from "solid-js"
|
|
4
|
+
import * as fs from "node:fs"
|
|
5
|
+
import * as path from "node:path"
|
|
4
6
|
import { resolveOptions, type PromptSuggestOptions } from "./options"
|
|
5
|
-
import { clip, normalize, parseModel } from "./text"
|
|
7
|
+
import { clip, isEcho, normalize, parseModel } from "./text"
|
|
6
8
|
|
|
7
9
|
const HIDDEN_TITLE = "ghost-hidden"
|
|
10
|
+
// Survives the server's automatic title generation, which renames the hidden
|
|
11
|
+
// session after the first prompt and would otherwise make it invisible to a
|
|
12
|
+
// title-based sweep.
|
|
13
|
+
const GHOST_METADATA = { ghost: "opencode-ghost" } as const
|
|
14
|
+
const ORPHAN_SWEEP_INTERVAL_MS = 30_000
|
|
15
|
+
const ORPHAN_MIN_AGE_MS = 15_000
|
|
8
16
|
const MAX_MESSAGE_CHARS = 800
|
|
9
17
|
const MAX_TRANSCRIPT_CHARS = 6000
|
|
10
18
|
const ENABLED_KEY = "ghost.enabled"
|
|
@@ -30,8 +38,226 @@ const HIDDEN_TOOLS = {
|
|
|
30
38
|
lsp: false,
|
|
31
39
|
}
|
|
32
40
|
|
|
41
|
+
function markPending(dir: string, pending: string) {
|
|
42
|
+
try {
|
|
43
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
44
|
+
fs.writeFileSync(pending, "")
|
|
45
|
+
} catch {
|
|
46
|
+
// best effort
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function markSession(dir: string, pending: string, id: string, directory: string | undefined) {
|
|
51
|
+
try {
|
|
52
|
+
fs.writeFileSync(
|
|
53
|
+
path.join(dir, `id-${id}`),
|
|
54
|
+
JSON.stringify(directory ? { directory, pid: process.pid } : { pid: process.pid }),
|
|
55
|
+
)
|
|
56
|
+
fs.rmSync(pending, { force: true })
|
|
57
|
+
} catch {
|
|
58
|
+
// best effort
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function unmarkSession(dir: string, pending: string, id: string | undefined, deleted: boolean) {
|
|
63
|
+
try {
|
|
64
|
+
fs.rmSync(pending, { force: true })
|
|
65
|
+
} catch {
|
|
66
|
+
// best effort
|
|
67
|
+
}
|
|
68
|
+
if (!id || !deleted) return
|
|
69
|
+
try {
|
|
70
|
+
fs.rmSync(path.join(dir, `id-${id}`), { force: true })
|
|
71
|
+
} catch {
|
|
72
|
+
// best effort
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isGhostSession(session: { title?: unknown; metadata?: unknown }): boolean {
|
|
77
|
+
if ((session as { title?: unknown }).title === HIDDEN_TITLE) return true
|
|
78
|
+
const metadata = (session as { metadata?: Record<string, unknown> }).metadata
|
|
79
|
+
return (
|
|
80
|
+
typeof metadata === "object" &&
|
|
81
|
+
metadata !== null &&
|
|
82
|
+
(metadata as Record<string, unknown>).ghost === GHOST_METADATA.ghost
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function processIsRunning(pid: number): boolean {
|
|
87
|
+
try {
|
|
88
|
+
process.kill(pid, 0)
|
|
89
|
+
return true
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isNotFound(error: unknown): boolean {
|
|
96
|
+
const candidate = error as { status?: number; response?: { status?: number }; message?: string }
|
|
97
|
+
const status = candidate?.status ?? candidate?.response?.status
|
|
98
|
+
if (status === 404) return true
|
|
99
|
+
return typeof candidate?.message === "string" && /not found/i.test(candidate.message)
|
|
100
|
+
}
|
|
101
|
+
|
|
33
102
|
const tui: TuiPlugin = async (api, rawOptions) => {
|
|
34
103
|
const opts = resolveOptions(rawOptions as PromptSuggestOptions | undefined)
|
|
104
|
+
// In-flight hidden sessions (id -> directory). A Map so dispose and the
|
|
105
|
+
// sweep can scope get/delete calls even while a generation is running.
|
|
106
|
+
const activeHidden = new Map<string, string | undefined>()
|
|
107
|
+
|
|
108
|
+
// Directory of the visible session the suggestion belongs to, falling back
|
|
109
|
+
// to the TUI working directory. Every hidden-session call is scoped with it:
|
|
110
|
+
// the server resolves sessions per directory, and an unscoped call is the
|
|
111
|
+
// historical reason failed generations left sessions behind.
|
|
112
|
+
const resolveParentDirectory = (visibleSessionID: string): string | undefined => {
|
|
113
|
+
try {
|
|
114
|
+
const local = api.state.session.get(visibleSessionID) as { directory?: unknown } | undefined
|
|
115
|
+
if (typeof local?.directory === "string" && local.directory) return local.directory
|
|
116
|
+
} catch {
|
|
117
|
+
// fall through to the cwd
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
const cwd = (api.state as { path?: { directory?: unknown } }).path?.directory
|
|
121
|
+
if (typeof cwd === "string" && cwd) return cwd
|
|
122
|
+
} catch {
|
|
123
|
+
// unavailable in some test harnesses; callers fall back to the
|
|
124
|
+
// server-reported directory instead.
|
|
125
|
+
}
|
|
126
|
+
return undefined
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const markerPaths = () => {
|
|
130
|
+
const dir = opts.internalSessionMarkerDir
|
|
131
|
+
if (!dir) return [] as { entry: string; marker: string; ageMs: number }[]
|
|
132
|
+
let names: string[]
|
|
133
|
+
try {
|
|
134
|
+
names = fs.readdirSync(dir).filter((entry) => /^id-ses_[\w-]+$/.test(entry))
|
|
135
|
+
} catch {
|
|
136
|
+
return [] as { entry: string; marker: string; ageMs: number }[]
|
|
137
|
+
}
|
|
138
|
+
const now = Date.now()
|
|
139
|
+
return names.map((entry) => {
|
|
140
|
+
const marker = path.join(dir, entry)
|
|
141
|
+
let ageMs = Number.POSITIVE_INFINITY
|
|
142
|
+
try {
|
|
143
|
+
ageMs = now - fs.statSync(marker).mtimeMs
|
|
144
|
+
} catch {
|
|
145
|
+
// Unreadable mtime: treat as old so it gets swept.
|
|
146
|
+
}
|
|
147
|
+
return { entry, marker, ageMs }
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Deletes a ghost session and reports whether it is confirmed gone. A
|
|
152
|
+
// `directory` is passed whenever known: the server scopes deletion by it, and
|
|
153
|
+
// omitting it is why failed generations left sessions behind. A non-true
|
|
154
|
+
// delete response is re-checked by existence rather than assumed to have
|
|
155
|
+
// failed, so a session already removed (or removed concurrently) still counts
|
|
156
|
+
// as gone and its marker is not kept forever.
|
|
157
|
+
const removeHiddenSession = async (sessionID: string, directory?: string): Promise<boolean> => {
|
|
158
|
+
const target = directory ? { sessionID, directory } : { sessionID }
|
|
159
|
+
try {
|
|
160
|
+
const result = await api.client.session.delete(target)
|
|
161
|
+
if (result.data === true) return true
|
|
162
|
+
} catch (error) {
|
|
163
|
+
// Only a definitive "not found" means it is gone; anything else (server
|
|
164
|
+
// down, permissions) must keep the marker so a later sweep retries.
|
|
165
|
+
return isNotFound(error)
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
await api.client.session.get(target)
|
|
169
|
+
return false
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return isNotFound(error)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Reaps abandoned ghost sessions in this process and from crashed ones. Two
|
|
176
|
+
// phases:
|
|
177
|
+
//
|
|
178
|
+
// - Marker files (when `internalSessionMarkerDir` is configured) are
|
|
179
|
+
// authoritative proof this plugin created the session, so they are deleted
|
|
180
|
+
// regardless of title: the server auto-titles the hidden session after the
|
|
181
|
+
// first prompt, and the old title check let every retitled orphan live
|
|
182
|
+
// forever. It deliberately does not skip markers by owner pid anymore: a
|
|
183
|
+
// failed delete in a still-running TUI left its own markers behind forever,
|
|
184
|
+
// because the old startup-only recovery treated a live pid as
|
|
185
|
+
// "not orphaned". Fresh markers (younger than ORPHAN_MIN_AGE_MS) whose
|
|
186
|
+
// owner is still running are left alone so an in-flight generation in
|
|
187
|
+
// another process is not swept mid-write.
|
|
188
|
+
// - A list-based sweep needs no configuration and catches everything the
|
|
189
|
+
// markers miss (default setup, older versions, deleted marker dirs). Only
|
|
190
|
+
// sessions still identifiable as ghost (title or metadata tag) and quiet
|
|
191
|
+
// past the grace window are deleted, and never in-flight generations of
|
|
192
|
+
// this process.
|
|
193
|
+
const sweepOrphans = async () => {
|
|
194
|
+
for (const { entry, marker, ageMs } of markerPaths()) {
|
|
195
|
+
const sessionID = entry.slice(3)
|
|
196
|
+
if (activeHidden.has(sessionID)) continue
|
|
197
|
+
let directory: string | undefined
|
|
198
|
+
let pid: number | undefined
|
|
199
|
+
try {
|
|
200
|
+
const parsed = JSON.parse(fs.readFileSync(marker, "utf8")) as {
|
|
201
|
+
directory?: string
|
|
202
|
+
pid?: number
|
|
203
|
+
}
|
|
204
|
+
if (typeof parsed?.directory === "string" && parsed.directory) directory = parsed.directory
|
|
205
|
+
if (Number.isInteger(parsed?.pid) && (parsed.pid as number) > 0) pid = parsed.pid as number
|
|
206
|
+
} catch {
|
|
207
|
+
// Corrupt/empty marker: no directory or pid; still attempt cleanup.
|
|
208
|
+
}
|
|
209
|
+
// A fresh marker whose owner is still running may be an in-flight
|
|
210
|
+
// generation in another TUI. A dead owner, or one quiet past the grace
|
|
211
|
+
// window, is an orphan.
|
|
212
|
+
if (pid !== undefined && ageMs < ORPHAN_MIN_AGE_MS && processIsRunning(pid)) continue
|
|
213
|
+
|
|
214
|
+
if (await removeHiddenSession(sessionID, directory)) {
|
|
215
|
+
try {
|
|
216
|
+
fs.rmSync(marker, { force: true })
|
|
217
|
+
} catch {
|
|
218
|
+
// best effort
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const listed = await api.client.session.list()
|
|
225
|
+
const sessions = (listed as { data?: unknown }).data
|
|
226
|
+
if (!Array.isArray(sessions)) return
|
|
227
|
+
const now = Date.now()
|
|
228
|
+
for (const session of sessions) {
|
|
229
|
+
const candidate = session as {
|
|
230
|
+
id?: unknown
|
|
231
|
+
title?: unknown
|
|
232
|
+
metadata?: unknown
|
|
233
|
+
directory?: unknown
|
|
234
|
+
time?: { created?: unknown; updated?: unknown }
|
|
235
|
+
}
|
|
236
|
+
if (typeof candidate.id !== "string" || !candidate.id) continue
|
|
237
|
+
if (activeHidden.has(candidate.id)) continue
|
|
238
|
+
if (!isGhostSession(candidate)) continue
|
|
239
|
+
const updated =
|
|
240
|
+
typeof candidate.time?.updated === "number"
|
|
241
|
+
? candidate.time.updated
|
|
242
|
+
: typeof candidate.time?.created === "number"
|
|
243
|
+
? candidate.time.created
|
|
244
|
+
: undefined
|
|
245
|
+
if (updated !== undefined && now - updated < ORPHAN_MIN_AGE_MS) continue
|
|
246
|
+
const directory =
|
|
247
|
+
typeof candidate.directory === "string" && candidate.directory
|
|
248
|
+
? candidate.directory
|
|
249
|
+
: undefined
|
|
250
|
+
await removeHiddenSession(candidate.id, directory)
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
// Listing is best effort (server down, older SDK): markers and the
|
|
254
|
+
// generation finally-block still cover the common cases.
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
void sweepOrphans()
|
|
258
|
+
const sweepTimer = setInterval(() => void sweepOrphans(), ORPHAN_SWEEP_INTERVAL_MS)
|
|
259
|
+
sweepTimer.unref?.()
|
|
260
|
+
|
|
35
261
|
const [ghost, setGhost] = createSignal<{ sessionID: string; text: string } | undefined>()
|
|
36
262
|
let promptRef: TuiPromptRef | undefined
|
|
37
263
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -96,17 +322,49 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
96
322
|
return `${lines.join("\n").slice(-MAX_TRANSCRIPT_CHARS)}\n\nTask: write the user's next message.`
|
|
97
323
|
}
|
|
98
324
|
|
|
325
|
+
const lastUserText = (sessionID: string) => {
|
|
326
|
+
const message = api.state.session.messages(sessionID).filter((m) => m.role === "user").at(-1)
|
|
327
|
+
if (!message) return ""
|
|
328
|
+
return api.state
|
|
329
|
+
.part(message.id)
|
|
330
|
+
.filter((part) => part.type === "text")
|
|
331
|
+
.map((part) => (part.type === "text" ? part.text : ""))
|
|
332
|
+
.join(" ")
|
|
333
|
+
}
|
|
334
|
+
|
|
99
335
|
const generate = async (sessionID: string): Promise<string | undefined> => {
|
|
100
336
|
const transcript = buildTranscript(sessionID)
|
|
101
337
|
if (!transcript) return undefined
|
|
102
338
|
|
|
103
|
-
const
|
|
104
|
-
const
|
|
105
|
-
if (
|
|
339
|
+
const markerDir = opts.internalSessionMarkerDir
|
|
340
|
+
const pending = markerDir ? path.join(markerDir, "pending") : undefined
|
|
341
|
+
if (markerDir && pending) markPending(markerDir, pending)
|
|
106
342
|
|
|
343
|
+
let tempID: string | undefined
|
|
344
|
+
let createdDirectory: string | undefined
|
|
345
|
+
// Scope every hidden-session call to the visible session's project: the
|
|
346
|
+
// server resolves sessions per directory, and unscoped calls left orphans
|
|
347
|
+
// behind whenever the default differed.
|
|
348
|
+
const parentDirectory = resolveParentDirectory(sessionID)
|
|
107
349
|
try {
|
|
350
|
+
const created = await api.client.session.create({
|
|
351
|
+
title: HIDDEN_TITLE,
|
|
352
|
+
metadata: { ...GHOST_METADATA },
|
|
353
|
+
...(parentDirectory ? { directory: parentDirectory } : {}),
|
|
354
|
+
})
|
|
355
|
+
tempID = created.data?.id
|
|
356
|
+
if (!tempID) return undefined
|
|
357
|
+
createdDirectory =
|
|
358
|
+
typeof created.data?.directory === "string" && created.data.directory
|
|
359
|
+
? created.data.directory
|
|
360
|
+
: parentDirectory
|
|
361
|
+
activeHidden.set(tempID, createdDirectory)
|
|
362
|
+
if (markerDir && pending)
|
|
363
|
+
markSession(markerDir, pending, tempID, createdDirectory ?? parentDirectory ?? "")
|
|
364
|
+
|
|
108
365
|
const result = await api.client.session.prompt({
|
|
109
366
|
sessionID: tempID,
|
|
367
|
+
...(createdDirectory ? { directory: createdDirectory } : {}),
|
|
110
368
|
system: opts.system,
|
|
111
369
|
tools: { ...HIDDEN_TOOLS },
|
|
112
370
|
model: resolveModel(),
|
|
@@ -117,9 +375,21 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
117
375
|
.filter((part) => part.type === "text")
|
|
118
376
|
.map((part) => (part.type === "text" ? part.text : ""))
|
|
119
377
|
.join("\n")
|
|
120
|
-
|
|
378
|
+
const suggestion = normalize(raw, opts.maxChars)
|
|
379
|
+
if (!suggestion) return undefined
|
|
380
|
+
const previous = lastUserText(sessionID)
|
|
381
|
+
if (previous && isEcho(suggestion, previous)) return undefined
|
|
382
|
+
return suggestion
|
|
121
383
|
} finally {
|
|
122
|
-
|
|
384
|
+
let deleted = false
|
|
385
|
+
if (tempID) {
|
|
386
|
+
activeHidden.delete(tempID)
|
|
387
|
+
// Pass the directory and retry-based confirmation so a failed
|
|
388
|
+
// generation still removes its hidden session. If the session truly
|
|
389
|
+
// cannot be removed yet, the marker is kept and sweepOrphans retries.
|
|
390
|
+
deleted = await removeHiddenSession(tempID, createdDirectory)
|
|
391
|
+
}
|
|
392
|
+
if (markerDir && pending) unmarkSession(markerDir, pending, tempID, deleted)
|
|
123
393
|
}
|
|
124
394
|
}
|
|
125
395
|
|
|
@@ -197,6 +467,35 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
197
467
|
schedule(id)
|
|
198
468
|
})
|
|
199
469
|
|
|
470
|
+
// session.idle is deprecated upstream; the live signal is session.status.
|
|
471
|
+
const offStatus = api.event.on("session.status", (event) => {
|
|
472
|
+
const props = event.properties
|
|
473
|
+
if (props?.status?.type !== "idle") return
|
|
474
|
+
if (!props.sessionID) return
|
|
475
|
+
schedule(props.sessionID)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
const backLayer = api.keymap.registerLayer({
|
|
479
|
+
mode: "base",
|
|
480
|
+
priority: 40,
|
|
481
|
+
bindings: [
|
|
482
|
+
{
|
|
483
|
+
key: "left",
|
|
484
|
+
desc: "Return to the home screen when the prompt is empty",
|
|
485
|
+
preventDefault: false,
|
|
486
|
+
fallthrough: true,
|
|
487
|
+
cmd: () => {
|
|
488
|
+
if (!opts.backOnEmptyLeft) return false
|
|
489
|
+
if (api.route.current.name !== "session") return false
|
|
490
|
+
if (!promptRef) return false
|
|
491
|
+
if (currentInput().trim().length > 0) return false
|
|
492
|
+
api.route.navigate("home")
|
|
493
|
+
return true
|
|
494
|
+
},
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
})
|
|
498
|
+
|
|
200
499
|
api.slots.register({
|
|
201
500
|
order: 60,
|
|
202
501
|
slots: {
|
|
@@ -263,8 +562,19 @@ const tui: TuiPlugin = async (api, rawOptions) => {
|
|
|
263
562
|
|
|
264
563
|
api.lifecycle.onDispose(() => {
|
|
265
564
|
if (timer) clearTimeout(timer)
|
|
565
|
+
clearInterval(sweepTimer)
|
|
266
566
|
offIdle()
|
|
567
|
+
offStatus()
|
|
568
|
+
backLayer()
|
|
267
569
|
disableAccept()
|
|
570
|
+
// A generation in flight at shutdown would otherwise leak its hidden
|
|
571
|
+
// session: the finally-block still runs for the awaiting caller, but if
|
|
572
|
+
// the host drops pending work, fire one last scoped delete per session.
|
|
573
|
+
// The periodic sweep (markers + list) retries anything this misses.
|
|
574
|
+
for (const [sessionID, directory] of activeHidden) {
|
|
575
|
+
void removeHiddenSession(sessionID, directory).catch(() => {})
|
|
576
|
+
}
|
|
577
|
+
activeHidden.clear()
|
|
268
578
|
})
|
|
269
579
|
}
|
|
270
580
|
|