@soederpop/luca 0.0.25 → 0.0.28
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/examples/assistant-with-process-manager.md +84 -0
- package/docs/examples/websocket-ask-and-reply-example.md +128 -0
- package/docs/window-manager-fix.md +249 -0
- package/package.json +1 -1
- package/src/agi/features/assistant.ts +75 -13
- package/src/agi/features/docs-reader.ts +25 -1
- package/src/bootstrap/generated.ts +215 -1
- package/src/cli/build-info.ts +2 -2
- package/src/clients/websocket.ts +76 -1
- package/src/command.ts +75 -0
- package/src/commands/describe.ts +29 -1089
- package/src/container-describer.ts +1098 -0
- package/src/container.ts +11 -0
- package/src/helper.ts +29 -2
- package/src/introspection/generated.agi.ts +1315 -611
- package/src/introspection/generated.node.ts +1168 -552
- package/src/introspection/generated.web.ts +9 -1
- package/src/node/features/content-db.ts +17 -0
- package/src/node/features/fs.ts +18 -0
- package/src/node/features/ipc-socket.ts +370 -180
- package/src/node/features/process-manager.ts +316 -49
- package/src/node/features/window-manager.ts +843 -235
- package/src/scaffolds/generated.ts +1 -1
- package/src/server.ts +40 -0
- package/src/servers/express.ts +2 -0
- package/src/servers/mcp.ts +1 -0
- package/src/servers/socket.ts +89 -0
- package/src/web/clients/socket.ts +22 -6
- package/test/websocket-ask.test.ts +101 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Auto-generated scaffold and MCP readme content
|
|
2
|
-
// Generated at: 2026-03-
|
|
2
|
+
// Generated at: 2026-03-23T07:45:57.906Z
|
|
3
3
|
// Source: docs/scaffolds/*.md, docs/examples/assistant/, and docs/mcp/readme.md
|
|
4
4
|
//
|
|
5
5
|
// Do not edit manually. Run: luca build-scaffolds
|
package/src/server.ts
CHANGED
|
@@ -79,6 +79,44 @@ export class Server<T extends ServerState = ServerState, K extends ServerOptions
|
|
|
79
79
|
return super.container as NodeContainer
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** Async functions passed to `.use()` before `start()` — drained in `start()`. */
|
|
83
|
+
_pendingPlugins: Promise<void>[] = []
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Register a setup function against this server. The function receives the
|
|
87
|
+
* server instance and can configure it however it likes.
|
|
88
|
+
*
|
|
89
|
+
* - If the server hasn't started yet, async return values are queued and
|
|
90
|
+
* awaited when `start()` is called.
|
|
91
|
+
* - If the server is already listening, the function is fire-and-forget.
|
|
92
|
+
* - Always returns `this` synchronously for chaining.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```typescript
|
|
96
|
+
* server
|
|
97
|
+
* .use((s) => s.app.use(cors()))
|
|
98
|
+
* .use(async (s) => { await loadRoutes(s) })
|
|
99
|
+
* await server.start()
|
|
100
|
+
* ```
|
|
101
|
+
*/
|
|
102
|
+
use(fn: (server: this) => void | Promise<void>): this {
|
|
103
|
+
const result = fn(this)
|
|
104
|
+
if (result && typeof (result as any).then === 'function') {
|
|
105
|
+
if (!this.isListening) {
|
|
106
|
+
this._pendingPlugins.push(result as Promise<void>)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return this
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Drain all pending `.use()` promises. Subclasses should call this at the top of `start()`. */
|
|
113
|
+
protected async _drainPendingPlugins() {
|
|
114
|
+
if (this._pendingPlugins.length) {
|
|
115
|
+
await Promise.all(this._pendingPlugins)
|
|
116
|
+
this._pendingPlugins = []
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
82
120
|
get isListening() {
|
|
83
121
|
return !!this.state.get('listening')
|
|
84
122
|
}
|
|
@@ -117,6 +155,8 @@ export class Server<T extends ServerState = ServerState, K extends ServerOptions
|
|
|
117
155
|
return this
|
|
118
156
|
}
|
|
119
157
|
|
|
158
|
+
await this._drainPendingPlugins()
|
|
159
|
+
|
|
120
160
|
if (options?.port) {
|
|
121
161
|
this.state.set('port', options.port)
|
|
122
162
|
}
|
package/src/servers/express.ts
CHANGED
|
@@ -104,6 +104,8 @@ export class ExpressServer<T extends ServerState = ServerState, K extends Expres
|
|
|
104
104
|
return this
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
await this._drainPendingPlugins()
|
|
108
|
+
|
|
107
109
|
// Apply runtime port to state so this.port reflects the override
|
|
108
110
|
if (options?.port) {
|
|
109
111
|
this.state.set('port', options.port)
|
package/src/servers/mcp.ts
CHANGED
|
@@ -472,6 +472,7 @@ export class MCPServer extends Server<MCPServerState, MCPServerOptions> {
|
|
|
472
472
|
stdioCompat?: StdioCompatMode
|
|
473
473
|
}) {
|
|
474
474
|
if (this.isListening) return this
|
|
475
|
+
await this._drainPendingPlugins()
|
|
475
476
|
if (!this.isConfigured) await this.configure()
|
|
476
477
|
|
|
477
478
|
const transport = options?.transport || this.options.transport || 'stdio'
|
package/src/servers/socket.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from 'zod'
|
|
|
2
2
|
import { ServerStateSchema, ServerOptionsSchema, ServerEventsSchema } from '../schemas/base.js'
|
|
3
3
|
import { type StartOptions, Server, type ServerState } from '../server.js';
|
|
4
4
|
import { WebSocketServer as BaseServer } from 'ws'
|
|
5
|
+
import type { PendingRequest } from '../clients/websocket.js'
|
|
5
6
|
|
|
6
7
|
declare module '../server' {
|
|
7
8
|
interface AvailableServers {
|
|
@@ -29,6 +30,12 @@ export const SocketServerEventsSchema = ServerEventsSchema.extend({
|
|
|
29
30
|
* When `json` mode is disabled, raw message data is emitted as-is and
|
|
30
31
|
* `send()` / `broadcast()` still JSON-stringify for safety.
|
|
31
32
|
*
|
|
33
|
+
* Supports ask/reply semantics when paired with the Luca WebSocket client.
|
|
34
|
+
* The server can `ask(ws, type, data)` a connected client and await a typed
|
|
35
|
+
* response, or handle incoming asks from clients by listening for messages
|
|
36
|
+
* with a `requestId` and replying via `send(ws, { replyTo, data })`.
|
|
37
|
+
* Requests time out if no reply arrives within the configurable window.
|
|
38
|
+
*
|
|
32
39
|
* @extends Server
|
|
33
40
|
*
|
|
34
41
|
* @example
|
|
@@ -40,6 +47,12 @@ export const SocketServerEventsSchema = ServerEventsSchema.extend({
|
|
|
40
47
|
* console.log('Received:', data)
|
|
41
48
|
* ws.broadcast({ echo: data })
|
|
42
49
|
* })
|
|
50
|
+
*
|
|
51
|
+
* // ask/reply: request info from a connected client
|
|
52
|
+
* ws.on('connection', async (client) => {
|
|
53
|
+
* const info = await ws.ask(client, 'identify')
|
|
54
|
+
* console.log('Client says:', info)
|
|
55
|
+
* })
|
|
43
56
|
* ```
|
|
44
57
|
*/
|
|
45
58
|
export class WebsocketServer<T extends ServerState = ServerState, K extends SocketServerOptions = SocketServerOptions> extends Server<T,K> {
|
|
@@ -63,6 +76,7 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
|
|
|
63
76
|
}
|
|
64
77
|
|
|
65
78
|
connections : Set<any> = new Set()
|
|
79
|
+
_pending = new Map<string, PendingRequest>()
|
|
66
80
|
|
|
67
81
|
async broadcast(message: any) {
|
|
68
82
|
for(const ws of this.connections) {
|
|
@@ -77,6 +91,66 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
|
|
|
77
91
|
return this
|
|
78
92
|
}
|
|
79
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Send a request to a specific client and wait for a correlated response.
|
|
96
|
+
* The client is expected to reply with a message whose `replyTo` matches
|
|
97
|
+
* the `requestId` of this message.
|
|
98
|
+
*
|
|
99
|
+
* @param ws - The WebSocket client to ask
|
|
100
|
+
* @param type - A string identifying the request type
|
|
101
|
+
* @param data - Optional payload
|
|
102
|
+
* @param timeout - How long to wait (default 10 000 ms)
|
|
103
|
+
* @returns The `data` field of the response
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```typescript
|
|
107
|
+
* ws.on('connection', async (client) => {
|
|
108
|
+
* const info = await ws.ask(client, 'identify')
|
|
109
|
+
* console.log('Client says:', info)
|
|
110
|
+
* })
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
async ask<R = any>(ws: any, type: string, data?: any, timeout = 10000): Promise<R> {
|
|
114
|
+
const requestId = this.container.utils.uuid()
|
|
115
|
+
|
|
116
|
+
return new Promise<R>((resolve, reject) => {
|
|
117
|
+
const timer = setTimeout(() => {
|
|
118
|
+
this._pending.delete(requestId)
|
|
119
|
+
reject(new Error(`ask("${type}") timed out after ${timeout}ms`))
|
|
120
|
+
}, timeout)
|
|
121
|
+
|
|
122
|
+
this._pending.set(requestId, { resolve, reject, timer })
|
|
123
|
+
this.send(ws, { type, data, requestId })
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** @internal Resolve a pending ask() if the incoming message has a replyTo field. Returns true if handled. */
|
|
128
|
+
_handleReply(message: any): boolean {
|
|
129
|
+
if (!message || !message.replyTo) return false
|
|
130
|
+
|
|
131
|
+
const pending = this._pending.get(message.replyTo)
|
|
132
|
+
if (!pending) return false
|
|
133
|
+
|
|
134
|
+
this._pending.delete(message.replyTo)
|
|
135
|
+
clearTimeout(pending.timer)
|
|
136
|
+
|
|
137
|
+
if (message.error) {
|
|
138
|
+
pending.reject(new Error(message.error))
|
|
139
|
+
} else {
|
|
140
|
+
pending.resolve(message.data)
|
|
141
|
+
}
|
|
142
|
+
return true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** @internal Reject all pending ask() calls — used on stop. */
|
|
146
|
+
_rejectAllPending(reason: string) {
|
|
147
|
+
for (const [id, pending] of this._pending) {
|
|
148
|
+
clearTimeout(pending.timer)
|
|
149
|
+
pending.reject(new Error(reason))
|
|
150
|
+
}
|
|
151
|
+
this._pending.clear()
|
|
152
|
+
}
|
|
153
|
+
|
|
80
154
|
/**
|
|
81
155
|
* Start the WebSocket server. A runtime `port` overrides the constructor
|
|
82
156
|
* option and is written to state before the underlying `ws.Server` is created,
|
|
@@ -89,6 +163,8 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
|
|
|
89
163
|
return this
|
|
90
164
|
}
|
|
91
165
|
|
|
166
|
+
await this._drainPendingPlugins()
|
|
167
|
+
|
|
92
168
|
// Apply runtime port to state before configure/wss touches it
|
|
93
169
|
if (options?.port) {
|
|
94
170
|
this.state.set('port', options.port)
|
|
@@ -113,6 +189,17 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
|
|
|
113
189
|
data = JSON.parse(typeof raw === 'string' ? raw : raw.toString())
|
|
114
190
|
} catch {}
|
|
115
191
|
}
|
|
192
|
+
|
|
193
|
+
// Route reply messages to pending ask() calls
|
|
194
|
+
if (this._handleReply(data)) return
|
|
195
|
+
|
|
196
|
+
// If this message is a request (has requestId), provide a reply helper
|
|
197
|
+
if (data && data.requestId) {
|
|
198
|
+
const requestId = data.requestId
|
|
199
|
+
data.reply = (responseData: any) => this.send(ws, { replyTo: requestId, data: responseData })
|
|
200
|
+
data.replyError = (error: string) => this.send(ws, { replyTo: requestId, error })
|
|
201
|
+
}
|
|
202
|
+
|
|
116
203
|
this.emit('message', data, ws)
|
|
117
204
|
})
|
|
118
205
|
})
|
|
@@ -127,6 +214,8 @@ export class WebsocketServer<T extends ServerState = ServerState, K extends Sock
|
|
|
127
214
|
return this
|
|
128
215
|
}
|
|
129
216
|
|
|
217
|
+
this._rejectAllPending('WebSocket server stopped')
|
|
218
|
+
|
|
130
219
|
await Promise.race([
|
|
131
220
|
new Promise<void>((resolve) => {
|
|
132
221
|
if (!this._wss) {
|
|
@@ -6,7 +6,10 @@ import { WebSocketClientEventsSchema } from '../../schemas/base.js'
|
|
|
6
6
|
/**
|
|
7
7
|
* Web-specific WebSocket client implementation using isomorphic-ws.
|
|
8
8
|
* Extends the base WebSocketClient with platform-specific transport and
|
|
9
|
-
* an envelope format that wraps sent data with a unique ID.
|
|
9
|
+
* an envelope format that wraps sent data with a unique ID. Inherits
|
|
10
|
+
* ask/reply semantics from the base WebSocketClient — protocol messages
|
|
11
|
+
* (those with `requestId` or `replyTo`) bypass the envelope wrapper to
|
|
12
|
+
* maintain compatibility with the Luca WebSocket server's ask/reply flow.
|
|
10
13
|
*/
|
|
11
14
|
export class SocketClient<T extends WebSocketClientState = WebSocketClientState, K extends WebSocketClientOptions = WebSocketClientOptions> extends WebSocketClient<T,K> {
|
|
12
15
|
// @ts-expect-error widening ws type for isomorphic-ws compatibility
|
|
@@ -20,6 +23,8 @@ export class SocketClient<T extends WebSocketClientState = WebSocketClientState,
|
|
|
20
23
|
/**
|
|
21
24
|
* Send data over the WebSocket with an ID envelope.
|
|
22
25
|
* Wraps the payload in { id, data } before JSON serialization.
|
|
26
|
+
* Messages with a `requestId` or `replyTo` are sent as-is to
|
|
27
|
+
* preserve the ask/reply protocol.
|
|
23
28
|
*/
|
|
24
29
|
override async send(data: any) {
|
|
25
30
|
if(!this.isConnected && !this.hasError) {
|
|
@@ -30,10 +35,15 @@ export class SocketClient<T extends WebSocketClientState = WebSocketClientState,
|
|
|
30
35
|
throw new Error(`Missing websocket instance`)
|
|
31
36
|
}
|
|
32
37
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
data
|
|
36
|
-
}
|
|
38
|
+
// Protocol messages (ask/reply) bypass the envelope
|
|
39
|
+
if (data && (data.requestId || data.replyTo)) {
|
|
40
|
+
this.ws.send(JSON.stringify(data))
|
|
41
|
+
} else {
|
|
42
|
+
this.ws.send(JSON.stringify({
|
|
43
|
+
id: this.container.utils.uuid(),
|
|
44
|
+
data
|
|
45
|
+
}))
|
|
46
|
+
}
|
|
37
47
|
}
|
|
38
48
|
|
|
39
49
|
/**
|
|
@@ -64,7 +74,13 @@ export class SocketClient<T extends WebSocketClientState = WebSocketClientState,
|
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
ws.onmessage = (event: any) => {
|
|
67
|
-
|
|
77
|
+
let data = event?.data ?? event
|
|
78
|
+
try {
|
|
79
|
+
data = JSON.parse(typeof data === 'string' ? data : data.toString())
|
|
80
|
+
} catch {}
|
|
81
|
+
if (!this._handleReply(data)) {
|
|
82
|
+
this.emit('message', data)
|
|
83
|
+
}
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
ws.onclose = (event: any) => {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, it, expect, afterAll } from 'bun:test'
|
|
2
|
+
import { NodeContainer } from '../src/node/container'
|
|
3
|
+
|
|
4
|
+
describe('WebSocket ask/reply protocol', () => {
|
|
5
|
+
const container = new NodeContainer()
|
|
6
|
+
const server = container.server('websocket', { json: true })
|
|
7
|
+
let clientWsOnServer: any
|
|
8
|
+
|
|
9
|
+
afterAll(async () => {
|
|
10
|
+
try { server.connections.forEach((ws: any) => ws.close?.()) } catch {}
|
|
11
|
+
try { await server.stop() } catch {}
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('setup: start server and connect client', async () => {
|
|
15
|
+
const connPromise = new Promise<any>((resolve) => {
|
|
16
|
+
server.on('connection', resolve)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
server.on('message', (data: any) => {
|
|
20
|
+
if (data.type === 'greet') {
|
|
21
|
+
data.reply({ greeting: `hello ${data.data.name}` })
|
|
22
|
+
}
|
|
23
|
+
if (data.type === 'fail') {
|
|
24
|
+
data.replyError('something went wrong')
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
await server.start({ port: 19880 })
|
|
29
|
+
|
|
30
|
+
// Use bun's native WebSocket as the client
|
|
31
|
+
const ws = new WebSocket('ws://localhost:19880')
|
|
32
|
+
await new Promise<void>((resolve, reject) => {
|
|
33
|
+
ws.onopen = () => resolve()
|
|
34
|
+
ws.onerror = (e) => reject(e)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
clientWsOnServer = await connPromise
|
|
38
|
+
expect(clientWsOnServer).toBeDefined()
|
|
39
|
+
|
|
40
|
+
// Wire up the client to handle server-initiated asks
|
|
41
|
+
ws.onmessage = (event: any) => {
|
|
42
|
+
const msg = JSON.parse(event.data)
|
|
43
|
+
if (msg.requestId && msg.type === 'identify') {
|
|
44
|
+
ws.send(JSON.stringify({ replyTo: msg.requestId, data: { role: 'worker' } }))
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Stash ws for other tests
|
|
49
|
+
;(globalThis as any).__testWs = ws
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('client.ask() sends a request and resolves with the server reply', async () => {
|
|
53
|
+
const ws = (globalThis as any).__testWs as WebSocket
|
|
54
|
+
|
|
55
|
+
// Manually implement the client-side ask protocol
|
|
56
|
+
const requestId = container.utils.uuid()
|
|
57
|
+
const result = await new Promise<any>((resolve, reject) => {
|
|
58
|
+
const timer = setTimeout(() => reject(new Error('timeout')), 5000)
|
|
59
|
+
const handler = (event: any) => {
|
|
60
|
+
const msg = JSON.parse(event.data)
|
|
61
|
+
if (msg.replyTo === requestId) {
|
|
62
|
+
clearTimeout(timer)
|
|
63
|
+
ws.removeEventListener('message', handler)
|
|
64
|
+
if (msg.error) reject(new Error(msg.error))
|
|
65
|
+
else resolve(msg.data)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
ws.addEventListener('message', handler)
|
|
69
|
+
ws.send(JSON.stringify({ type: 'greet', data: { name: 'luca' }, requestId }))
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
expect(result).toEqual({ greeting: 'hello luca' })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('server.ask() sends a request and resolves with the client reply', async () => {
|
|
76
|
+
const result = await server.ask(clientWsOnServer, 'identify')
|
|
77
|
+
expect(result).toEqual({ role: 'worker' })
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('ask() rejects with error when reply contains error', async () => {
|
|
81
|
+
const ws = (globalThis as any).__testWs as WebSocket
|
|
82
|
+
|
|
83
|
+
const requestId = container.utils.uuid()
|
|
84
|
+
const promise = new Promise<any>((resolve, reject) => {
|
|
85
|
+
const timer = setTimeout(() => reject(new Error('timeout')), 5000)
|
|
86
|
+
const handler = (event: any) => {
|
|
87
|
+
const msg = JSON.parse(event.data)
|
|
88
|
+
if (msg.replyTo === requestId) {
|
|
89
|
+
clearTimeout(timer)
|
|
90
|
+
ws.removeEventListener('message', handler)
|
|
91
|
+
if (msg.error) reject(new Error(msg.error))
|
|
92
|
+
else resolve(msg.data)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
ws.addEventListener('message', handler)
|
|
96
|
+
ws.send(JSON.stringify({ type: 'fail', requestId }))
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
await expect(promise).rejects.toThrow('something went wrong')
|
|
100
|
+
})
|
|
101
|
+
})
|