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 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.0",
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
- "It must sound like the user, not the assistant: short and direct.",
24
- "Never restate or summarize the assistant's reply.",
25
- 'If the assistant proposed a next step, a brief affirmation such as "Yes." or "Go ahead." is best.',
26
- "If the assistant asked a question, answer it briefly.",
27
- "Output one line, plain text only: no markdown, no quotes, no backticks, no preamble.",
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 created = await api.client.session.create({ title: HIDDEN_TITLE })
104
- const tempID = created.data?.id
105
- if (!tempID) return undefined
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
- return normalize(raw, opts.maxChars)
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
- await api.client.session.delete({ sessionID: tempID }).catch(() => undefined)
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