acrylblends 1.0.5 → 1.0.6
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/docs/framework/agents/index.md +1 -1
- package/lib/{audit-D5z9uwOP.d.ts → audit-GcLtvNLV.d.ts} +4 -2
- package/lib/cordis/door.d.ts +90 -4
- package/lib/cordis/door.js +524 -13
- package/lib/door/host.d.ts +2 -2
- package/lib/door/host.js +1 -1
- package/lib/door/page.d.ts +3 -1
- package/lib/door/page.js +1 -1
- package/lib/door/protocol.d.ts +1 -1
- package/lib/{protocol-C37gPePD.d.ts → protocol-CR31M7K_.d.ts} +2 -0
- package/lib/{snapshot-text-Bbx43mN0.js → snapshot-text-CtQm3n8n.js} +14 -5
- package/package.json +1 -1
- package/plugins/acryl-door/src/cli/acryl.ts +36 -5
- package/plugins/acryl-door/src/host/channel.ts +14 -6
- package/plugins/acryl-door/src/host/door.ts +78 -10
- package/plugins/acryl-door/src/host/page-routes.ts +3 -1
- package/plugins/acryl-door/src/host/shell.ts +147 -0
- package/plugins/acryl-door/src/host/shim.ts +39 -3
- package/plugins/acryl-door/src/host/verbs.ts +39 -2
- package/plugins/acryl-door/src/index.ts +7 -1
- package/plugins/acryl-door/src/page/channel.ts +3 -1
- package/plugins/acryl-door/src/page/index.ts +2 -1
- package/plugins/acryl-door/src/page/protocol.ts +2 -0
- package/plugins/acryl-door/src/shell/protocol.ts +130 -0
- package/plugins/agent-panel/headless.ts +29 -8
- package/plugins/agent-panel/launch.ts +67 -0
- package/plugins/desktop/door-shell.ts +379 -0
- package/plugins/desktop/main.ts +66 -12
- package/templates/agent/AGENTS.md +2 -0
- package/plugins/acryl-router/src/connection.d.ts +0 -25
- package/plugins/acryl-router/src/core/app-base.d.ts +0 -215
- package/plugins/acryl-router/src/core/app.d.ts +0 -19
- package/plugins/acryl-router/src/core/client/client.d.ts +0 -4
- package/plugins/acryl-router/src/core/client/fetch-result-please.d.ts +0 -35
- package/plugins/acryl-router/src/core/client/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/client/types.d.ts +0 -229
- package/plugins/acryl-router/src/core/client/utils.d.ts +0 -18
- package/plugins/acryl-router/src/core/compose.d.ts +0 -14
- package/plugins/acryl-router/src/core/context.d.ts +0 -443
- package/plugins/acryl-router/src/core/helper/accepts/accepts.d.ts +0 -30
- package/plugins/acryl-router/src/core/helper/accepts/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/helper/conninfo/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/helper/conninfo/types.d.ts +0 -36
- package/plugins/acryl-router/src/core/helper/cookie/index.d.ts +0 -25
- package/plugins/acryl-router/src/core/helper/css/common.d.ts +0 -59
- package/plugins/acryl-router/src/core/helper/css/index.d.ts +0 -80
- package/plugins/acryl-router/src/core/helper/dev/index.d.ts +0 -20
- package/plugins/acryl-router/src/core/helper/factory/index.d.ts +0 -82
- package/plugins/acryl-router/src/core/helper/html/index.d.ts +0 -8
- package/plugins/acryl-router/src/core/helper/proxy/index.d.ts +0 -73
- package/plugins/acryl-router/src/core/helper/route/index.d.ts +0 -75
- package/plugins/acryl-router/src/core/helper/ssg/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/helper/ssg/middleware.d.ts +0 -44
- package/plugins/acryl-router/src/core/helper/ssg/plugins.d.ts +0 -27
- package/plugins/acryl-router/src/core/helper/ssg/ssg.d.ts +0 -91
- package/plugins/acryl-router/src/core/helper/ssg/utils.d.ts +0 -16
- package/plugins/acryl-router/src/core/helper/streaming/index.d.ts +0 -8
- package/plugins/acryl-router/src/core/helper/streaming/sse.d.ts +0 -13
- package/plugins/acryl-router/src/core/helper/streaming/stream.d.ts +0 -3
- package/plugins/acryl-router/src/core/helper/streaming/text.d.ts +0 -3
- package/plugins/acryl-router/src/core/helper/streaming/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/helper/testing/index.d.ts +0 -13
- package/plugins/acryl-router/src/core/helper/websocket/index.d.ts +0 -70
- package/plugins/acryl-router/src/core/http-exception.d.ts +0 -58
- package/plugins/acryl-router/src/core/index.d.ts +0 -37
- package/plugins/acryl-router/src/core/jsx/base.d.ts +0 -61
- package/plugins/acryl-router/src/core/jsx/children.d.ts +0 -9
- package/plugins/acryl-router/src/core/jsx/components.d.ts +0 -15
- package/plugins/acryl-router/src/core/jsx/constants.d.ts +0 -6
- package/plugins/acryl-router/src/core/jsx/context.d.ts +0 -51
- package/plugins/acryl-router/src/core/jsx/dom/components.d.ts +0 -10
- package/plugins/acryl-router/src/core/jsx/dom/context.d.ts +0 -3
- package/plugins/acryl-router/src/core/jsx/dom/css.d.ts +0 -83
- package/plugins/acryl-router/src/core/jsx/dom/hooks/index.d.ts +0 -39
- package/plugins/acryl-router/src/core/jsx/dom/intrinsic-element/components.d.ts +0 -16
- package/plugins/acryl-router/src/core/jsx/dom/jsx-dev-runtime.d.ts +0 -8
- package/plugins/acryl-router/src/core/jsx/dom/jsx-runtime.d.ts +0 -7
- package/plugins/acryl-router/src/core/jsx/dom/render.d.ts +0 -73
- package/plugins/acryl-router/src/core/jsx/dom/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/jsx/hooks/index.d.ts +0 -47
- package/plugins/acryl-router/src/core/jsx/index.d.ts +0 -76
- package/plugins/acryl-router/src/core/jsx/intrinsic-element/common.d.ts +0 -6
- package/plugins/acryl-router/src/core/jsx/intrinsic-element/components.d.ts +0 -13
- package/plugins/acryl-router/src/core/jsx/intrinsic-elements.d.ts +0 -735
- package/plugins/acryl-router/src/core/jsx/streaming.d.ts +0 -36
- package/plugins/acryl-router/src/core/jsx/types.d.ts +0 -36
- package/plugins/acryl-router/src/core/jsx/utils.d.ts +0 -4
- package/plugins/acryl-router/src/core/request/constants.d.ts +0 -1
- package/plugins/acryl-router/src/core/request.d.ts +0 -306
- package/plugins/acryl-router/src/core/router/reg-exp-router/index.d.ts +0 -6
- package/plugins/acryl-router/src/core/router/reg-exp-router/matcher.d.ts +0 -7
- package/plugins/acryl-router/src/core/router/reg-exp-router/node.d.ts +0 -13
- package/plugins/acryl-router/src/core/router/reg-exp-router/prepared-router.d.ts +0 -17
- package/plugins/acryl-router/src/core/router/reg-exp-router/router.d.ts +0 -11
- package/plugins/acryl-router/src/core/router/reg-exp-router/trie.d.ts +0 -8
- package/plugins/acryl-router/src/core/router/smart-router/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/router/smart-router/router.d.ts +0 -11
- package/plugins/acryl-router/src/core/router/trie-router/index.d.ts +0 -5
- package/plugins/acryl-router/src/core/router/trie-router/node.d.ts +0 -6
- package/plugins/acryl-router/src/core/router/trie-router/router.d.ts +0 -7
- package/plugins/acryl-router/src/core/router/utils.d.ts +0 -1
- package/plugins/acryl-router/src/core/router.d.ts +0 -97
- package/plugins/acryl-router/src/core/types.d.ts +0 -573
- package/plugins/acryl-router/src/core/utils/accept.d.ts +0 -6
- package/plugins/acryl-router/src/core/utils/body.d.ts +0 -79
- package/plugins/acryl-router/src/core/utils/buffer.d.ts +0 -17
- package/plugins/acryl-router/src/core/utils/color.d.ts +0 -21
- package/plugins/acryl-router/src/core/utils/concurrent.d.ts +0 -11
- package/plugins/acryl-router/src/core/utils/constants.d.ts +0 -4
- package/plugins/acryl-router/src/core/utils/cookie.d.ts +0 -41
- package/plugins/acryl-router/src/core/utils/crypto.d.ts +0 -15
- package/plugins/acryl-router/src/core/utils/handler.d.ts +0 -6
- package/plugins/acryl-router/src/core/utils/headers.d.ts +0 -8
- package/plugins/acryl-router/src/core/utils/html.d.ts +0 -44
- package/plugins/acryl-router/src/core/utils/http-status.d.ts +0 -32
- package/plugins/acryl-router/src/core/utils/mime.d.ts +0 -70
- package/plugins/acryl-router/src/core/utils/stream.d.ts +0 -31
- package/plugins/acryl-router/src/core/utils/types.d.ts +0 -128
- package/plugins/acryl-router/src/core/utils/url.d.ts +0 -44
- package/plugins/acryl-router/src/core/validator/index.d.ts +0 -7
- package/plugins/acryl-router/src/core/validator/utils.d.ts +0 -29
- package/plugins/acryl-router/src/core/validator/validator.d.ts +0 -16
- package/plugins/acryl-router/src/helpers/index.d.ts +0 -17
- package/plugins/acryl-router/src/helpers/schema-validator.d.ts +0 -54
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { MAX_SNAPSHOT_NODES, MAX_WAIT_MS } from '../page/protocol.ts'
|
|
7
7
|
|
|
8
8
|
/** Every verb, in the order `brief` lists them. */
|
|
9
|
-
export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call'] as const
|
|
9
|
+
export const VERBS = ['brief', 'look', 'act', 'plugin-list', 'plugin-add', 'plugin-remove', 'plugin-reload', 'tool-list', 'tool-call', 'window-list', 'window-open', 'window-focus', 'window-close', 'window-resize', 'menu-list', 'menu-click'] as const
|
|
10
10
|
export type Verb = (typeof VERBS)[number]
|
|
11
11
|
|
|
12
12
|
/** The JSON Schema of a verb's arguments, as MCP lists it. */
|
|
@@ -35,13 +35,14 @@ export const VERB_SPECS: Readonly<Record<Verb, VerbSpec>> = {
|
|
|
35
35
|
arguments: none,
|
|
36
36
|
},
|
|
37
37
|
'look': {
|
|
38
|
-
usage: 'look [--cursor <n>] [--max-nodes <n>]: the page as text, with refs',
|
|
38
|
+
usage: 'look [--window <n>] [--cursor <n>] [--max-nodes <n>]: the page as text, with refs',
|
|
39
39
|
description: 'The open page as text: its roles, names, values and visible text, each with a ref to act on. A long page comes in parts (cursor).',
|
|
40
40
|
arguments: {
|
|
41
41
|
type: 'object',
|
|
42
42
|
properties: {
|
|
43
43
|
cursor: { type: 'integer', minimum: 0, description: 'where the previous part ended' },
|
|
44
44
|
maxNodes: { type: 'integer', minimum: 1, maximum: MAX_SNAPSHOT_NODES },
|
|
45
|
+
window: { type: 'integer', minimum: 1, description: 'a desktop window (the id window-list prints); default: the page used last' },
|
|
45
46
|
},
|
|
46
47
|
additionalProperties: false,
|
|
47
48
|
},
|
|
@@ -66,6 +67,7 @@ export const VERB_SPECS: Readonly<Record<Verb, VerbSpec>> = {
|
|
|
66
67
|
name: { type: 'string', description: 'wait: its name' },
|
|
67
68
|
gone: { type: 'boolean', description: 'wait: until it is gone instead' },
|
|
68
69
|
timeoutMs: { type: 'integer', minimum: 0, maximum: MAX_WAIT_MS },
|
|
70
|
+
window: { type: 'integer', minimum: 1, description: 'a desktop window (the id window-list prints); default: the page used last' },
|
|
69
71
|
},
|
|
70
72
|
required: ['op'],
|
|
71
73
|
additionalProperties: false,
|
|
@@ -101,6 +103,41 @@ export const VERB_SPECS: Readonly<Record<Verb, VerbSpec>> = {
|
|
|
101
103
|
description: 'Run one of the app tools that tool-list names.',
|
|
102
104
|
arguments: { type: 'object', properties: { tool: { type: 'string' }, args: { description: 'the tool\'s input' } }, required: ['tool'], additionalProperties: false },
|
|
103
105
|
},
|
|
106
|
+
'window-list': {
|
|
107
|
+
usage: 'window list: the windows of the desktop app',
|
|
108
|
+
description: 'The windows of the desktop app: id, title, path, size, and which one is focused. Needs the desktop app (npm run desktop).',
|
|
109
|
+
arguments: none,
|
|
110
|
+
},
|
|
111
|
+
'window-open': {
|
|
112
|
+
usage: 'window open [--path <path of the app>]: open a new window',
|
|
113
|
+
description: 'Open a new window of the desktop app on a path of the app (default: its start page). Answers with the new window\'s id; look and act take it as window.',
|
|
114
|
+
arguments: { type: 'object', properties: { path: { type: 'string', description: 'a path of this app, e.g. /settings' } }, additionalProperties: false },
|
|
115
|
+
},
|
|
116
|
+
'window-focus': {
|
|
117
|
+
usage: 'window focus --id <n>: bring a window to the front',
|
|
118
|
+
description: 'Bring a window of the desktop app to the front. Look and act without a window then drive it.',
|
|
119
|
+
arguments: { type: 'object', properties: { id: { type: 'integer', minimum: 1, description: 'the id window-list prints' } }, required: ['id'], additionalProperties: false },
|
|
120
|
+
},
|
|
121
|
+
'window-close': {
|
|
122
|
+
usage: 'window close --id <n> [--force]: close a window',
|
|
123
|
+
description: 'Close a window of the desktop app. The last window cannot be closed without force, because that ends the desktop app.',
|
|
124
|
+
arguments: { type: 'object', properties: { id: { type: 'integer', minimum: 1 }, force: { type: 'boolean', description: 'allow closing the last window, which ends the desktop app' } }, required: ['id'], additionalProperties: false },
|
|
125
|
+
},
|
|
126
|
+
'window-resize': {
|
|
127
|
+
usage: 'window resize --id <n> --width <px> --height <px>',
|
|
128
|
+
description: 'Resize a window of the desktop app.',
|
|
129
|
+
arguments: { type: 'object', properties: { id: { type: 'integer', minimum: 1 }, width: { type: 'integer', minimum: 200, maximum: 10_000 }, height: { type: 'integer', minimum: 200, maximum: 10_000 } }, required: ['id', 'width', 'height'], additionalProperties: false },
|
|
130
|
+
},
|
|
131
|
+
'menu-list': {
|
|
132
|
+
usage: 'menu list: the application menu',
|
|
133
|
+
description: 'The desktop app\'s application menu as paths such as File>New Note, with the disabled items marked.',
|
|
134
|
+
arguments: none,
|
|
135
|
+
},
|
|
136
|
+
'menu-click': {
|
|
137
|
+
usage: 'menu click --path "File>New Note" [--force]: press a menu item',
|
|
138
|
+
description: 'Press an item of the desktop app\'s application menu by its path (labels joined with >, as menu-list prints them). A disabled item is refused, and so is one that ends the app (Quit, Close Window on the last window) unless force is set.',
|
|
139
|
+
arguments: { type: 'object', properties: { path: { type: 'string', description: 'e.g. File>New Note' }, force: { type: 'boolean', description: 'allow an item that ends the desktop app' } }, required: ['path'], additionalProperties: false },
|
|
140
|
+
},
|
|
104
141
|
}
|
|
105
142
|
|
|
106
143
|
/** A verb's name in the `tools` registry the built-in chat reads: prefixed, so it never meets an app tool's name. */
|
|
@@ -23,6 +23,7 @@ import { DOOR_PREFIX, privateApi } from './host/http.ts'
|
|
|
23
23
|
import { mcpRoutes } from './host/mcp-routes.ts'
|
|
24
24
|
import { operatorRoutes } from './host/operator-routes.ts'
|
|
25
25
|
import { pageChannelRoutes } from './host/page-routes.ts'
|
|
26
|
+
import { ShellChannel, shellRoutes } from './host/shell.ts'
|
|
26
27
|
import { realNode, writeShims, type ShimTarget } from './host/shim.ts'
|
|
27
28
|
|
|
28
29
|
/** Stable Cordis plugin name, equal to the package name and the Loader row id. */
|
|
@@ -113,9 +114,11 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
113
114
|
}
|
|
114
115
|
const secret = newSecret()
|
|
115
116
|
const channel = new UiChannel()
|
|
117
|
+
const shell = new ShellChannel()
|
|
116
118
|
const audit = new AuditLog(auditPath(stateDir(app.root)))
|
|
117
119
|
const door = new Door({
|
|
118
120
|
channel,
|
|
121
|
+
shell,
|
|
119
122
|
audit,
|
|
120
123
|
app: { name: app.name, root: app.root, url: loopbackUrl(server.url) },
|
|
121
124
|
extensions: () => ctx.get('extensions') as unknown as ExtensionsPort | undefined,
|
|
@@ -124,6 +127,7 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
124
127
|
reloadOnChange: config.reloadOnChange !== false,
|
|
125
128
|
})
|
|
126
129
|
ctx.effect(() => () => { channel.close() }, 'acryl-door: page channel')
|
|
130
|
+
ctx.effect(() => () => { shell.close() }, 'acryl-door: desktop shell channel')
|
|
127
131
|
// One script tag for the page (`page.js`): it imports the module tree served below it.
|
|
128
132
|
const pageModule = new Acryl()
|
|
129
133
|
.get(`${DOOR_PREFIX}/page.js`, c => c.body(`import '${DOOR_PREFIX}/page/main.ts'\n`, 200, { 'content-type': 'text/javascript; charset=utf-8' }))
|
|
@@ -154,6 +158,7 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
154
158
|
DOOR_PREFIX,
|
|
155
159
|
pageModule,
|
|
156
160
|
pageChannelRoutes(channel, { prefix: DOOR_PREFIX }),
|
|
161
|
+
shellRoutes(DOOR_PREFIX, secret, shell),
|
|
157
162
|
operatorRoutes(DOOR_PREFIX, secret, door, reportError),
|
|
158
163
|
mcpRoutes(DOOR_PREFIX, secret, door, reportError),
|
|
159
164
|
))
|
|
@@ -164,7 +169,8 @@ export function apply(ctx: Context, rawConfig: Config = {}): void {
|
|
|
164
169
|
ctx.effect(function* () {
|
|
165
170
|
yield ctx.reflect.provide('door', door)
|
|
166
171
|
writeDoorFile(app.root, loopbackUrl(server.url), secret)
|
|
167
|
-
|
|
172
|
+
// A shim that cannot be replaced (Windows, held open by a running `acryl`) leaves the old one in place; it must not stop the app from starting.
|
|
173
|
+
try { writeShims(app.root, shimTarget()) } catch (cause) { reportError('write .acryl/bin', cause) }
|
|
168
174
|
yield () => { removeDoorFile(app.root, secret) }
|
|
169
175
|
}, 'acryl-door: the door service, .acryl/door.json and .acryl/bin/acryl')
|
|
170
176
|
}
|
|
@@ -26,6 +26,8 @@ const BACKOFF_MAX_MS = 10_000
|
|
|
26
26
|
|
|
27
27
|
export interface PageChannelOptions {
|
|
28
28
|
readonly windowId: string
|
|
29
|
+
/** The desktop window this page is in (`AcrylWindow/<n>` in the user agent, set by the Electron shell), so the Host can drive it by the number `window list` prints. */
|
|
30
|
+
readonly shellWindow?: number
|
|
29
31
|
readonly focused: () => boolean
|
|
30
32
|
/** Where the door's routes are, e.g. `/api/acryl-door`. */
|
|
31
33
|
readonly base: string
|
|
@@ -56,7 +58,7 @@ export class PageChannel {
|
|
|
56
58
|
connect(): void {
|
|
57
59
|
if (this.disposed || this.source !== undefined) return
|
|
58
60
|
const create = this.options.createSource ?? ((url: string): ChannelSource => new EventSource(url))
|
|
59
|
-
const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}`
|
|
61
|
+
const query = `window=${encodeURIComponent(this.options.windowId)}&focused=${this.options.focused() ? '1' : '0'}${this.options.shellWindow === undefined ? '' : `&shell=${String(this.options.shellWindow)}`}`
|
|
60
62
|
const source = create(`${this.options.base}/channel?${query}`)
|
|
61
63
|
this.source = source
|
|
62
64
|
this.lastHeard = this.now()
|
|
@@ -21,7 +21,8 @@ export function startDoor(document: Document, base: string = DOOR_BASE): () => v
|
|
|
21
21
|
const focused = (): boolean => document.visibilityState === 'visible' && document.hasFocus()
|
|
22
22
|
const detachInput = clock.attach(document)
|
|
23
23
|
const removeIndicator = mountIndicator(driver, document)
|
|
24
|
-
const
|
|
24
|
+
const shellWindow = Number(/\bAcrylWindow\/(\d{1,9})\b/u.exec(view.navigator?.userAgent ?? '')?.[1])
|
|
25
|
+
const channel = new PageChannel(driver, { windowId, focused, base, ...(Number.isSafeInteger(shellWindow) && shellWindow > 0 ? { shellWindow } : {}) })
|
|
25
26
|
const reportFocus = (): void => { channel.reportFocus(focused()) }
|
|
26
27
|
view.addEventListener('focus', reportFocus)
|
|
27
28
|
view.addEventListener('blur', reportFocus)
|
|
@@ -212,6 +212,8 @@ export interface AuditEntry {
|
|
|
212
212
|
readonly tool: string
|
|
213
213
|
/** The control touched, when the call had one. */
|
|
214
214
|
readonly target?: { readonly role: string; readonly name: string }
|
|
215
|
+
/** The desktop window the call was about (the number `window list` prints), when it had one. */
|
|
216
|
+
readonly window?: number
|
|
215
217
|
readonly outcome: 'ok' | 'refused' | 'failed'
|
|
216
218
|
/** The error code for a refusal or failure, or a short note for a success. */
|
|
217
219
|
readonly detail?: string
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the door and the desktop shell say to each other. The shell is the Electron main process of `npm run desktop`; it reaches the door as a page does (an event
|
|
3
|
+
* stream down, a POST up) and answers calls about the app's windows and menu. This file is plain data and checks, so the shell side can follow it without importing the door.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** The shell's operations, one per door verb (`window-list` is `acryl window list`). */
|
|
7
|
+
export const SHELL_OPS = ['window-list', 'window-open', 'window-focus', 'window-close', 'window-resize', 'menu-list', 'menu-click'] as const
|
|
8
|
+
export type ShellOp = (typeof SHELL_OPS)[number]
|
|
9
|
+
|
|
10
|
+
export type ShellRequest =
|
|
11
|
+
| { readonly op: 'window-list' }
|
|
12
|
+
| { readonly op: 'window-open'; readonly path?: string }
|
|
13
|
+
| { readonly op: 'window-focus'; readonly id: number }
|
|
14
|
+
/** `force`: the window may be the last, and closing the last one ends the desktop app (and with it the server it started). */
|
|
15
|
+
| { readonly op: 'window-close'; readonly id: number; readonly force?: boolean }
|
|
16
|
+
| { readonly op: 'window-resize'; readonly id: number; readonly width: number; readonly height: number }
|
|
17
|
+
| { readonly op: 'menu-list' }
|
|
18
|
+
/** `force`: the item may end the desktop app (Quit, or Close Window on the last window). */
|
|
19
|
+
| { readonly op: 'menu-click'; readonly path: string; readonly force?: boolean }
|
|
20
|
+
|
|
21
|
+
export interface ShellWindow {
|
|
22
|
+
/** Electron's own window id: the number `window list` prints and `--window` takes. */
|
|
23
|
+
readonly id: number
|
|
24
|
+
readonly title: string
|
|
25
|
+
/** The path (with query) the window shows, never the origin. */
|
|
26
|
+
readonly path: string
|
|
27
|
+
readonly focused: boolean
|
|
28
|
+
readonly width: number
|
|
29
|
+
readonly height: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ShellMenuItem {
|
|
33
|
+
/** Labels from the top of the menu, joined with `>`: `File>New Note`. */
|
|
34
|
+
readonly path: string
|
|
35
|
+
readonly label: string
|
|
36
|
+
readonly enabled: boolean
|
|
37
|
+
/** True for an item that ends the desktop app (Quit, or Close Window): `menu click` presses it only with `force`. */
|
|
38
|
+
readonly ends?: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type ShellResult =
|
|
42
|
+
| { readonly windows: readonly ShellWindow[] }
|
|
43
|
+
| { readonly items: readonly ShellMenuItem[] }
|
|
44
|
+
| { readonly ok: true; readonly detail: string; readonly id?: number }
|
|
45
|
+
|
|
46
|
+
/** Door to shell. */
|
|
47
|
+
export interface ShellCall {
|
|
48
|
+
readonly t: 'call'
|
|
49
|
+
readonly id: number
|
|
50
|
+
readonly request: ShellRequest
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Shell to door. */
|
|
54
|
+
export type ShellAnswer =
|
|
55
|
+
| { readonly t: 'result'; readonly id: number; readonly value: ShellResult }
|
|
56
|
+
| { readonly t: 'error'; readonly id: number; readonly code: string; readonly message: string }
|
|
57
|
+
|
|
58
|
+
export type ShellErrorCode = 'invalid' | 'no-desktop' | 'no-such-window' | 'last-window' | 'no-such-menu-item' | 'disabled' | 'quits-app' | 'timeout' | 'disconnected' | 'unloaded' | 'aborted' | 'failed'
|
|
59
|
+
|
|
60
|
+
export class ShellError extends Error {
|
|
61
|
+
readonly code: ShellErrorCode
|
|
62
|
+
|
|
63
|
+
constructor(code: ShellErrorCode, message: string) {
|
|
64
|
+
super(message)
|
|
65
|
+
this.code = code
|
|
66
|
+
this.name = 'ShellError'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A path of the app, as `act navigate` takes one: one leading `/`, and no other site. */
|
|
71
|
+
const APP_PATH = /^\/(?![/\\])[^\s\\]*$/u
|
|
72
|
+
|
|
73
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
74
|
+
|
|
75
|
+
function only(value: Record<string, unknown>, allowed: readonly string[]): void {
|
|
76
|
+
const extra = Object.keys(value).filter(key => key !== 'op' && !allowed.includes(key))
|
|
77
|
+
if (extra.length > 0) throw new ShellError('invalid', `unknown field ${extra.join(', ')}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function windowId(value: Record<string, unknown>): number {
|
|
81
|
+
if (typeof value.id !== 'number' || !Number.isSafeInteger(value.id) || value.id < 1) throw new ShellError('invalid', 'id must be the number `window list` prints for the window')
|
|
82
|
+
return value.id
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function size(value: Record<string, unknown>, key: 'width' | 'height'): number {
|
|
86
|
+
const number = value[key]
|
|
87
|
+
if (typeof number !== 'number' || !Number.isInteger(number) || number < 200 || number > 10_000) throw new ShellError('invalid', `${key} must be a whole number of pixels from 200 to 10000`)
|
|
88
|
+
return number
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @throws ShellError `invalid`. */
|
|
92
|
+
export function parseShellRequest(op: ShellOp, input: unknown): ShellRequest {
|
|
93
|
+
const value = isRecord(input) ? input : {}
|
|
94
|
+
switch (op) {
|
|
95
|
+
case 'window-list': only(value, []); return { op }
|
|
96
|
+
case 'window-open': {
|
|
97
|
+
only(value, ['path'])
|
|
98
|
+
if (value.path === undefined) return { op }
|
|
99
|
+
if (typeof value.path !== 'string' || !APP_PATH.test(value.path)) throw new ShellError('invalid', 'path must be a path of this app, such as /settings (it starts with one /, and names no other site)')
|
|
100
|
+
return { op, path: value.path }
|
|
101
|
+
}
|
|
102
|
+
case 'window-focus': only(value, ['id']); return { op, id: windowId(value) }
|
|
103
|
+
case 'window-close': {
|
|
104
|
+
only(value, ['id', 'force'])
|
|
105
|
+
if (value.force !== undefined && typeof value.force !== 'boolean') throw new ShellError('invalid', 'force must be true or false')
|
|
106
|
+
return { op, id: windowId(value), ...(value.force === true ? { force: true } : {}) }
|
|
107
|
+
}
|
|
108
|
+
case 'window-resize': only(value, ['id', 'width', 'height']); return { op, id: windowId(value), width: size(value, 'width'), height: size(value, 'height') }
|
|
109
|
+
case 'menu-list': only(value, []); return { op }
|
|
110
|
+
case 'menu-click': {
|
|
111
|
+
only(value, ['path', 'force'])
|
|
112
|
+
if (typeof value.path !== 'string' || value.path.trim() === '' || value.path.length > 300) throw new ShellError('invalid', 'path is the labels from the top of the menu joined with >, e.g. "File>New Note" (`menu list` prints them)')
|
|
113
|
+
if (value.force !== undefined && typeof value.force !== 'boolean') throw new ShellError('invalid', 'force must be true or false')
|
|
114
|
+
return { op, path: value.path, ...(value.force === true ? { force: true } : {}) }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** @param value - one parsed POST body from the shell; null when it is not a well-formed answer. */
|
|
120
|
+
export function parseShellAnswer(value: unknown): ShellAnswer | null {
|
|
121
|
+
if (!isRecord(value) || typeof value.id !== 'number' || !Number.isSafeInteger(value.id)) return null
|
|
122
|
+
if (value.t === 'result' && isRecord(value.value)) return { t: 'result', id: value.id, value: value.value as unknown as ShellResult }
|
|
123
|
+
if (value.t === 'error' && typeof value.code === 'string' && typeof value.message === 'string') return { t: 'error', id: value.id, code: value.code.slice(0, 40), message: value.message.slice(0, 500) }
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Whatever code the shell used, as one the door knows (an unknown code is `failed`). */
|
|
128
|
+
export function shellErrorCode(code: string): ShellErrorCode {
|
|
129
|
+
return (['invalid', 'no-such-window', 'last-window', 'no-such-menu-item', 'disabled', 'quits-app'] as const).find(known => known === code) ?? 'failed'
|
|
130
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** The panel's own drivers, all `chat`: the app's agent, and the coding agents (`claude`, `codex`, `pi`) started headless in the app folder. */
|
|
2
|
-
import { spawn, spawnSync } from 'node:child_process'
|
|
2
|
+
import { type ChildProcess, spawn, spawnSync } from 'node:child_process'
|
|
3
3
|
import { readFileSync } from 'node:fs'
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import type { Context } from '@deepseek-ai/cordis'
|
|
7
7
|
import type { ChatDriver, ChatEvent } from './drivers.ts'
|
|
8
|
+
import { resolveLaunch } from './launch.ts'
|
|
8
9
|
|
|
9
10
|
export type HeadlessName = 'claude' | 'codex' | 'pi'
|
|
10
11
|
|
|
@@ -58,6 +59,15 @@ export function command(
|
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
/** Stop a program and what it started: Windows has no process groups, so the tree goes by its root (`SIGTERM` there ends only the one process). */
|
|
63
|
+
function stopProgram(child: ChildProcess): void {
|
|
64
|
+
if (process.platform === 'win32' && child.pid !== undefined) {
|
|
65
|
+
spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
child.kill('SIGTERM')
|
|
69
|
+
}
|
|
70
|
+
|
|
61
71
|
export function onPath(program: string): boolean {
|
|
62
72
|
const found = spawnSync(process.platform === 'win32' ? 'where' : 'which', [program], {
|
|
63
73
|
encoding: 'utf8',
|
|
@@ -89,7 +99,10 @@ export function availableHeadless(wanted?: readonly HeadlessName[]): HeadlessNam
|
|
|
89
99
|
const found: HeadlessName[] = []
|
|
90
100
|
for (const driver of wanted ?? (['claude', 'codex', 'pi'] as const)) {
|
|
91
101
|
if (driver === 'pi' && !piHasDeepseekKey()) continue
|
|
92
|
-
if (onPath(driver))
|
|
102
|
+
if (!onPath(driver)) continue
|
|
103
|
+
// On Windows an npm-installed program is offered only when its script can be started directly: a driver that is listed must start.
|
|
104
|
+
if ('error' in resolveLaunch(driver, [])) continue
|
|
105
|
+
found.push(driver)
|
|
93
106
|
}
|
|
94
107
|
return found
|
|
95
108
|
}
|
|
@@ -131,8 +144,13 @@ export function headlessDriver(name: HeadlessName, root: string): ChatDriver {
|
|
|
131
144
|
kind: 'chat',
|
|
132
145
|
name,
|
|
133
146
|
async *start({ message, signal }) {
|
|
134
|
-
const
|
|
135
|
-
const
|
|
147
|
+
const wanted = command(name, message, root)
|
|
148
|
+
const launch = resolveLaunch(wanted.file, wanted.args)
|
|
149
|
+
if ('error' in launch) {
|
|
150
|
+
yield { event: 'error', data: { message: launch.error } }
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
const child = spawn(launch.file, launch.args, {
|
|
136
154
|
cwd: root,
|
|
137
155
|
env: process.env,
|
|
138
156
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -144,16 +162,19 @@ export function headlessDriver(name: HeadlessName, root: string): ChatDriver {
|
|
|
144
162
|
queue.push(event)
|
|
145
163
|
wake?.()
|
|
146
164
|
}
|
|
147
|
-
signal.addEventListener('abort', () => child
|
|
165
|
+
signal.addEventListener('abort', () => stopProgram(child), { once: true })
|
|
148
166
|
child.stdout.on('data', (chunk: Buffer) =>
|
|
149
167
|
push({ event: 'text', data: { text: chunk.toString() } }),
|
|
150
168
|
)
|
|
151
169
|
child.stderr.on('data', (chunk: Buffer) =>
|
|
152
170
|
push({ event: 'tool', data: { tool: name, info: chunk.toString().slice(0, 500) } }),
|
|
153
171
|
)
|
|
154
|
-
child.on('error', () => {
|
|
172
|
+
child.on('error', (cause) => {
|
|
155
173
|
ended = true
|
|
156
|
-
push({
|
|
174
|
+
push({
|
|
175
|
+
event: 'error',
|
|
176
|
+
data: { message: `${name} could not be started: ${cause.message}` },
|
|
177
|
+
})
|
|
157
178
|
})
|
|
158
179
|
child.on('exit', (code) => {
|
|
159
180
|
ended = true
|
|
@@ -169,7 +190,7 @@ export function headlessDriver(name: HeadlessName, root: string): ChatDriver {
|
|
|
169
190
|
else await new Promise<void>((resolve) => (wake = resolve))
|
|
170
191
|
}
|
|
171
192
|
} finally {
|
|
172
|
-
if (child.exitCode === null) child
|
|
193
|
+
if (child.exitCode === null) stopProgram(child)
|
|
173
194
|
}
|
|
174
195
|
},
|
|
175
196
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting a coding agent (`claude`, `codex`, `pi`) without a shell. Off Windows the program is spawned by name. On Windows an npm-installed CLI is a `.cmd` shim, which CreateProcess
|
|
3
|
+
* cannot start, and a shell would pass the person's message through cmd.exe quoting (`& | < > ^ "` would run or break). So the shim is read: npm writes the script it runs
|
|
4
|
+
* (`"%dp0%\node_modules\<package>\dist\cli.js"`), and that script is started with Node directly, as one process. A native `.exe` (the Windows installer of claude) is started as it is.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { win32 } from 'node:path'
|
|
8
|
+
|
|
9
|
+
export interface Launch {
|
|
10
|
+
readonly file: string
|
|
11
|
+
readonly args: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LaunchDeps {
|
|
15
|
+
readonly platform?: NodeJS.Platform
|
|
16
|
+
readonly env?: NodeJS.ProcessEnv
|
|
17
|
+
readonly exists?: (path: string) => boolean
|
|
18
|
+
readonly read?: (path: string) => string
|
|
19
|
+
/** The Node that starts a shim's script (this process's). */
|
|
20
|
+
readonly execPath?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The script an npm `.cmd` shim runs: the quoted path after `%dp0%\`, ending in .js, .cjs or .mjs. */
|
|
24
|
+
const SHIM_SCRIPT = /"%dp0%[\\/]([^"\r\n]+?\.(?:mjs|cjs|js))"/iu
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @returns how to start `program` with `args`, or a sentence saying why it cannot be started here. A program that is not found is also a sentence.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveLaunch(
|
|
30
|
+
program: string,
|
|
31
|
+
args: readonly string[],
|
|
32
|
+
deps: LaunchDeps = {},
|
|
33
|
+
): Launch | { readonly error: string } {
|
|
34
|
+
const platform = deps.platform ?? process.platform
|
|
35
|
+
if (platform !== 'win32') return { file: program, args: [...args] }
|
|
36
|
+
const env = deps.env ?? process.env
|
|
37
|
+
const exists = deps.exists ?? existsSync
|
|
38
|
+
const read = deps.read ?? ((path: string) => readFileSync(path, 'utf8'))
|
|
39
|
+
const execPath = deps.execPath ?? process.execPath
|
|
40
|
+
const folders = (env.PATH ?? env.Path ?? '').split(';').filter(Boolean)
|
|
41
|
+
for (const folder of folders) {
|
|
42
|
+
for (const extension of ['.exe', '.com', '.cmd', '.bat']) {
|
|
43
|
+
const found = win32.join(folder, `${program}${extension}`)
|
|
44
|
+
if (!exists(found)) continue
|
|
45
|
+
if (extension === '.exe' || extension === '.com') return { file: found, args: [...args] }
|
|
46
|
+
let text: string
|
|
47
|
+
try {
|
|
48
|
+
text = read(found)
|
|
49
|
+
} catch {
|
|
50
|
+
return { error: `${program} is installed as ${found} but that file cannot be read` }
|
|
51
|
+
}
|
|
52
|
+
const script = SHIM_SCRIPT.exec(text)?.[1]
|
|
53
|
+
if (script === undefined) {
|
|
54
|
+
return {
|
|
55
|
+
error: `${program} is installed as ${found}, a batch file the panel cannot start without a shell (a shell would pass the message through cmd.exe quoting). Install the native program, or an npm package that creates an npm shim`,
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const directory = win32.dirname(found)
|
|
59
|
+
const node = win32.join(directory, 'node.exe')
|
|
60
|
+
return {
|
|
61
|
+
file: exists(node) ? node : execPath,
|
|
62
|
+
args: [win32.join(directory, script), ...args],
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { error: `${program} was not found on the PATH` }
|
|
67
|
+
}
|