@soederpop/luca 0.0.26 → 0.0.29
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/structured-output-with-assistants.md +144 -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 +132 -3
- package/src/agi/features/conversation.ts +135 -45
- package/src/agi/lib/interceptor-chain.ts +79 -0
- package/src/bootstrap/generated.ts +360 -1
- package/src/cli/build-info.ts +2 -2
- package/src/clients/websocket.ts +76 -1
- package/src/helper.ts +29 -2
- package/src/introspection/generated.agi.ts +1379 -663
- package/src/introspection/generated.node.ts +1126 -542
- package/src/introspection/generated.web.ts +1 -1
- package/src/node/features/file-manager.ts +22 -1
- package/src/node/features/fs.ts +41 -3
- package/src/node/features/helpers.ts +25 -18
- 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/servers/socket.ts +87 -0
- package/src/web/clients/socket.ts +22 -6
- package/test/interceptor-chain.test.ts +61 -0
- package/test/websocket-ask.test.ts +101 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Assistant with ProcessManager Tools"
|
|
3
|
+
tags: [assistant, processManager, tools, runtime, use]
|
|
4
|
+
lastTested: null
|
|
5
|
+
lastTestPassed: null
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Assistant with ProcessManager Tools
|
|
9
|
+
|
|
10
|
+
Create an assistant at runtime, give it processManager tools, and watch it orchestrate long-running processes — spawning ping and top, checking their output over time, running a quick command in between, then coming back to report.
|
|
11
|
+
|
|
12
|
+
## The Demo
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
const pm = container.feature('processManager', { enable: true, autoCleanup: true })
|
|
16
|
+
const ui = container.feature('ui')
|
|
17
|
+
|
|
18
|
+
const assistant = container.feature('assistant', {
|
|
19
|
+
systemPrompt: [
|
|
20
|
+
'You are a process management assistant with tools to spawn, monitor, inspect, and kill background processes.',
|
|
21
|
+
'When asked to check on processes, use getProcessOutput to read their latest output and summarize what you see.',
|
|
22
|
+
'For ping output, parse the lines and calculate the average response time yourself.',
|
|
23
|
+
'For top output, summarize CPU and memory usage from the header lines.',
|
|
24
|
+
'Always be concise — give the data, not a lecture.',
|
|
25
|
+
].join('\n'),
|
|
26
|
+
model: 'gpt-4.1-mini',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
assistant.use(pm)
|
|
30
|
+
await assistant.start()
|
|
31
|
+
|
|
32
|
+
const tools = Object.keys(assistant.tools)
|
|
33
|
+
console.log(ui.colors.cyan('Tools registered:'), tools.join(', '))
|
|
34
|
+
console.log()
|
|
35
|
+
|
|
36
|
+
// ── Helper to print assistant responses ──────────────────────────────
|
|
37
|
+
const ask = async (label, question) => {
|
|
38
|
+
console.log(ui.colors.dim(`── ${label} ──`))
|
|
39
|
+
console.log(ui.colors.yellow('→'), question.split('\n')[0])
|
|
40
|
+
const response = await assistant.ask(question)
|
|
41
|
+
console.log(ui.markdown(response))
|
|
42
|
+
console.log()
|
|
43
|
+
return response
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Step 1: Spawn long-running processes
|
|
47
|
+
await ask('SPAWN',
|
|
48
|
+
'Spawn two background processes:\n' +
|
|
49
|
+
'1. Ping google.com with tag "ping-google" (use: ping -c 20 google.com)\n' +
|
|
50
|
+
'2. Run top in batch mode with tag "top-monitor" (use: top -l 5 -s 2)\n' +
|
|
51
|
+
'Confirm both are running.'
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
// Step 2: Wait, then check in on their output
|
|
55
|
+
await new Promise(r => setTimeout(r, 4000))
|
|
56
|
+
await ask('CHECK-IN #1',
|
|
57
|
+
'Check on both processes. For ping-google, read the stdout and tell me how many replies so far and the average response time. For top-monitor, read the stdout and tell me the current CPU usage summary.'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// Step 3: Quick one-shot command while the others keep going
|
|
61
|
+
await ask('QUICK COMMAND',
|
|
62
|
+
'Run a quick command: "uptime" — tell me the system load averages.'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
// Step 4: Second check-in — more data should have accumulated
|
|
66
|
+
await new Promise(r => setTimeout(r, 4000))
|
|
67
|
+
await ask('CHECK-IN #2',
|
|
68
|
+
'Check on ping-google again. How many replies now vs last time? What is the average response time? Also list all tracked processes and their status.'
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
// Step 5: Kill everything
|
|
72
|
+
await ask('CLEANUP',
|
|
73
|
+
'Kill all running processes and confirm they are stopped.'
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
// Belt and suspenders
|
|
77
|
+
pm.killAll()
|
|
78
|
+
const remaining = pm.list().filter(h => h.status === 'running')
|
|
79
|
+
console.log(ui.colors.green('Running after cleanup:'), remaining.length)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Summary
|
|
83
|
+
|
|
84
|
+
This example showed a runtime assistant orchestrating real background processes over multiple conversation turns — spawning long-running `ping` and `top` commands, checking in on their output as it accumulates, running a quick `uptime` in between, then coming back for a second check-in before cleaning everything up. The assistant parsed ping times, summarized CPU usage, and managed the full lifecycle without any hardcoded logic — just natural language and processManager tools.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Structured Output with Assistants"
|
|
3
|
+
tags: [assistant, conversation, structured-output, zod, openai]
|
|
4
|
+
lastTested: null
|
|
5
|
+
lastTestPassed: null
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Structured Output with Assistants
|
|
9
|
+
|
|
10
|
+
Get typed, schema-validated JSON responses from OpenAI instead of raw text strings.
|
|
11
|
+
|
|
12
|
+
## Overview
|
|
13
|
+
|
|
14
|
+
OpenAI's Structured Outputs feature constrains the model to return JSON that exactly matches a schema you provide. Combined with Zod, this means `ask()` can return parsed objects instead of strings — no regex parsing, no "please respond in JSON", no malformed output.
|
|
15
|
+
|
|
16
|
+
Pass a `schema` option to `ask()` and the response comes back as a parsed object guaranteed to match your schema.
|
|
17
|
+
|
|
18
|
+
## Basic: Extract Structured Data
|
|
19
|
+
|
|
20
|
+
The simplest use case — ask a question and get structured data back.
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
const { z } = container
|
|
24
|
+
const conversation = container.feature('conversation', {
|
|
25
|
+
model: 'gpt-4.1-mini',
|
|
26
|
+
history: [{ role: 'system', content: 'You are a helpful data extraction assistant.' }]
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const result = await conversation.ask('The founders of Apple are Steve Jobs, Steve Wozniak, and Ronald Wayne. They started it in 1976 in Los Altos, California.', {
|
|
30
|
+
schema: z.object({
|
|
31
|
+
company: z.string(),
|
|
32
|
+
foundedYear: z.number(),
|
|
33
|
+
location: z.string(),
|
|
34
|
+
founders: z.array(z.string()),
|
|
35
|
+
}).describe('CompanyInfo')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
console.log('Company:', result.company)
|
|
39
|
+
console.log('Founded:', result.foundedYear)
|
|
40
|
+
console.log('Location:', result.location)
|
|
41
|
+
console.log('Founders:', result.founders)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The `.describe()` on the schema gives OpenAI the schema name — keep it short and descriptive.
|
|
45
|
+
|
|
46
|
+
## Enums and Categorization
|
|
47
|
+
|
|
48
|
+
Structured outputs work great for classification tasks where you want the model to pick from a fixed set of values.
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
const { z } = container
|
|
52
|
+
const conversation = container.feature('conversation', {
|
|
53
|
+
model: 'gpt-4.1-mini',
|
|
54
|
+
history: [{ role: 'system', content: 'You are a helpful assistant.' }]
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const sentiment = await conversation.ask('I absolutely love this product, it changed my life!', {
|
|
58
|
+
schema: z.object({
|
|
59
|
+
sentiment: z.enum(['positive', 'negative', 'neutral', 'mixed']),
|
|
60
|
+
confidence: z.number(),
|
|
61
|
+
reasoning: z.string(),
|
|
62
|
+
}).describe('SentimentAnalysis')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
console.log('Sentiment:', sentiment.sentiment)
|
|
66
|
+
console.log('Confidence:', sentiment.confidence)
|
|
67
|
+
console.log('Reasoning:', sentiment.reasoning)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Because the model is constrained by the schema, `sentiment` will always be one of the four allowed values.
|
|
71
|
+
|
|
72
|
+
## Nested Objects and Arrays
|
|
73
|
+
|
|
74
|
+
Schemas can be as complex as you need. Here we extract a structured analysis with nested objects.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
const { z } = container
|
|
78
|
+
const conversation = container.feature('conversation', {
|
|
79
|
+
model: 'gpt-4.1-mini',
|
|
80
|
+
history: [{ role: 'system', content: 'You are a technical analyst.' }]
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const analysis = await conversation.ask(
|
|
84
|
+
'TypeScript 5.5 introduced inferred type predicates, which automatically narrow types in filter callbacks. It also added isolated declarations for faster builds in monorepos, and a new regex syntax checking feature.',
|
|
85
|
+
{
|
|
86
|
+
schema: z.object({
|
|
87
|
+
subject: z.string(),
|
|
88
|
+
version: z.string(),
|
|
89
|
+
features: z.array(z.object({
|
|
90
|
+
name: z.string(),
|
|
91
|
+
category: z.enum(['type-system', 'performance', 'developer-experience', 'syntax', 'other']),
|
|
92
|
+
summary: z.string(),
|
|
93
|
+
})),
|
|
94
|
+
featureCount: z.number(),
|
|
95
|
+
}).describe('ReleaseAnalysis')
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
console.log('Subject:', analysis.subject, analysis.version)
|
|
100
|
+
console.log('Features:')
|
|
101
|
+
for (const f of analysis.features) {
|
|
102
|
+
console.log(` [${f.category}] ${f.name}: ${f.summary}`)
|
|
103
|
+
}
|
|
104
|
+
console.log('Total features:', analysis.featureCount)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Every level of nesting is validated — the model cannot return a feature without a category or skip required fields.
|
|
108
|
+
|
|
109
|
+
## With an Assistant
|
|
110
|
+
|
|
111
|
+
Structured outputs work the same way through the assistant API. The schema passes straight through to the underlying conversation.
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
const { z } = container
|
|
115
|
+
const assistant = container.feature('assistant', {
|
|
116
|
+
systemPrompt: 'You are a code review assistant. You analyze code snippets and provide structured feedback.',
|
|
117
|
+
model: 'gpt-4.1-mini',
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const review = await assistant.ask(
|
|
121
|
+
'Review this: function add(a, b) { return a + b }',
|
|
122
|
+
{
|
|
123
|
+
schema: z.object({
|
|
124
|
+
issues: z.array(z.object({
|
|
125
|
+
severity: z.enum(['info', 'warning', 'error']),
|
|
126
|
+
message: z.string(),
|
|
127
|
+
})),
|
|
128
|
+
suggestion: z.string(),
|
|
129
|
+
score: z.number(),
|
|
130
|
+
}).describe('CodeReview')
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
console.log('Score:', review.score)
|
|
135
|
+
console.log('Suggestion:', review.suggestion)
|
|
136
|
+
console.log('Issues:')
|
|
137
|
+
for (const issue of review.issues) {
|
|
138
|
+
console.log(` [${issue.severity}] ${issue.message}`)
|
|
139
|
+
}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Summary
|
|
143
|
+
|
|
144
|
+
This demo covered extracting structured data, classification with enums, nested schema validation, and using structured outputs through both the conversation and assistant APIs. The key is passing a Zod schema via `{ schema }` in the options to `ask()` — OpenAI guarantees the response matches, and you get a parsed object back.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "websocket-ask-and-reply"
|
|
3
|
+
tags: [websocket, client, server, ask, reply, rpc]
|
|
4
|
+
lastTested: null
|
|
5
|
+
lastTestPassed: null
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# websocket-ask-and-reply
|
|
9
|
+
|
|
10
|
+
Request/response conversations over WebSocket using `ask()` and `reply()`.
|
|
11
|
+
|
|
12
|
+
## Overview
|
|
13
|
+
|
|
14
|
+
The WebSocket client and server both support a request/response protocol on top of the normal fire-and-forget message stream. The client can `ask()` the server a question and await the answer. The server can `ask()` a connected client the same way. Under the hood it works with correlation IDs — `requestId` on the request, `replyTo` on the response — but you never have to touch those directly.
|
|
15
|
+
|
|
16
|
+
## Setup
|
|
17
|
+
|
|
18
|
+
Declare the shared references that all blocks will use, and wire up the server's message handler. This block is synchronous so the variables persist across subsequent blocks.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
var port = 0
|
|
22
|
+
var server = container.server('websocket', { json: true })
|
|
23
|
+
var client = null
|
|
24
|
+
|
|
25
|
+
server.on('message', (data, ws) => {
|
|
26
|
+
if (data.type === 'add') {
|
|
27
|
+
data.reply({ sum: data.data.a + data.data.b })
|
|
28
|
+
} else if (data.type === 'divide') {
|
|
29
|
+
if (data.data.b === 0) {
|
|
30
|
+
data.replyError('division by zero')
|
|
31
|
+
} else {
|
|
32
|
+
data.reply({ result: data.data.a / data.data.b })
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
console.log('Server and handlers configured')
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Start Server and Connect Client
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
port = await networking.findOpenPort(19900)
|
|
43
|
+
await server.start({ port })
|
|
44
|
+
console.log('Server listening on port', port)
|
|
45
|
+
|
|
46
|
+
client = container.client('websocket', { baseURL: `ws://localhost:${port}` })
|
|
47
|
+
await client.connect()
|
|
48
|
+
console.log('Client connected')
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Client Asks the Server
|
|
52
|
+
|
|
53
|
+
`ask(type, data, timeout?)` sends a message and returns a promise that resolves with the response payload.
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
var sum = await client.ask('add', { a: 3, b: 4 })
|
|
57
|
+
console.log('3 + 4 =', sum.sum)
|
|
58
|
+
|
|
59
|
+
var quotient = await client.ask('divide', { a: 10, b: 3 })
|
|
60
|
+
console.log('10 / 3 =', quotient.result.toFixed(2))
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Handling Errors
|
|
64
|
+
|
|
65
|
+
When the server calls `replyError(message)`, the client's `ask()` promise rejects with that message.
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
try {
|
|
69
|
+
await client.ask('divide', { a: 1, b: 0 })
|
|
70
|
+
} catch (err) {
|
|
71
|
+
console.log('Caught error:', err.message)
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Server Asks the Client
|
|
76
|
+
|
|
77
|
+
The server can also ask a connected client. The client handles incoming requests by listening for messages with a `requestId` and sending back a `replyTo` response.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
client.on('message', (data) => {
|
|
81
|
+
if (data.requestId && data.type === 'whoAreYou') {
|
|
82
|
+
client.send({ replyTo: data.requestId, data: { name: 'luca-client', version: '1.0' } })
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
var firstClient = [...server.connections][0]
|
|
87
|
+
var identity = await server.ask(firstClient, 'whoAreYou')
|
|
88
|
+
console.log('Client identified as:', identity.name, identity.version)
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Timeouts
|
|
92
|
+
|
|
93
|
+
If nobody replies, `ask()` rejects after the timeout (default 10s, configurable as the third argument).
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
try {
|
|
97
|
+
await client.ask('noop', {}, 500)
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.log('Timed out as expected:', err.message)
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Regular Messages Still Work
|
|
104
|
+
|
|
105
|
+
Messages without `requestId` flow through the normal `message` event as always. The ask/reply protocol is purely additive.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
var received = null
|
|
109
|
+
server.on('message', (data) => {
|
|
110
|
+
if (data.type === 'ping') received = data
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
await client.send({ type: 'ping', ts: Date.now() })
|
|
114
|
+
await new Promise(r => setTimeout(r, 50))
|
|
115
|
+
console.log('Regular message received:', received.type, '— no requestId:', received.requestId === undefined)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Cleanup
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
await client.disconnect()
|
|
122
|
+
await server.stop()
|
|
123
|
+
console.log('Done')
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Summary
|
|
127
|
+
|
|
128
|
+
The ask/reply protocol gives you awaitable request/response over WebSocket without leaving the Luca helper API. The client calls `ask(type, data)` and gets back a promise. The server's message handler gets `reply()` and `replyError()` injected on any message that carries a `requestId`. The server can also `ask()` a specific client. Timeouts, error propagation, and cleanup of pending requests on disconnect are all handled automatically.
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# Window Manager Fix
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
The current `windowManager` design allows any Luca process to call `listen()` on the same well-known Unix socket:
|
|
6
|
+
|
|
7
|
+
- `~/Library/Application Support/LucaVoiceLauncher/ipc-window.sock`
|
|
8
|
+
|
|
9
|
+
That means unrelated commands can compete for ownership of the app-facing socket. The current implementation makes this worse by doing the following on startup:
|
|
10
|
+
|
|
11
|
+
1. If the socket path exists, `unlinkSync(socketPath)`.
|
|
12
|
+
2. Bind a new server at the same path.
|
|
13
|
+
|
|
14
|
+
This creates a race where one Luca process can steal the socket from another. The native `LucaVoiceLauncher` app then disconnects from the old server and reconnects to whichever process currently owns the path. If that process exits, the app falls into reconnect loops.
|
|
15
|
+
|
|
16
|
+
This is the root cause of the observed behavior where:
|
|
17
|
+
|
|
18
|
+
- the launcher sometimes connects successfully
|
|
19
|
+
- the connection then drops unexpectedly
|
|
20
|
+
- repeated `ipc connect failed` messages appear in the launcher log
|
|
21
|
+
|
|
22
|
+
## Design Goal
|
|
23
|
+
|
|
24
|
+
We want:
|
|
25
|
+
|
|
26
|
+
- one stable owner of the app-facing socket
|
|
27
|
+
- many independent Luca commands able to trigger window actions
|
|
28
|
+
- optional failover if the main owner dies
|
|
29
|
+
- support for multiple launcher app clients over time, and optionally at once
|
|
30
|
+
|
|
31
|
+
The key design rule is:
|
|
32
|
+
|
|
33
|
+
> Many clients is fine. Many servers competing for the same well-known socket is not.
|
|
34
|
+
|
|
35
|
+
## Recommended Architecture
|
|
36
|
+
|
|
37
|
+
### 1. Single broker for the app socket
|
|
38
|
+
|
|
39
|
+
Only one broker process may own:
|
|
40
|
+
|
|
41
|
+
- `ipc-window.sock`
|
|
42
|
+
|
|
43
|
+
The broker is responsible for:
|
|
44
|
+
|
|
45
|
+
- accepting native launcher app connections
|
|
46
|
+
- tracking connected app clients
|
|
47
|
+
- routing window commands to the selected app client
|
|
48
|
+
- receiving `windowAck`, `windowClosed`, and `terminalExited`
|
|
49
|
+
- routing responses and lifecycle events back to the original requester
|
|
50
|
+
|
|
51
|
+
### 2. Separate control channel for Luca commands
|
|
52
|
+
|
|
53
|
+
Luca commands should not bind the app-facing socket directly.
|
|
54
|
+
|
|
55
|
+
Instead, they should talk to the broker over a separate channel, for example:
|
|
56
|
+
|
|
57
|
+
- `~/Library/Application Support/LucaVoiceLauncher/ipc-window-control.sock`
|
|
58
|
+
|
|
59
|
+
This control channel is for producers:
|
|
60
|
+
|
|
61
|
+
- `luca main`
|
|
62
|
+
- `luca workflow run ...`
|
|
63
|
+
- `luca present`
|
|
64
|
+
- scripts
|
|
65
|
+
- background jobs
|
|
66
|
+
|
|
67
|
+
These producers send requests to the broker, and the broker fans them out to the connected app client.
|
|
68
|
+
|
|
69
|
+
### 3. Broker supports multiple app clients
|
|
70
|
+
|
|
71
|
+
The broker should replace the current single `_client` field with a registry:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
Map<string, ClientConnection>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Each client should have:
|
|
78
|
+
|
|
79
|
+
- `clientId`
|
|
80
|
+
- `socket`
|
|
81
|
+
- `buffer`
|
|
82
|
+
- metadata if useful later, such as display, role, labels, or lastSeenAt
|
|
83
|
+
|
|
84
|
+
This allows:
|
|
85
|
+
|
|
86
|
+
- multiple launcher app instances
|
|
87
|
+
- reconnect without confusing request ownership
|
|
88
|
+
- future routing by target client
|
|
89
|
+
|
|
90
|
+
## Routing Model
|
|
91
|
+
|
|
92
|
+
### Producer -> broker
|
|
93
|
+
|
|
94
|
+
Producer sends a request like:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"type": "windowRequest",
|
|
99
|
+
"requestId": "uuid",
|
|
100
|
+
"originId": "uuid",
|
|
101
|
+
"targetClientId": "optional",
|
|
102
|
+
"window": {
|
|
103
|
+
"action": "open",
|
|
104
|
+
"url": "https://example.com"
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Broker -> app client
|
|
110
|
+
|
|
111
|
+
Broker forwards the request to the chosen app client, preserving `requestId`.
|
|
112
|
+
|
|
113
|
+
### App client -> broker
|
|
114
|
+
|
|
115
|
+
App replies with:
|
|
116
|
+
|
|
117
|
+
- `windowAck`
|
|
118
|
+
- `windowClosed`
|
|
119
|
+
- `terminalExited`
|
|
120
|
+
|
|
121
|
+
### Broker -> producer
|
|
122
|
+
|
|
123
|
+
Broker routes:
|
|
124
|
+
|
|
125
|
+
- the `windowAck` back to the producer that originated the request
|
|
126
|
+
- lifecycle events either to the originating producer, or to any subscribed producer
|
|
127
|
+
|
|
128
|
+
## Client Selection Policy
|
|
129
|
+
|
|
130
|
+
The simplest policy is:
|
|
131
|
+
|
|
132
|
+
- use the most recently connected healthy app client
|
|
133
|
+
|
|
134
|
+
Later policies can support:
|
|
135
|
+
|
|
136
|
+
- explicit `targetClientId`
|
|
137
|
+
- labels like `role=presenter`
|
|
138
|
+
- display-aware routing
|
|
139
|
+
- sticky routing based on `windowId -> clientId`
|
|
140
|
+
|
|
141
|
+
## Leader Election / Failover
|
|
142
|
+
|
|
143
|
+
If we want multiple `windowManager` instances to exist, they must not all behave as brokers.
|
|
144
|
+
|
|
145
|
+
Instead:
|
|
146
|
+
|
|
147
|
+
1. Try connecting to the broker control socket.
|
|
148
|
+
2. If broker exists, act as a producer client.
|
|
149
|
+
3. If broker does not exist, try to acquire a broker lock.
|
|
150
|
+
4. If lock succeeds, become broker and bind both sockets.
|
|
151
|
+
5. If lock fails, retry broker connection and act as producer.
|
|
152
|
+
|
|
153
|
+
Possible lock mechanisms:
|
|
154
|
+
|
|
155
|
+
- lock file with `flock`
|
|
156
|
+
- lock directory with atomic `mkdir`
|
|
157
|
+
- local TCP/Unix registration endpoint
|
|
158
|
+
|
|
159
|
+
The important constraint is:
|
|
160
|
+
|
|
161
|
+
- only the elected broker binds `ipc-window.sock`
|
|
162
|
+
|
|
163
|
+
All other instances must route through it.
|
|
164
|
+
|
|
165
|
+
## Why not let many processes bind the same socket?
|
|
166
|
+
|
|
167
|
+
Because Unix domain socket paths are singular ownership points. A path is not a shared bus.
|
|
168
|
+
|
|
169
|
+
If multiple processes all call `listen()` against the same path and delete stale files optimistically, they will:
|
|
170
|
+
|
|
171
|
+
- steal the path from each other
|
|
172
|
+
- disconnect the app unexpectedly
|
|
173
|
+
- lose in-flight requests
|
|
174
|
+
- create non-deterministic routing
|
|
175
|
+
|
|
176
|
+
This is fundamentally the wrong abstraction.
|
|
177
|
+
|
|
178
|
+
## Backward-Compatible Migration
|
|
179
|
+
|
|
180
|
+
We can migrate without breaking the public `windowManager.spawn()` API.
|
|
181
|
+
|
|
182
|
+
### Phase 1
|
|
183
|
+
|
|
184
|
+
- Introduce a broker mode internally.
|
|
185
|
+
- Add `ipc-window-control.sock`.
|
|
186
|
+
- Keep the existing app protocol unchanged.
|
|
187
|
+
- Make `windowManager.spawn()` talk to the broker when possible.
|
|
188
|
+
|
|
189
|
+
### Phase 2
|
|
190
|
+
|
|
191
|
+
- Prevent non-broker processes from binding `ipc-window.sock`.
|
|
192
|
+
- Replace blind `unlinkSync(socketPath)` with active listener detection.
|
|
193
|
+
- Add broker election and failover.
|
|
194
|
+
|
|
195
|
+
### Phase 3
|
|
196
|
+
|
|
197
|
+
- Add multi-client routing.
|
|
198
|
+
- Add subscriptions for lifecycle events.
|
|
199
|
+
- Add explicit target selection if needed.
|
|
200
|
+
|
|
201
|
+
## Minimal Fix if We Need Something Fast
|
|
202
|
+
|
|
203
|
+
If we do not implement the full broker immediately, we should at least stop destroying active listeners.
|
|
204
|
+
|
|
205
|
+
`listen()` should:
|
|
206
|
+
|
|
207
|
+
1. Attempt to connect to the existing socket.
|
|
208
|
+
2. If a listener is alive, do not unlink or rebind.
|
|
209
|
+
3. If the socket is dead, clean it up and bind.
|
|
210
|
+
|
|
211
|
+
This does not solve multi-producer routing, but it prevents random Luca commands from stealing the app socket from a healthy broker.
|
|
212
|
+
|
|
213
|
+
## Proposed Internal Refactor
|
|
214
|
+
|
|
215
|
+
Current state:
|
|
216
|
+
|
|
217
|
+
- one process tries to be both broker and producer
|
|
218
|
+
- one `_client`
|
|
219
|
+
- one app-facing socket
|
|
220
|
+
|
|
221
|
+
Target state:
|
|
222
|
+
|
|
223
|
+
- broker owns app-facing socket
|
|
224
|
+
- producers use control socket
|
|
225
|
+
- broker stores:
|
|
226
|
+
- `clients: Map<clientId, ClientConnection>`
|
|
227
|
+
- `pendingRequests: Map<requestId, PendingRequest>`
|
|
228
|
+
- `requestOrigins: Map<requestId, originConnection>`
|
|
229
|
+
- `windowOwners: Map<windowId, clientId>`
|
|
230
|
+
|
|
231
|
+
That separation gives us:
|
|
232
|
+
|
|
233
|
+
- stable app connectivity
|
|
234
|
+
- multi-command triggering
|
|
235
|
+
- failover
|
|
236
|
+
- room for multi-client routing
|
|
237
|
+
|
|
238
|
+
## Summary
|
|
239
|
+
|
|
240
|
+
The right fix is not “allow many `listen()` calls on the same socket.”
|
|
241
|
+
|
|
242
|
+
The right fix is:
|
|
243
|
+
|
|
244
|
+
- one elected broker owns the app socket
|
|
245
|
+
- many Luca processes talk to the broker
|
|
246
|
+
- many app clients may connect to the broker
|
|
247
|
+
- failover is implemented through broker election, not socket contention
|
|
248
|
+
|
|
249
|
+
That preserves a stable connection for the launcher app while still allowing multiple people, commands, or workflows to trigger window operations.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soederpop/luca",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.29",
|
|
4
4
|
"website": "https://luca.soederpop.com",
|
|
5
5
|
"description": "lightweight universal conversational architecture AKA Le Ultimate Component Architecture AKA Last Universal Common Ancestor, part AI part Human",
|
|
6
6
|
"author": "jon soeder aka the people's champ <jon@soederpop.com>",
|