@neuralmux/omp-superwhisper 1.0.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/README.md +205 -0
- package/bin/com.superwhisper.bridge.plist +28 -0
- package/bin/install-bridge-service.ts +140 -0
- package/bin/superwhisper-bridge.ts +287 -0
- package/extensions/constants.ts +4 -0
- package/extensions/host.ts +237 -0
- package/extensions/inbox.ts +83 -0
- package/extensions/message.ts +56 -0
- package/extensions/poll.ts +115 -0
- package/extensions/superwhisper.ts +356 -0
- package/package.json +52 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HostOps — abstraction over host-specific Superwhisper operations.
|
|
3
|
+
*
|
|
4
|
+
* Two implementations:
|
|
5
|
+
* DirectHostOps – runs on macOS, talks to Superwhisper via filesystem + open
|
|
6
|
+
* BridgeHostOps – runs inside a container, proxies to a host daemon via HTTP
|
|
7
|
+
*
|
|
8
|
+
* Selection is automatic:
|
|
9
|
+
* - If SUPERWHISPER_BRIDGE_URL is set → BridgeHostOps
|
|
10
|
+
* - Otherwise → DirectHostOps
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs"
|
|
14
|
+
import { $ } from "bun"
|
|
15
|
+
import type { InboxPayload } from "./inbox"
|
|
16
|
+
import { deliverAgentPayload } from "./inbox"
|
|
17
|
+
import { waitForResponse, type WaitResult } from "./poll"
|
|
18
|
+
import { MESSAGE_DIR } from "./constants"
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Public interface
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export interface HostOps {
|
|
25
|
+
/** Detect the Superwhisper deeplink scheme and whether the app is running. */
|
|
26
|
+
detect(): Promise<{ scheme: string; running: boolean }>
|
|
27
|
+
|
|
28
|
+
/** Deliver an inbox payload (update or dismiss). */
|
|
29
|
+
deliverPayload(payload: InboxPayload, scheme: string): Promise<boolean>
|
|
30
|
+
|
|
31
|
+
/** Write per-session message file. */
|
|
32
|
+
writeMessage(sessionId: string, content: string): Promise<void>
|
|
33
|
+
|
|
34
|
+
/** Read per-session message file; null if missing. */
|
|
35
|
+
readMessage(sessionId: string): Promise<string | null>
|
|
36
|
+
|
|
37
|
+
/** Remove per-session message file. */
|
|
38
|
+
deleteMessage(sessionId: string): Promise<void>
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Wait for Superwhisper to write a response file.
|
|
42
|
+
* Returns { kind: "response", text } | { kind: "empty" } |
|
|
43
|
+
* { kind: "timeout" } | { kind: "cancelled" }
|
|
44
|
+
*/
|
|
45
|
+
waitForResponse(sessionId: string, signal?: AbortSignal): Promise<WaitResult>
|
|
46
|
+
|
|
47
|
+
/** Remove per-session response file. */
|
|
48
|
+
deleteResponse(sessionId: string): Promise<void>
|
|
49
|
+
|
|
50
|
+
/** Is this session disabled for Superwhisper notifications? */
|
|
51
|
+
isSessionDisabled(sessionId: string): Promise<boolean>
|
|
52
|
+
|
|
53
|
+
/** Enable or disable Superwhisper for this session. */
|
|
54
|
+
setSessionDisabled(sessionId: string, disabled: boolean): Promise<void>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Direct (on-host) implementation
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
class DirectHostOps implements HostOps {
|
|
62
|
+
private scheme: string | null = null
|
|
63
|
+
|
|
64
|
+
async detect(): Promise<{ scheme: string; running: boolean }> {
|
|
65
|
+
const envScheme = process.env.SUPERWHISPER_SCHEME
|
|
66
|
+
if (envScheme) {
|
|
67
|
+
this.scheme = envScheme
|
|
68
|
+
} else if (!this.scheme) {
|
|
69
|
+
try {
|
|
70
|
+
await $`pgrep -f DerivedData.*superwhisper.app`.quiet()
|
|
71
|
+
this.scheme = "superwhisper-debug"
|
|
72
|
+
} catch {
|
|
73
|
+
this.scheme = "superwhisper"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const running = await this.checkRunning()
|
|
77
|
+
return { scheme: this.scheme, running }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async checkRunning(): Promise<boolean> {
|
|
81
|
+
try {
|
|
82
|
+
await $`pgrep -x superwhisper`.quiet()
|
|
83
|
+
return true
|
|
84
|
+
} catch {
|
|
85
|
+
return false
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async deliverPayload(payload: InboxPayload, scheme: string): Promise<boolean> {
|
|
90
|
+
return deliverAgentPayload(payload, scheme)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async writeMessage(sessionId: string, content: string): Promise<void> {
|
|
94
|
+
mkdirSync(MESSAGE_DIR, { recursive: true })
|
|
95
|
+
writeFileSync(`${MESSAGE_DIR}/${sessionId}-message.txt`, content)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async readMessage(sessionId: string): Promise<string | null> {
|
|
99
|
+
try {
|
|
100
|
+
return readFileSync(`${MESSAGE_DIR}/${sessionId}-message.txt`, "utf8")
|
|
101
|
+
} catch {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async deleteMessage(sessionId: string): Promise<void> {
|
|
107
|
+
try { unlinkSync(`${MESSAGE_DIR}/${sessionId}-message.txt`) } catch {}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async waitForResponse(sessionId: string, signal?: AbortSignal): Promise<WaitResult> {
|
|
111
|
+
const path = `${MESSAGE_DIR}/${sessionId}-response.txt`
|
|
112
|
+
return waitForResponse(path, { signal })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async deleteResponse(sessionId: string): Promise<void> {
|
|
116
|
+
try { unlinkSync(`${MESSAGE_DIR}/${sessionId}-response.txt`) } catch {}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async isSessionDisabled(sessionId: string): Promise<boolean> {
|
|
120
|
+
return existsSync(`${MESSAGE_DIR}/disabled-${sessionId}`)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async setSessionDisabled(sessionId: string, disabled: boolean): Promise<void> {
|
|
124
|
+
mkdirSync(MESSAGE_DIR, { recursive: true })
|
|
125
|
+
const flag = `${MESSAGE_DIR}/disabled-${sessionId}`
|
|
126
|
+
if (disabled) {
|
|
127
|
+
writeFileSync(flag, "")
|
|
128
|
+
} else {
|
|
129
|
+
try { unlinkSync(flag) } catch {}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Bridge (container → host daemon) implementation
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
class BridgeHostOps implements HostOps {
|
|
139
|
+
private url: string
|
|
140
|
+
private cachedScheme: string | null = null
|
|
141
|
+
|
|
142
|
+
constructor(bridgeUrl: string) {
|
|
143
|
+
this.url = bridgeUrl.replace(/\/+$/, "")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async detect(): Promise<{ scheme: string; running: boolean }> {
|
|
147
|
+
const res = await fetch(`${this.url}/health`)
|
|
148
|
+
const data = await res.json() as { scheme: string; running: boolean }
|
|
149
|
+
this.cachedScheme = data.scheme
|
|
150
|
+
return { scheme: data.scheme, running: data.running }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async deliverPayload(payload: InboxPayload, _scheme: string): Promise<boolean> {
|
|
154
|
+
const res = await fetch(`${this.url}/inbox`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: { "content-type": "application/json" },
|
|
157
|
+
body: JSON.stringify(payload),
|
|
158
|
+
})
|
|
159
|
+
const data = await res.json() as { ok: boolean }
|
|
160
|
+
return data.ok === true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async writeMessage(sessionId: string, content: string): Promise<void> {
|
|
164
|
+
await fetch(`${this.url}/session/${encodeURIComponent(sessionId)}/message`, {
|
|
165
|
+
method: "PUT",
|
|
166
|
+
headers: { "content-type": "text/plain" },
|
|
167
|
+
body: content,
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async readMessage(sessionId: string): Promise<string | null> {
|
|
172
|
+
const res = await fetch(
|
|
173
|
+
`${this.url}/session/${encodeURIComponent(sessionId)}/message`,
|
|
174
|
+
)
|
|
175
|
+
return res.status === 200 ? await res.text() : null
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async deleteMessage(sessionId: string): Promise<void> {
|
|
179
|
+
await fetch(`${this.url}/session/${encodeURIComponent(sessionId)}/message`, {
|
|
180
|
+
method: "DELETE",
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async waitForResponse(sessionId: string, signal?: AbortSignal): Promise<WaitResult> {
|
|
185
|
+
const res = await fetch(
|
|
186
|
+
`${this.url}/session/${encodeURIComponent(sessionId)}/response?timeout=1800000`,
|
|
187
|
+
{ signal },
|
|
188
|
+
)
|
|
189
|
+
return (await res.json()) as WaitResult
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async deleteResponse(sessionId: string): Promise<void> {
|
|
193
|
+
await fetch(`${this.url}/session/${encodeURIComponent(sessionId)}/response`, {
|
|
194
|
+
method: "DELETE",
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async isSessionDisabled(sessionId: string): Promise<boolean> {
|
|
199
|
+
const res = await fetch(
|
|
200
|
+
`${this.url}/session/${encodeURIComponent(sessionId)}/disabled`,
|
|
201
|
+
)
|
|
202
|
+
const data = await res.json() as { disabled: boolean }
|
|
203
|
+
return data.disabled === true
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async setSessionDisabled(sessionId: string, disabled: boolean): Promise<void> {
|
|
207
|
+
await fetch(
|
|
208
|
+
`${this.url}/session/${encodeURIComponent(sessionId)}/disabled`,
|
|
209
|
+
{ method: disabled ? "PUT" : "DELETE" },
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Factory
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
let _hostOps: HostOps | null = null
|
|
219
|
+
|
|
220
|
+
export function createHostOps(): HostOps {
|
|
221
|
+
const bridgeUrl = process.env.SUPERWHISPER_BRIDGE_URL
|
|
222
|
+
if (bridgeUrl) {
|
|
223
|
+
return new BridgeHostOps(bridgeUrl)
|
|
224
|
+
}
|
|
225
|
+
return new DirectHostOps()
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Singleton accessor — creates on first call, returns cached thereafter. */
|
|
229
|
+
export function getHostOps(): HostOps {
|
|
230
|
+
if (!_hostOps) _hostOps = createHostOps()
|
|
231
|
+
return _hostOps
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Reset singleton (useful for testing). */
|
|
235
|
+
export function __resetHostOpsForTest(): void {
|
|
236
|
+
_hostOps = null
|
|
237
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, renameSync, unlinkSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { $ } from "bun"
|
|
5
|
+
|
|
6
|
+
export interface InboxPayload {
|
|
7
|
+
kind: "update" | "dismiss"
|
|
8
|
+
sessionId?: string
|
|
9
|
+
requestId?: string
|
|
10
|
+
agent?: string
|
|
11
|
+
status?: string
|
|
12
|
+
summary?: string
|
|
13
|
+
message?: string
|
|
14
|
+
messageFile?: string
|
|
15
|
+
responseFile?: string
|
|
16
|
+
cwd?: string
|
|
17
|
+
project?: string
|
|
18
|
+
branch?: string
|
|
19
|
+
title?: string
|
|
20
|
+
hookPid?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let INBOX_DIR = join(
|
|
24
|
+
homedir(),
|
|
25
|
+
"Library/Application Support/superwhisper/agent/inbox",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
export function __setInboxDirForTest(dir: string): void {
|
|
29
|
+
INBOX_DIR = dir
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function writeInboxPayload(payload: InboxPayload): boolean {
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(INBOX_DIR, { recursive: true })
|
|
35
|
+
} catch {
|
|
36
|
+
return false
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const id = crypto.randomUUID()
|
|
40
|
+
const tmpPath = join(INBOX_DIR, `${id}.json.tmp`)
|
|
41
|
+
const finalPath = join(INBOX_DIR, `${id}.json`)
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
writeFileSync(tmpPath, JSON.stringify(payload))
|
|
45
|
+
renameSync(tmpPath, finalPath)
|
|
46
|
+
return true
|
|
47
|
+
} catch {
|
|
48
|
+
try {
|
|
49
|
+
unlinkSync(tmpPath)
|
|
50
|
+
} catch {}
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function isSuperwhisperRunning(): Promise<boolean> {
|
|
56
|
+
try {
|
|
57
|
+
await $`pgrep -x superwhisper`.quiet()
|
|
58
|
+
return true
|
|
59
|
+
} catch {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function fireAgentWake(scheme: string): Promise<void> {
|
|
65
|
+
const url = `${scheme}://agent-wake`
|
|
66
|
+
try {
|
|
67
|
+
await $`open ${url}`.quiet()
|
|
68
|
+
} catch {
|
|
69
|
+
// wake is best-effort
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function deliverAgentPayload(
|
|
74
|
+
payload: InboxPayload,
|
|
75
|
+
scheme: string,
|
|
76
|
+
): Promise<boolean> {
|
|
77
|
+
const wrote = writeInboxPayload(payload)
|
|
78
|
+
const running = await isSuperwhisperRunning()
|
|
79
|
+
if (!running) {
|
|
80
|
+
await fireAgentWake(scheme)
|
|
81
|
+
}
|
|
82
|
+
return wrote
|
|
83
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract the last assistant text from an agent_end message list.
|
|
3
|
+
* Returns "" if no assistant message or no text content.
|
|
4
|
+
*/
|
|
5
|
+
export function extractLastAssistantText(messages: any[]): string {
|
|
6
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
7
|
+
const m = messages[i]
|
|
8
|
+
if (m?.role !== "assistant") continue
|
|
9
|
+
const content = Array.isArray(m.content) ? m.content : []
|
|
10
|
+
const text = content
|
|
11
|
+
.filter((c: any) => c?.type === "text")
|
|
12
|
+
.map((c: any) => c.text || "")
|
|
13
|
+
.join("\n")
|
|
14
|
+
.trim()
|
|
15
|
+
if (text) return text
|
|
16
|
+
return ""
|
|
17
|
+
}
|
|
18
|
+
return ""
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The last assistant message — used to inspect stopReason / tool calls.
|
|
23
|
+
*/
|
|
24
|
+
export function getLastAssistant(messages: any[]): any | undefined {
|
|
25
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
26
|
+
const m = messages[i]
|
|
27
|
+
if (m?.role === "assistant") return m
|
|
28
|
+
}
|
|
29
|
+
return undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Determine if the turn truly ended (vs. the model wanting to call more tools).
|
|
34
|
+
* OMP marks completed turns with stopReason "stop"; "toolUse" means the loop
|
|
35
|
+
* is about to run tools and isn't actually done.
|
|
36
|
+
*/
|
|
37
|
+
export function isEndTurn(message: any | undefined): boolean {
|
|
38
|
+
if (!message) return false
|
|
39
|
+
return message.stopReason === "stop"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function extractSummary(text: string): string {
|
|
43
|
+
if (!text) return ""
|
|
44
|
+
|
|
45
|
+
const maxLength = 200
|
|
46
|
+
|
|
47
|
+
if (text.length <= maxLength) return text
|
|
48
|
+
|
|
49
|
+
const sentenceEnd = text.substring(0, maxLength).lastIndexOf(". ")
|
|
50
|
+
if (sentenceEnd > 100) return text.substring(0, sentenceEnd + 1)
|
|
51
|
+
|
|
52
|
+
const wordEnd = text.substring(0, maxLength).lastIndexOf(" ")
|
|
53
|
+
if (wordEnd > 150) return text.substring(0, wordEnd) + "..."
|
|
54
|
+
|
|
55
|
+
return text.substring(0, maxLength) + "..."
|
|
56
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { existsSync, readFileSync, watch } from "node:fs"
|
|
2
|
+
import { dirname, basename } from "node:path"
|
|
3
|
+
import { POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./constants"
|
|
4
|
+
|
|
5
|
+
export type WaitResult =
|
|
6
|
+
| { kind: "response"; text: string }
|
|
7
|
+
| { kind: "empty" }
|
|
8
|
+
| { kind: "cancelled" }
|
|
9
|
+
| { kind: "timeout" }
|
|
10
|
+
|
|
11
|
+
export interface WaitOptions {
|
|
12
|
+
timeoutMs?: number
|
|
13
|
+
intervalMs?: number
|
|
14
|
+
signal?: AbortSignal
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const EMPTY_GRACE_MS = 2_000
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Wait for a response file to appear and contain text. Uses `fs.watch` on the
|
|
21
|
+
* parent directory so it reacts within milliseconds, with a periodic fallback
|
|
22
|
+
* for filesystems where watch events are flaky.
|
|
23
|
+
*
|
|
24
|
+
* - File missing for the full timeout → `timeout`
|
|
25
|
+
* - File created and stays empty for EMPTY_GRACE_MS → `empty` (Superwhisper X / double-ESC)
|
|
26
|
+
* - File created with text → `response`
|
|
27
|
+
* - `signal` aborts → `cancelled`
|
|
28
|
+
*
|
|
29
|
+
* The grace period prevents a race where Superwhisper creates an empty
|
|
30
|
+
* placeholder file before writing the transcription.
|
|
31
|
+
*/
|
|
32
|
+
export function waitForResponse(
|
|
33
|
+
path: string,
|
|
34
|
+
options: WaitOptions = {},
|
|
35
|
+
): Promise<WaitResult> {
|
|
36
|
+
const timeoutMs = options.timeoutMs ?? POLL_TIMEOUT_MS
|
|
37
|
+
const intervalMs = options.intervalMs ?? POLL_INTERVAL_MS
|
|
38
|
+
const { signal } = options
|
|
39
|
+
|
|
40
|
+
const dir = dirname(path)
|
|
41
|
+
const file = basename(path)
|
|
42
|
+
|
|
43
|
+
function tryReadText(): string | null {
|
|
44
|
+
try {
|
|
45
|
+
if (!existsSync(path)) return null
|
|
46
|
+
const raw = readFileSync(path, "utf8")
|
|
47
|
+
return raw
|
|
48
|
+
} catch {
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return new Promise<WaitResult>((resolve) => {
|
|
54
|
+
if (signal?.aborted) {
|
|
55
|
+
resolve({ kind: "cancelled" })
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let settled = false
|
|
60
|
+
let watcher: ReturnType<typeof watch> | undefined
|
|
61
|
+
let intervalId: ReturnType<typeof setInterval> | undefined
|
|
62
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
63
|
+
let abortHandler: (() => void) | undefined
|
|
64
|
+
let emptySeenAt: number | null = null
|
|
65
|
+
|
|
66
|
+
const finish = (result: WaitResult) => {
|
|
67
|
+
if (settled) return
|
|
68
|
+
settled = true
|
|
69
|
+
try {
|
|
70
|
+
watcher?.close()
|
|
71
|
+
} catch {}
|
|
72
|
+
if (intervalId) clearInterval(intervalId)
|
|
73
|
+
if (timeoutId) clearTimeout(timeoutId)
|
|
74
|
+
if (abortHandler && signal) signal.removeEventListener("abort", abortHandler)
|
|
75
|
+
resolve(result)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const check = () => {
|
|
79
|
+
if (settled) return
|
|
80
|
+
const text = tryReadText()
|
|
81
|
+
if (text === null) {
|
|
82
|
+
emptySeenAt = null
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
const trimmed = text.trim()
|
|
86
|
+
if (trimmed.length > 0) {
|
|
87
|
+
finish({ kind: "response", text: trimmed })
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
if (emptySeenAt === null) {
|
|
91
|
+
emptySeenAt = Date.now()
|
|
92
|
+
} else if (Date.now() - emptySeenAt >= EMPTY_GRACE_MS) {
|
|
93
|
+
finish({ kind: "empty" })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
check()
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
watcher = watch(dir, { persistent: false }, (_eventType, filename) => {
|
|
101
|
+
if (filename === file) check()
|
|
102
|
+
})
|
|
103
|
+
} catch {
|
|
104
|
+
// dir missing or watch unsupported — interval is enough
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
intervalId = setInterval(check, intervalMs)
|
|
108
|
+
timeoutId = setTimeout(() => finish({ kind: "timeout" }), timeoutMs)
|
|
109
|
+
|
|
110
|
+
if (signal) {
|
|
111
|
+
abortHandler = () => finish({ kind: "cancelled" })
|
|
112
|
+
signal.addEventListener("abort", abortHandler, { once: true })
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
}
|