acrylblends 1.0.7 → 1.0.9
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/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/lib/{audit-BD8_2pkh.d.ts → audit-Dw-siYTp.d.ts} +7 -1
- package/lib/cordis/door.d.ts +4 -1
- package/lib/cordis/door.js +76 -5
- package/lib/door/host.d.ts +1 -1
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +16 -0
- package/lib/door/page.js +81 -4
- package/lib/{snapshot-text-m13Pk-AI.js → snapshot-text-CBi3EMnj.js} +19 -7
- package/package.json +1 -1
- package/plugins/acryl-door/plugin.json +3 -0
- package/plugins/acryl-door/src/host/channel.ts +20 -3
- package/plugins/acryl-door/src/host/door.ts +1 -1
- package/plugins/acryl-door/src/host/page-socket.ts +78 -0
- package/plugins/acryl-door/src/index.ts +12 -1
- package/plugins/acryl-door/src/page/channel.ts +47 -5
- package/plugins/acryl-door/src/page/index.ts +9 -1
- package/plugins/acryl-door/src/page/socket-source.ts +75 -0
- package/plugins/acryl-door/src/shell/protocol.ts +3 -0
- package/plugins/agent-panel/client/main.ts +154 -32
- package/plugins/agent-panel/headless.ts +20 -0
- package/plugins/agent-panel/index.ts +193 -50
- package/plugins/desktop/door-shell.ts +6 -0
- package/plugins/desktop/main.ts +58 -3
- package/plugins/desktop/menu.ts +60 -0
- package/plugins/desktop/window.ts +62 -2
- package/templates/agent/AGENTS.md +3 -1
- package/templates/agent/src/routes.ts +11 -1
- package/templates/agent/test/app.test.ts +20 -0
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { UiControlError, parseChannelToPage, type ChannelToHost } from './protocol.ts'
|
|
11
11
|
import type { UiDriver } from './driver/driver.ts'
|
|
12
|
+
import { type SocketLike, type SocketSource, socketSource } from './socket-source.ts'
|
|
12
13
|
|
|
13
14
|
/** The slice of an `EventSource` the channel uses, so tests can supply their own. */
|
|
14
15
|
export interface ChannelSource {
|
|
@@ -32,6 +33,11 @@ export interface PageChannelOptions {
|
|
|
32
33
|
/** Where the door's routes are, e.g. `/api/acryl-door`. */
|
|
33
34
|
readonly base: string
|
|
34
35
|
readonly createSource?: (url: string) => ChannelSource
|
|
36
|
+
/** Opens the WebSocket form of the channel (`<base>/channel/ws`); the browser's own by default. Pass `transport: 'stream'` to keep to the event stream. */
|
|
37
|
+
readonly createSocket?: (url: string) => SocketSource
|
|
38
|
+
readonly transport?: 'auto' | 'stream'
|
|
39
|
+
/** Where the page says it fell back to the event stream (the console by default). */
|
|
40
|
+
readonly warn?: (message: string) => void
|
|
35
41
|
readonly post?: (url: string, body: string) => Promise<unknown>
|
|
36
42
|
readonly silenceMs?: number
|
|
37
43
|
readonly now?: () => number
|
|
@@ -42,6 +48,9 @@ export class PageChannel {
|
|
|
42
48
|
private reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
|
43
49
|
private watchdog: ReturnType<typeof setInterval> | undefined
|
|
44
50
|
private backoff = BACKOFF_START_MS
|
|
51
|
+
/** The WebSocket form is tried first; once it fails to open (a proxy that cannot upgrade, no `ws` on the server) the page keeps to the event stream. */
|
|
52
|
+
private socketAllowed: boolean
|
|
53
|
+
private socketReady = false
|
|
45
54
|
private lastHeard = 0
|
|
46
55
|
private disposed = false
|
|
47
56
|
private readonly now: () => number
|
|
@@ -53,33 +62,56 @@ export class PageChannel {
|
|
|
53
62
|
this.driver = driver
|
|
54
63
|
this.options = options
|
|
55
64
|
this.now = options.now ?? (() => Date.now())
|
|
65
|
+
// A test that supplies only a stream keeps to it; the browser's own WebSocket is tried when the page supplies neither.
|
|
66
|
+
this.socketAllowed = options.transport !== 'stream' && (options.createSocket !== undefined || (options.createSource === undefined && typeof WebSocket !== 'undefined'))
|
|
56
67
|
}
|
|
57
68
|
|
|
58
69
|
connect(): void {
|
|
59
70
|
if (this.disposed || this.source !== undefined) return
|
|
60
|
-
const create = this.options.createSource ?? ((url: string): ChannelSource => new EventSource(url))
|
|
61
71
|
const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}${this.options.shellWindow === undefined ? '' : `&shell=${String(this.options.shellWindow)}`}`
|
|
62
|
-
const
|
|
72
|
+
const viaSocket = this.socketAllowed
|
|
73
|
+
this.socketReady = false
|
|
74
|
+
const source: ChannelSource = viaSocket
|
|
75
|
+
? (this.options.createSocket ?? ((url: string): SocketSource => socketSource(new WebSocket(url) as unknown as SocketLike)))(`${socketBase(this.options.base)}/channel/ws?${query}`)
|
|
76
|
+
: (this.options.createSource ?? ((url: string): ChannelSource => new EventSource(url) as unknown as ChannelSource))(`${this.options.base}/channel?${query}`)
|
|
63
77
|
this.source = source
|
|
64
78
|
this.lastHeard = this.now()
|
|
65
79
|
const heard = (): void => {
|
|
66
80
|
this.lastHeard = this.now()
|
|
67
81
|
this.backoff = BACKOFF_START_MS
|
|
68
82
|
}
|
|
69
|
-
source.addEventListener('ready',
|
|
83
|
+
source.addEventListener('ready', () => {
|
|
84
|
+
this.socketReady = true
|
|
85
|
+
heard()
|
|
86
|
+
})
|
|
70
87
|
source.addEventListener('ping', heard)
|
|
71
88
|
source.addEventListener('call', (event) => {
|
|
72
89
|
heard()
|
|
73
90
|
if (typeof event.data === 'string') this.answer(event.data)
|
|
74
91
|
})
|
|
75
|
-
// An EventSource retries by itself after a network error; once it gives up (CLOSED), the channel starts over.
|
|
76
|
-
|
|
92
|
+
// An EventSource retries by itself after a network error; once it gives up (CLOSED), the channel starts over. A WebSocket that never became ready is not retried: the page
|
|
93
|
+
// falls back to the event stream, and says so once.
|
|
94
|
+
source.onerror = () => {
|
|
95
|
+
if (source.readyState !== CLOSED) return
|
|
96
|
+
if (viaSocket && !this.socketReady) this.fallBack(source)
|
|
97
|
+
else this.restart(source)
|
|
98
|
+
}
|
|
77
99
|
const silence = this.options.silenceMs ?? SILENCE_MS
|
|
78
100
|
this.watchdog = setInterval(() => {
|
|
79
101
|
if (this.now() - this.lastHeard > silence) this.restart(source)
|
|
80
102
|
}, Math.max(50, Math.floor(silence / 3)))
|
|
81
103
|
}
|
|
82
104
|
|
|
105
|
+
/** The WebSocket did not open: use the event stream from now on, and say why once. */
|
|
106
|
+
private fallBack(source: ChannelSource): void {
|
|
107
|
+
if (this.source !== source) return
|
|
108
|
+
this.socketAllowed = false
|
|
109
|
+
const warn = this.options.warn ?? ((message: string) => { console.warn(message) })
|
|
110
|
+
warn('acryl-door: the page channel could not open a WebSocket (the server has no upgrade route, or a proxy refused it); using server-sent events, which count against the browser\'s six connections per host')
|
|
111
|
+
this.stop()
|
|
112
|
+
this.connect()
|
|
113
|
+
}
|
|
114
|
+
|
|
83
115
|
/** Tell the Host whether this window is the one in front. */
|
|
84
116
|
reportFocus(focused: boolean): void {
|
|
85
117
|
this.send({ t: 'focus', focused })
|
|
@@ -149,9 +181,19 @@ export class PageChannel {
|
|
|
149
181
|
}
|
|
150
182
|
|
|
151
183
|
private send(message: ChannelToHost): void {
|
|
184
|
+
// On a WebSocket the answer goes up the socket: a request would queue behind the browser's six connections per host.
|
|
185
|
+
const source = this.source as Partial<SocketSource> | undefined
|
|
186
|
+
if (source?.send?.(JSON.stringify(message)) === true) return
|
|
152
187
|
const post = this.options.post ?? ((url: string, body: string) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body }))
|
|
153
188
|
post(`${this.options.base}/channel/answer`, JSON.stringify({ window: this.options.windowId, message })).catch(() => {
|
|
154
189
|
// The Host is gone or restarting; the call it made settles on its side.
|
|
155
190
|
})
|
|
156
191
|
}
|
|
157
192
|
}
|
|
193
|
+
|
|
194
|
+
/** The WebSocket address of the door's routes: this page's own origin, `ws:` or `wss:`, then the base path. */
|
|
195
|
+
function socketBase(base: string): string {
|
|
196
|
+
const here = (globalThis as { location?: { protocol: string; host: string } }).location
|
|
197
|
+
if (here === undefined) return base
|
|
198
|
+
return `${here.protocol === 'https:' ? 'wss' : 'ws'}://${here.host}${base}`
|
|
199
|
+
}
|
|
@@ -22,7 +22,15 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
22
22
|
const detachInput = clock.attach(document)
|
|
23
23
|
const removeIndicator = mountIndicator(driver, document)
|
|
24
24
|
const shellWindow = Number(/\bAcrylWindow\/(\d{1,9})\b/u.exec(view.navigator?.userAgent ?? '')?.[1])
|
|
25
|
-
|
|
25
|
+
// `localStorage.setItem('acryl-door-transport', 'stream')` keeps this browser to the event stream (to compare, or behind a proxy that cannot upgrade).
|
|
26
|
+
const transport = ((): 'auto' | 'stream' => {
|
|
27
|
+
try {
|
|
28
|
+
return view.localStorage.getItem('acryl-door-transport') === 'stream' ? 'stream' : 'auto'
|
|
29
|
+
} catch {
|
|
30
|
+
return 'auto'
|
|
31
|
+
}
|
|
32
|
+
})()
|
|
33
|
+
const channel = new PageChannel(driver, { windowId, focused, base, transport, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
|
|
26
34
|
const reportFocus = (): void => { channel.reportFocus(focused()) }
|
|
27
35
|
view.addEventListener('focus', reportFocus)
|
|
28
36
|
view.addEventListener('blur', reportFocus)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A WebSocket that looks like the event stream the channel already runs on: `addEventListener('call' | 'ready' | 'ping')`, `onerror`, `close()` and `readyState` (0 connecting, 1 open,
|
|
3
|
+
* 2 closed). Each frame from the Host is `{ "e": <event>, "d": <data> }`. It can also send, so the page's answers go up the same socket and need no request of their own: a request
|
|
4
|
+
* would wait behind the browser's six connections per host, which is what the socket is for.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ChannelSource } from './channel.ts'
|
|
8
|
+
|
|
9
|
+
/** The slice of the browser's `WebSocket` used here, so a test can supply its own. */
|
|
10
|
+
export interface SocketLike {
|
|
11
|
+
readyState: number
|
|
12
|
+
onopen: ((event: Event) => void) | null
|
|
13
|
+
onmessage: ((event: MessageEvent) => void) | null
|
|
14
|
+
onerror: ((event: Event) => void) | null
|
|
15
|
+
onclose: ((event: CloseEvent) => void) | null
|
|
16
|
+
send(data: string): void
|
|
17
|
+
close(code?: number, reason?: string): void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SocketSource extends ChannelSource {
|
|
21
|
+
/** Send one message up; false when the socket is not open (the caller posts it instead). */
|
|
22
|
+
send(text: string): boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function socketSource(socket: SocketLike): SocketSource {
|
|
26
|
+
const listeners = new Map<string, Array<(event: MessageEvent) => void>>()
|
|
27
|
+
let closed = false
|
|
28
|
+
const source: SocketSource = {
|
|
29
|
+
onerror: null,
|
|
30
|
+
get readyState(): number {
|
|
31
|
+
return closed || socket.readyState >= 2 ? 2 : socket.readyState
|
|
32
|
+
},
|
|
33
|
+
addEventListener(type, listener) {
|
|
34
|
+
listeners.set(type, [...(listeners.get(type) ?? []), listener])
|
|
35
|
+
},
|
|
36
|
+
send(text) {
|
|
37
|
+
if (socket.readyState !== 1) return false
|
|
38
|
+
try {
|
|
39
|
+
socket.send(text)
|
|
40
|
+
return true
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
close() {
|
|
46
|
+
closed = true
|
|
47
|
+
socket.onmessage = null
|
|
48
|
+
socket.onerror = null
|
|
49
|
+
socket.onclose = null
|
|
50
|
+
try {
|
|
51
|
+
socket.close(1000, 'page left')
|
|
52
|
+
} catch {
|
|
53
|
+
// Already closed.
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
socket.onmessage = (event) => {
|
|
58
|
+
if (typeof event.data !== 'string') return
|
|
59
|
+
let frame: { e?: unknown; d?: unknown }
|
|
60
|
+
try {
|
|
61
|
+
frame = JSON.parse(event.data) as { e?: unknown; d?: unknown }
|
|
62
|
+
} catch {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (typeof frame.e !== 'string') return
|
|
66
|
+
for (const listener of listeners.get(frame.e) ?? []) listener({ data: typeof frame.d === 'string' ? frame.d : '' } as MessageEvent)
|
|
67
|
+
}
|
|
68
|
+
// An error or a close that this side did not ask for is the channel's cue to start over, or to fall back to the event stream.
|
|
69
|
+
const lost = (event: Event): void => {
|
|
70
|
+
if (!closed) source.onerror?.(event)
|
|
71
|
+
}
|
|
72
|
+
socket.onerror = lost
|
|
73
|
+
socket.onclose = lost
|
|
74
|
+
return source
|
|
75
|
+
}
|
|
@@ -27,6 +27,9 @@ export interface ShellWindow {
|
|
|
27
27
|
readonly focused: boolean
|
|
28
28
|
readonly width: number
|
|
29
29
|
readonly height: number
|
|
30
|
+
/** Where the window's top left corner is on the screen (so two windows on top of each other can be told). */
|
|
31
|
+
readonly x?: number
|
|
32
|
+
readonly y?: number
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
export interface ShellMenuItem {
|
|
@@ -37,6 +37,77 @@ async function* events(response: Response): AsyncGenerator<PanelEvent> {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** What a run is read from: its events, and a way to end the run. A socket and a stream give the same events. */
|
|
41
|
+
interface RunSource {
|
|
42
|
+
readonly events: AsyncIterable<PanelEvent>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A WebSocket is not one of the browser's six connections per host, so the panel's Stop and a run still work when two windows stream their own state. */
|
|
46
|
+
let socketBroken = false
|
|
47
|
+
let activeSocket: WebSocket | undefined
|
|
48
|
+
|
|
49
|
+
/** Open the run socket and send the token and the first action. Rejects when the page cannot upgrade (a proxy, a server without `ws`): the caller falls back to the stream. */
|
|
50
|
+
function openSocket(token: string, action: Record<string, unknown>): Promise<WebSocket> {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
const socket = new WebSocket(
|
|
53
|
+
`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${PREFIX}/run/ws`,
|
|
54
|
+
)
|
|
55
|
+
const timer = setTimeout(() => {
|
|
56
|
+
socket.close()
|
|
57
|
+
reject(new Error('the socket did not open in time'))
|
|
58
|
+
}, 4000)
|
|
59
|
+
socket.onopen = () => {
|
|
60
|
+
clearTimeout(timer)
|
|
61
|
+
socket.send(JSON.stringify({ t: 'hello', token }))
|
|
62
|
+
socket.send(JSON.stringify(action))
|
|
63
|
+
resolve(socket)
|
|
64
|
+
}
|
|
65
|
+
socket.onerror = () => {
|
|
66
|
+
clearTimeout(timer)
|
|
67
|
+
reject(new Error('the server refused the upgrade'))
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The frames of an open run socket as panel events, until it closes. */
|
|
73
|
+
async function* socketEvents(socket: WebSocket): AsyncGenerator<PanelEvent> {
|
|
74
|
+
const queue: PanelEvent[] = []
|
|
75
|
+
let wake: (() => void) | undefined
|
|
76
|
+
let closed: CloseEvent | undefined
|
|
77
|
+
socket.onmessage = (message) => {
|
|
78
|
+
try {
|
|
79
|
+
const frame = JSON.parse(String(message.data)) as { e?: unknown; d?: unknown }
|
|
80
|
+
if (typeof frame.e === 'string')
|
|
81
|
+
queue.push({ event: frame.e, data: (frame.d ?? {}) as Record<string, unknown> })
|
|
82
|
+
} catch {
|
|
83
|
+
// Not a frame of ours.
|
|
84
|
+
}
|
|
85
|
+
wake?.()
|
|
86
|
+
}
|
|
87
|
+
socket.onclose = (event) => {
|
|
88
|
+
closed = event
|
|
89
|
+
wake?.()
|
|
90
|
+
}
|
|
91
|
+
for (;;) {
|
|
92
|
+
const next = queue.shift()
|
|
93
|
+
if (next !== undefined) {
|
|
94
|
+
yield next
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if (closed !== undefined) {
|
|
98
|
+
if (closed.code === 4401)
|
|
99
|
+
yield {
|
|
100
|
+
event: 'error',
|
|
101
|
+
data: { message: 'the panel was not accepted (reload the page and try again)' },
|
|
102
|
+
}
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
await new Promise<void>((resolve) => {
|
|
106
|
+
wake = resolve
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
40
111
|
const css = `
|
|
41
112
|
:host { all: initial }
|
|
42
113
|
* { box-sizing: border-box; font: 14px system-ui, sans-serif }
|
|
@@ -68,6 +139,7 @@ textarea { width: 100%; min-height: 64px; resize: vertical; padding: 8px; border
|
|
|
68
139
|
.row { display: flex; gap: 8px; align-items: center }
|
|
69
140
|
.row button, header button { padding: 6px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; cursor: pointer }
|
|
70
141
|
.row button.send { background: #1f2937; color: #fff; border-color: #1f2937 }
|
|
142
|
+
.row button:disabled { opacity: 0.45; cursor: not-allowed }
|
|
71
143
|
`
|
|
72
144
|
|
|
73
145
|
async function start(): Promise<void> {
|
|
@@ -194,32 +266,88 @@ async function start(): Promise<void> {
|
|
|
194
266
|
// loads next follows it again. Only Stop cancels it.
|
|
195
267
|
const headers = { 'content-type': 'application/json', 'x-agent-panel-token': session.token }
|
|
196
268
|
let following = false
|
|
197
|
-
stop.addEventListener('click', () =>
|
|
269
|
+
stop.addEventListener('click', () => {
|
|
270
|
+
// On the run's socket when there is one: a request would wait behind the browser's six connections per host.
|
|
271
|
+
if (activeSocket?.readyState === WebSocket.OPEN)
|
|
272
|
+
activeSocket.send(JSON.stringify({ t: 'stop' }))
|
|
273
|
+
else void fetch(`${PREFIX}/stop`, { method: 'POST', headers })
|
|
274
|
+
})
|
|
275
|
+
/** A run over the socket when the server upgrades, else over the stream, with one console warning saying why. */
|
|
276
|
+
const openRun = async (
|
|
277
|
+
action: { t: 'ask'; message: string; driver: string } | { t: 'follow' },
|
|
278
|
+
): Promise<RunSource | { error: string } | undefined> => {
|
|
279
|
+
if (!socketBroken && typeof WebSocket !== 'undefined') {
|
|
280
|
+
try {
|
|
281
|
+
const socket = await openSocket(session.token, action)
|
|
282
|
+
activeSocket = socket
|
|
283
|
+
socket.addEventListener('close', () => {
|
|
284
|
+
if (activeSocket === socket) activeSocket = undefined
|
|
285
|
+
})
|
|
286
|
+
return { events: socketEvents(socket) }
|
|
287
|
+
} catch (cause) {
|
|
288
|
+
socketBroken = true
|
|
289
|
+
console.warn(
|
|
290
|
+
`agent-panel: the run could not open a WebSocket (${cause instanceof Error ? cause.message : String(cause)}); using server-sent events, which count against the browser's six connections per host`,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (action.t === 'ask') {
|
|
295
|
+
const response = await fetch(`${PREFIX}/ask`, {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers,
|
|
298
|
+
body: JSON.stringify({ message: action.message, driver: action.driver }),
|
|
299
|
+
})
|
|
300
|
+
if (!response.ok)
|
|
301
|
+
return {
|
|
302
|
+
error:
|
|
303
|
+
((await response.json().catch(() => ({}))) as { error?: string }).error ??
|
|
304
|
+
`The panel answered ${response.status}.`,
|
|
305
|
+
}
|
|
306
|
+
return { events: events(response) }
|
|
307
|
+
}
|
|
308
|
+
const latest = await fetch(`${PREFIX}/run`, { headers })
|
|
309
|
+
return latest.ok && (latest.headers.get('content-type') ?? '').includes('text/event-stream')
|
|
310
|
+
? { events: events(latest) }
|
|
311
|
+
: undefined
|
|
312
|
+
}
|
|
198
313
|
/**
|
|
199
314
|
* Show one run, as its events come, until it ends. `own` is true for a run this page just started (its question is on screen); a run found on load is shown from its question, and
|
|
200
315
|
* a run that had already ended before the page loaded is only shown, never reloads the page.
|
|
201
316
|
*/
|
|
202
|
-
const watch = async (
|
|
317
|
+
const watch = async (source: RunSource, own: boolean): Promise<void> => {
|
|
203
318
|
let live = true
|
|
204
|
-
following = true
|
|
205
|
-
send.disabled = true
|
|
206
|
-
stop.disabled = false
|
|
207
319
|
let changed = false
|
|
208
320
|
let answer: HTMLElement | undefined
|
|
209
321
|
let answered = ''
|
|
210
|
-
// A coding agent started headless says little until it is done, so the panel says it is working, and for how long.
|
|
211
|
-
|
|
212
|
-
|
|
322
|
+
// A coding agent started headless says little until it is done, so the panel says it is working, and for how long (from when the run began, not from when this page loaded).
|
|
323
|
+
let started = Date.now()
|
|
324
|
+
let working: HTMLElement | undefined
|
|
325
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
213
326
|
const tick = (): void => {
|
|
214
|
-
|
|
327
|
+
if (working === undefined) return
|
|
328
|
+
const seconds = Math.max(0, Math.floor((Date.now() - started) / 1000))
|
|
215
329
|
working.textContent = `Working... ${String(Math.floor(seconds / 60))}:${String(seconds % 60).padStart(2, '0')}`
|
|
216
330
|
log.append(working)
|
|
217
331
|
}
|
|
218
|
-
|
|
219
|
-
|
|
332
|
+
// The panel is busy once there is a run to show. A page that just loaded asks the server for the latest run first, and "there is none" must not make a message typed in that moment
|
|
333
|
+
// wait or vanish, so a run that is only being looked for does not count (it begins at its first event).
|
|
334
|
+
const begin = (): void => {
|
|
335
|
+
if (working !== undefined) return
|
|
336
|
+
following = true
|
|
337
|
+
send.disabled = true
|
|
338
|
+
stop.disabled = false
|
|
339
|
+
working = line('', 'working')
|
|
340
|
+
tick()
|
|
341
|
+
timer = setInterval(tick, 1000)
|
|
342
|
+
}
|
|
343
|
+
if (own) begin()
|
|
220
344
|
try {
|
|
221
|
-
for await (const { event, data } of events
|
|
345
|
+
for await (const { event, data } of source.events) {
|
|
346
|
+
if (event === 'idle') continue
|
|
347
|
+
begin()
|
|
222
348
|
if (event === 'run') {
|
|
349
|
+
if (typeof data.startedAt === 'number') started = data.startedAt
|
|
350
|
+
tick()
|
|
223
351
|
live = data.ended !== true
|
|
224
352
|
if (!own) {
|
|
225
353
|
if (typeof data.message === 'string') line(data.message, 'me')
|
|
@@ -250,11 +378,13 @@ async function start(): Promise<void> {
|
|
|
250
378
|
} catch (error) {
|
|
251
379
|
line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
|
|
252
380
|
} finally {
|
|
253
|
-
clearInterval(timer)
|
|
254
|
-
working
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
381
|
+
if (timer !== undefined) clearInterval(timer)
|
|
382
|
+
if (working !== undefined) {
|
|
383
|
+
working.remove()
|
|
384
|
+
following = false
|
|
385
|
+
send.disabled = false
|
|
386
|
+
stop.disabled = true
|
|
387
|
+
}
|
|
258
388
|
}
|
|
259
389
|
// A plugin was installed or removed while this page watched: the new feature is served by the running app, so the page only needs to be loaded again (unless something is being typed).
|
|
260
390
|
// A run that ended before this page loaded was already shown after its reload, and does not reload it again.
|
|
@@ -270,29 +400,21 @@ async function start(): Promise<void> {
|
|
|
270
400
|
input.value = ''
|
|
271
401
|
line(message, 'me')
|
|
272
402
|
try {
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
})
|
|
278
|
-
if (!response.ok) {
|
|
279
|
-
line(
|
|
280
|
-
((await response.json().catch(() => ({}))) as { error?: string }).error ??
|
|
281
|
-
`The panel answered ${response.status}.`,
|
|
282
|
-
'err',
|
|
283
|
-
)
|
|
403
|
+
const run = await openRun({ t: 'ask', message, driver: driver.value })
|
|
404
|
+
if (run === undefined) return
|
|
405
|
+
if ('error' in run) {
|
|
406
|
+
line(run.error, 'err')
|
|
284
407
|
return
|
|
285
408
|
}
|
|
286
|
-
await watch(
|
|
409
|
+
await watch(run, true)
|
|
287
410
|
} catch (error) {
|
|
288
411
|
line(`The request failed: ${error instanceof Error ? error.message : String(error)}`, 'err')
|
|
289
412
|
}
|
|
290
413
|
})
|
|
291
414
|
// The latest run, if there is one: shown again after a reload (its question, its events), and followed if it is still going.
|
|
292
415
|
try {
|
|
293
|
-
const latest = await
|
|
294
|
-
if (latest
|
|
295
|
-
void watch(latest, false)
|
|
416
|
+
const latest = await openRun({ t: 'follow' })
|
|
417
|
+
if (latest !== undefined && !('error' in latest)) void watch(latest, false)
|
|
296
418
|
} catch {
|
|
297
419
|
// No run to show.
|
|
298
420
|
}
|
|
@@ -195,3 +195,23 @@ export function headlessDriver(name: HeadlessName, root: string): ChatDriver {
|
|
|
195
195
|
},
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Why a driver name cannot be used to ask a question, in a sentence: it is a terminal (picked in the panel, not asked over `/ask`), it is a coding agent that is not installed or not
|
|
201
|
+
* able to start, or pi without a key to reach its model. Nothing when the name is not one of the headless agents.
|
|
202
|
+
*/
|
|
203
|
+
export function whyNotChat(
|
|
204
|
+
name: string,
|
|
205
|
+
kind: 'chat' | 'terminal' | undefined,
|
|
206
|
+
): string | undefined {
|
|
207
|
+
if (kind === 'terminal')
|
|
208
|
+
return `"${name}" is a terminal driver: pick it in the panel to open its terminal; /ask runs the chat drivers`
|
|
209
|
+
if (name !== 'claude' && name !== 'codex' && name !== 'pi') return undefined
|
|
210
|
+
if (!onPath(name))
|
|
211
|
+
return `${name} was not found on the PATH of the app, so the panel cannot start it`
|
|
212
|
+
const launch = resolveLaunch(name, [])
|
|
213
|
+
if ('error' in launch) return launch.error
|
|
214
|
+
if (name === 'pi' && !piHasDeepseekKey())
|
|
215
|
+
return 'pi needs a DeepSeek key: set DEEPSEEK_API_KEY in the environment of the app, or log in with pi once (it keeps the key in ~/.pi/agent/auth.json)'
|
|
216
|
+
return undefined
|
|
217
|
+
}
|