iriai-build 0.6.2 → 0.6.3
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/bin/cli.js +10 -0
- package/cli/commands/desktop.js +19 -0
- package/lib/state-machines/feature-lead.js +8 -0
- package/package.json +3 -2
- package/v3/adapters/desktop-adapter.js +298 -28
- package/v3/artifact-portal.js +4 -5
- package/v3/bridge-desktop.js +114 -0
- package/v3/operator.js +16 -0
- package/v3/orchestrator.js +149 -62
- package/v3/prompt-builder.js +8 -3
- package/v3/queries.js +0 -33
- package/v3/review-sessions.js +4 -56
- package/v3/schema.sql +0 -14
- package/v3/tests/desktop-adapter.test.js +957 -0
package/bin/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ import { planCommand } from "../cli/commands/plan.js";
|
|
|
9
9
|
import { implementationCommand } from "../cli/commands/implementation.js";
|
|
10
10
|
import { launchCommand } from "../cli/commands/launch.js";
|
|
11
11
|
import { slackCommand } from "../cli/commands/slack.js";
|
|
12
|
+
import { desktopCommand } from "../cli/commands/desktop.js";
|
|
12
13
|
import { setupCommand } from "../cli/commands/setup.js";
|
|
13
14
|
import { transferToSlackCommand } from "../cli/commands/transfer.js";
|
|
14
15
|
import { ensureReady } from "../cli/first-run.js";
|
|
@@ -67,6 +68,15 @@ program
|
|
|
67
68
|
await withReadyCheck(() => slackCommand());
|
|
68
69
|
});
|
|
69
70
|
|
|
71
|
+
program
|
|
72
|
+
.command("desktop")
|
|
73
|
+
.description("Start the desktop companion bridge (WebSocket on port 9721).")
|
|
74
|
+
.option("--port <number>", "WebSocket server port (default: 9721)")
|
|
75
|
+
.option("--db <path>", "Database file path")
|
|
76
|
+
.action(async (opts) => {
|
|
77
|
+
await withReadyCheck(() => desktopCommand(opts));
|
|
78
|
+
});
|
|
79
|
+
|
|
70
80
|
program
|
|
71
81
|
.command("setup")
|
|
72
82
|
.description("Configure Slack tokens, tool paths, and preferences.")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// desktop.js — `iriai-build desktop` command.
|
|
2
|
+
// Starts the desktop companion bridge (WebSocket-based, no Slack dependency).
|
|
3
|
+
|
|
4
|
+
export async function desktopCommand(opts = {}) {
|
|
5
|
+
if (opts.port) {
|
|
6
|
+
const port = parseInt(opts.port, 10);
|
|
7
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
8
|
+
console.error(`Invalid port: ${opts.port}. Must be a number between 1 and 65535.`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
process.env.DESKTOP_WS_PORT = String(port);
|
|
12
|
+
}
|
|
13
|
+
if (opts.db) {
|
|
14
|
+
process.env.DB_PATH = opts.db;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// bridge-desktop.js is self-executing on import
|
|
18
|
+
await import("../../v3/bridge-desktop.js");
|
|
19
|
+
}
|
|
@@ -266,6 +266,14 @@ export default class FeatureLead extends EventEmitter {
|
|
|
266
266
|
onPhaseDone: () => {
|
|
267
267
|
this._mainCrashCount = 0;
|
|
268
268
|
this.emit("lifecycle", { key: this.key, event: "refresh-complete" });
|
|
269
|
+
// If gate evidence was already posted, don't chain — wait for user decision.
|
|
270
|
+
const currentGateTs = this._featureState?.gate_evidence_ts || null;
|
|
271
|
+
if (currentGateTs) {
|
|
272
|
+
this._state = "monitoring";
|
|
273
|
+
this.emit("lifecycle", { key: this.key, event: "gate-evidence-awaiting-decision" });
|
|
274
|
+
this._startTriggerDetection();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
269
277
|
// Chain to a fresh session to dispatch the next gate — same as
|
|
270
278
|
// _spawnTrigger("gate-ready") does. Without this, the FL drops
|
|
271
279
|
// into monitoring with no process and no trigger detection,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iriai-build",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Iriai Build tool — AI agent orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"@slack/web-api": "^7.8.0",
|
|
23
23
|
"chokidar": "^4.0.3",
|
|
24
24
|
"commander": "^14.0.3",
|
|
25
|
-
"iriai-feedback": "^0.1.0"
|
|
25
|
+
"iriai-feedback": "^0.1.0",
|
|
26
|
+
"ws": "^8.19.0"
|
|
26
27
|
}
|
|
27
28
|
}
|
|
@@ -1,78 +1,348 @@
|
|
|
1
1
|
// desktop-adapter.js — DesktopAdapter implementing InterfaceAdapter.
|
|
2
|
-
//
|
|
3
|
-
// Stub — implementation deferred to Phase 7.
|
|
4
|
-
// Will use WebSocket or stdin/stdout JSON protocol as Tauri sidecar.
|
|
2
|
+
// WebSocket server for iriai-command Tauri desktop app.
|
|
5
3
|
|
|
4
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import * as queries from "../queries.js";
|
|
8
|
+
import * as db from "../db.js";
|
|
9
|
+
import { slugify } from "../helpers.js";
|
|
6
10
|
import { InterfaceAdapter } from "./interface.js";
|
|
7
11
|
|
|
8
12
|
export class DesktopAdapter extends InterfaceAdapter {
|
|
9
|
-
constructor({ port } = {}) {
|
|
13
|
+
constructor({ port = 9721, db: dbRef, cityCatalogPath } = {}) {
|
|
10
14
|
super();
|
|
11
|
-
this.port = port
|
|
12
|
-
this.
|
|
13
|
-
|
|
15
|
+
this.port = port;
|
|
16
|
+
this.db = dbRef;
|
|
17
|
+
this.wss = null;
|
|
18
|
+
this.orchestrator = null;
|
|
19
|
+
this.assignedCities = new Set();
|
|
20
|
+
|
|
21
|
+
// Load city catalog
|
|
22
|
+
this.cityCatalog = [];
|
|
23
|
+
if (cityCatalogPath) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = fs.readFileSync(cityCatalogPath, "utf-8");
|
|
26
|
+
this.cityCatalog = JSON.parse(raw);
|
|
27
|
+
} catch (err) {
|
|
28
|
+
if (err.code === 'ENOENT') {
|
|
29
|
+
console.warn(`[desktop] City catalog not found at ${cityCatalogPath}. Set CITY_CATALOG_PATH env var or place file at the expected location.`);
|
|
30
|
+
} else {
|
|
31
|
+
console.warn(`[desktop] Failed to parse city catalog at ${cityCatalogPath}: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
this._cityIndex = 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setOrchestrator(orch) {
|
|
39
|
+
this.orchestrator = orch;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async start() {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
this.wss = new WebSocketServer({ port: this.port });
|
|
45
|
+
|
|
46
|
+
this.wss.on("error", (err) => {
|
|
47
|
+
if (err.code === "EADDRINUSE") {
|
|
48
|
+
console.error(`[desktop] Port ${this.port} is already in use. Set DESKTOP_WS_PORT to use a different port.`);
|
|
49
|
+
}
|
|
50
|
+
reject(err);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.wss.on("listening", () => {
|
|
54
|
+
this.wss.on("connection", (ws) => {
|
|
55
|
+
ws.send(JSON.stringify({ type: "welcome", version: "1.0" }));
|
|
56
|
+
ws.on("message", (raw) => this._handleMessage(ws, raw));
|
|
57
|
+
});
|
|
58
|
+
console.log(`Desktop adapter ready on ws://localhost:${this.port}`);
|
|
59
|
+
resolve();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async stop() {
|
|
65
|
+
if (this.wss) {
|
|
66
|
+
for (const client of this.wss.clients) {
|
|
67
|
+
client.close();
|
|
68
|
+
}
|
|
69
|
+
await new Promise((resolve) => this.wss.close(resolve));
|
|
70
|
+
this.wss = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── Client Message Handler ─────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
async _handleMessage(ws, raw) {
|
|
77
|
+
let msg;
|
|
78
|
+
try {
|
|
79
|
+
msg = JSON.parse(raw.toString());
|
|
80
|
+
} catch {
|
|
81
|
+
ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" }));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
switch (msg.type) {
|
|
87
|
+
case "sync-request":
|
|
88
|
+
await this._handleSyncRequest(ws);
|
|
89
|
+
break;
|
|
90
|
+
case "create-feature":
|
|
91
|
+
await this._handleCreateFeature(ws, msg.payload || {});
|
|
92
|
+
break;
|
|
93
|
+
case "user-message":
|
|
94
|
+
await this._handleUserMessage(ws, msg.payload || {});
|
|
95
|
+
break;
|
|
96
|
+
case "resolve-decision":
|
|
97
|
+
await this._handleResolveDecision(ws, msg.payload || {});
|
|
98
|
+
break;
|
|
99
|
+
default:
|
|
100
|
+
ws.send(JSON.stringify({ type: "error", message: `Unknown message type: ${msg.type}` }));
|
|
101
|
+
}
|
|
102
|
+
} catch (err) {
|
|
103
|
+
console.error(`[desktop] Error handling ${msg.type}:`, err);
|
|
104
|
+
ws.send(JSON.stringify({ type: "error", message: err.message }));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async _handleSyncRequest(ws) {
|
|
109
|
+
const d = db.get();
|
|
110
|
+
if (!d) {
|
|
111
|
+
ws.send(JSON.stringify({ type: "error", message: "Database not ready" }));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const features = queries.getAllFeatures();
|
|
115
|
+
const agents = d.prepare("SELECT * FROM agents ORDER BY updated_at DESC").all();
|
|
116
|
+
const decisions = d.prepare("SELECT * FROM decisions ORDER BY created_at DESC").all();
|
|
117
|
+
const events = d.prepare("SELECT * FROM events ORDER BY created_at DESC LIMIT 200").all();
|
|
118
|
+
|
|
119
|
+
ws.send(JSON.stringify({
|
|
120
|
+
type: "sync-response",
|
|
121
|
+
payload: { features, agents, decisions, events },
|
|
122
|
+
ts: Date.now(),
|
|
123
|
+
}));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async _handleCreateFeature(ws, payload) {
|
|
127
|
+
const slug = slugify(payload.description || "untitled");
|
|
128
|
+
const city = this._assignNextCity();
|
|
129
|
+
|
|
130
|
+
let feature = null;
|
|
131
|
+
if (this.orchestrator) {
|
|
132
|
+
feature = await this.orchestrator.initializeFeature(slug);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Store city_id in feature metadata if we have a feature
|
|
136
|
+
if (feature && city) {
|
|
137
|
+
const metadata = queries.getFeatureMetadata(feature.id);
|
|
138
|
+
metadata.city_id = city.id;
|
|
139
|
+
metadata.city_name = city.name;
|
|
140
|
+
queries.updateFeatureMetadata(feature.id, metadata);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
ws.send(JSON.stringify({
|
|
144
|
+
type: "feature-created",
|
|
145
|
+
payload: { feature, cityId: city ? city.id : null },
|
|
146
|
+
ts: Date.now(),
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async _handleUserMessage(ws, payload) {
|
|
151
|
+
if (!this.orchestrator) {
|
|
152
|
+
ws.send(JSON.stringify({ type: "error", message: "No orchestrator configured" }));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!payload.featureId || !payload.text) {
|
|
156
|
+
ws.send(JSON.stringify({ type: "error", message: "Missing required fields: featureId, text" }));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
await this.orchestrator.routeUserMessage(payload.featureId, payload.text, "desktop");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async _handleResolveDecision(ws, payload) {
|
|
163
|
+
if (!this.orchestrator) {
|
|
164
|
+
ws.send(JSON.stringify({ type: "error", message: "No orchestrator configured" }));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (!payload.decisionId) {
|
|
168
|
+
ws.send(JSON.stringify({ type: "error", message: "Missing required field: decisionId" }));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
await this.orchestrator.handleDecisionClick(
|
|
172
|
+
payload.decisionId,
|
|
173
|
+
payload.optionId,
|
|
174
|
+
payload.feedback,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── City Assignment ────────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
_assignNextCity() {
|
|
181
|
+
if (this.cityCatalog.length === 0) return null;
|
|
182
|
+
|
|
183
|
+
// Find next unassigned city, wrapping around if all assigned
|
|
184
|
+
let city = null;
|
|
185
|
+
|
|
186
|
+
for (let i = 0; i < this.cityCatalog.length; i++) {
|
|
187
|
+
const candidate = this.cityCatalog[this._cityIndex % this.cityCatalog.length];
|
|
188
|
+
this._cityIndex = (this._cityIndex + 1) % this.cityCatalog.length;
|
|
189
|
+
|
|
190
|
+
if (!this.assignedCities.has(candidate.id)) {
|
|
191
|
+
city = candidate;
|
|
192
|
+
this.assignedCities.add(candidate.id);
|
|
193
|
+
return city;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// All cities assigned — wrap around, reset tracking, assign first
|
|
198
|
+
this.assignedCities.clear();
|
|
199
|
+
city = this.cityCatalog[this._cityIndex % this.cityCatalog.length];
|
|
200
|
+
this._cityIndex = (this._cityIndex + 1) % this.cityCatalog.length;
|
|
201
|
+
this.assignedCities.add(city.id);
|
|
202
|
+
return city;
|
|
14
203
|
}
|
|
15
204
|
|
|
205
|
+
// ─── Broadcast Helper ──────────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
_broadcast(message) {
|
|
208
|
+
if (!this.wss) return;
|
|
209
|
+
const data = JSON.stringify(message);
|
|
210
|
+
for (const client of this.wss.clients) {
|
|
211
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
212
|
+
client.send(data);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ─── InterfaceAdapter Implementation ────────────────────────────────────────
|
|
218
|
+
|
|
16
219
|
async createFeatureChannel(featureId, slug) {
|
|
17
|
-
this.
|
|
220
|
+
const city = this._assignNextCity();
|
|
221
|
+
|
|
222
|
+
// Store city in feature metadata
|
|
223
|
+
if (city) {
|
|
224
|
+
const metadata = queries.getFeatureMetadata(featureId);
|
|
225
|
+
metadata.city_id = city.id;
|
|
226
|
+
metadata.city_name = city.name;
|
|
227
|
+
queries.updateFeatureMetadata(featureId, metadata);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this._broadcast({
|
|
231
|
+
type: "feature-created",
|
|
232
|
+
payload: { featureId, slug, city },
|
|
233
|
+
ts: Date.now(),
|
|
234
|
+
});
|
|
235
|
+
|
|
18
236
|
return `desktop-${slug}`;
|
|
19
237
|
}
|
|
20
238
|
|
|
21
239
|
async postMessage(featureId, text) {
|
|
22
240
|
const ref = Date.now().toString();
|
|
23
|
-
this.
|
|
241
|
+
this._broadcast({
|
|
242
|
+
type: "message",
|
|
243
|
+
payload: { featureId, text, ref },
|
|
244
|
+
ts: Date.now(),
|
|
245
|
+
});
|
|
24
246
|
return { ref };
|
|
25
247
|
}
|
|
26
248
|
|
|
27
249
|
async postThreadMessage(featureId, text) {
|
|
28
|
-
this.
|
|
250
|
+
this._broadcast({
|
|
251
|
+
type: "thread-message",
|
|
252
|
+
payload: { featureId, text },
|
|
253
|
+
ts: Date.now(),
|
|
254
|
+
});
|
|
29
255
|
}
|
|
30
256
|
|
|
31
257
|
async postPipelineMessage(featureId, text) {
|
|
32
258
|
const ref = Date.now().toString();
|
|
33
|
-
this.
|
|
259
|
+
this._broadcast({
|
|
260
|
+
type: "pipeline-status",
|
|
261
|
+
payload: { featureId, text, ref },
|
|
262
|
+
ts: Date.now(),
|
|
263
|
+
});
|
|
34
264
|
return { ref };
|
|
35
265
|
}
|
|
36
266
|
|
|
37
267
|
async postAgentResponse(featureId, agentLabel, content) {
|
|
38
268
|
const ref = Date.now().toString();
|
|
39
|
-
this.
|
|
269
|
+
this._broadcast({
|
|
270
|
+
type: "agent-response",
|
|
271
|
+
payload: { featureId, agentLabel, content, ref },
|
|
272
|
+
ts: Date.now(),
|
|
273
|
+
});
|
|
40
274
|
return { ref };
|
|
41
275
|
}
|
|
42
276
|
|
|
43
277
|
async uploadArtifact(featureId, filePath, title) {
|
|
44
|
-
this.
|
|
278
|
+
this._broadcast({
|
|
279
|
+
type: "artifact-uploaded",
|
|
280
|
+
payload: { featureId, filePath, title },
|
|
281
|
+
ts: Date.now(),
|
|
282
|
+
});
|
|
45
283
|
}
|
|
46
284
|
|
|
47
285
|
async postDecision(featureId, decision) {
|
|
48
286
|
const ref = Date.now().toString();
|
|
49
|
-
this.
|
|
50
|
-
|
|
287
|
+
this._broadcast({
|
|
288
|
+
type: "decision-posted",
|
|
289
|
+
payload: { featureId, decision, ref },
|
|
290
|
+
ts: Date.now(),
|
|
291
|
+
});
|
|
51
292
|
return { ref };
|
|
52
293
|
}
|
|
53
294
|
|
|
54
295
|
async resolveDecisionMessage(featureId, messageRef, decisionId, selectedOption, selectedLabel, resolvedBy, feedback) {
|
|
55
|
-
this.
|
|
296
|
+
this._broadcast({
|
|
297
|
+
type: "decision-resolved",
|
|
298
|
+
payload: { featureId, messageRef, decisionId, selectedOption, selectedLabel, resolvedBy, feedback },
|
|
299
|
+
ts: Date.now(),
|
|
300
|
+
});
|
|
56
301
|
}
|
|
57
302
|
|
|
58
303
|
async postPlanForApproval(featureId, planDir) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
304
|
+
// List artifact files in the plan directory
|
|
305
|
+
let artifacts = [];
|
|
306
|
+
try {
|
|
307
|
+
artifacts = fs.readdirSync(planDir).filter((f) => !f.startsWith("."));
|
|
308
|
+
} catch {
|
|
309
|
+
// planDir may not exist yet
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const ref = Date.now().toString();
|
|
313
|
+
this._broadcast({
|
|
314
|
+
type: "plan-approval",
|
|
315
|
+
payload: { featureId, planDir, artifacts, ref },
|
|
316
|
+
ts: Date.now(),
|
|
67
317
|
});
|
|
318
|
+
return { ref };
|
|
68
319
|
}
|
|
69
320
|
|
|
70
321
|
async postFeatureComplete(featureId) {
|
|
71
|
-
this.
|
|
322
|
+
this._broadcast({
|
|
323
|
+
type: "feature-complete",
|
|
324
|
+
payload: { featureId },
|
|
325
|
+
ts: Date.now(),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async pinMessage(_featureId, _messageTs) {
|
|
330
|
+
// No-op — desktop app doesn't need pinning
|
|
72
331
|
}
|
|
73
332
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
333
|
+
async markProcessing(featureId, messageRef) {
|
|
334
|
+
this._broadcast({
|
|
335
|
+
type: "processing-start",
|
|
336
|
+
payload: { featureId, messageRef },
|
|
337
|
+
ts: Date.now(),
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async clearProcessing(featureId, messageRef) {
|
|
342
|
+
this._broadcast({
|
|
343
|
+
type: "processing-end",
|
|
344
|
+
payload: { featureId, messageRef },
|
|
345
|
+
ts: Date.now(),
|
|
346
|
+
});
|
|
77
347
|
}
|
|
78
348
|
}
|
package/v3/artifact-portal.js
CHANGED
|
@@ -308,11 +308,10 @@ export class ArtifactPortal {
|
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
_getReviewSessionsForFeature(featureId) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
311
|
+
if (!this._reviewSessions) return [];
|
|
312
|
+
// Derive from in-memory sessions — no DB query needed
|
|
313
|
+
return this._reviewSessions.getAllSessions()
|
|
314
|
+
.filter(s => s.decisionId.includes(queries.getFeatureById(featureId)?.slug || "~~none~~"));
|
|
316
315
|
}
|
|
317
316
|
|
|
318
317
|
// ─── Shared CSS ────────────────────────────────────────────────────
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bridge-desktop.js — Desktop companion app bridge.
|
|
3
|
+
//
|
|
4
|
+
// Mirrors bridge-v3.js (Slack bridge) but uses DesktopAdapter (WebSocket)
|
|
5
|
+
// instead of SlackAdapter. No Slack tokens required.
|
|
6
|
+
//
|
|
7
|
+
// Usage: node v3/bridge-desktop.js
|
|
8
|
+
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import * as db from "./db.js";
|
|
11
|
+
import { DesktopAdapter } from "./adapters/desktop-adapter.js";
|
|
12
|
+
import { Orchestrator } from "./orchestrator.js";
|
|
13
|
+
import { Recovery } from "./recovery.js";
|
|
14
|
+
import { ReviewSessionManager } from "./review-sessions.js";
|
|
15
|
+
import { DB_PATH, PORTAL_PORT } from "./constants.js";
|
|
16
|
+
import { ArtifactPortal } from "./artifact-portal.js";
|
|
17
|
+
|
|
18
|
+
let _orchestrator = null;
|
|
19
|
+
let _portal = null;
|
|
20
|
+
let _adapter = null;
|
|
21
|
+
|
|
22
|
+
// ─── Config ──────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
const WS_PORT = process.env.DESKTOP_WS_PORT
|
|
25
|
+
? parseInt(process.env.DESKTOP_WS_PORT, 10)
|
|
26
|
+
: 9721;
|
|
27
|
+
|
|
28
|
+
const ARTIFACT_PORT = process.env.ARTIFACT_PORT
|
|
29
|
+
? parseInt(process.env.ARTIFACT_PORT, 10)
|
|
30
|
+
: PORTAL_PORT;
|
|
31
|
+
|
|
32
|
+
const CITY_CATALOG_PATH =
|
|
33
|
+
process.env.CITY_CATALOG_PATH ||
|
|
34
|
+
path.resolve(import.meta.dirname, "../data/city-catalog.json");
|
|
35
|
+
|
|
36
|
+
// ─── Startup ─────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
async function start() {
|
|
39
|
+
console.log("Starting Iriai Desktop Bridge (SQLite-backed)...");
|
|
40
|
+
|
|
41
|
+
// 1. Open database
|
|
42
|
+
try {
|
|
43
|
+
db.open(DB_PATH);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(`[desktop-bridge] Failed to open database at ${DB_PATH}:`, err.message);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
console.log(`Database opened: ${DB_PATH}`);
|
|
49
|
+
|
|
50
|
+
// 2. Create DesktopAdapter
|
|
51
|
+
const adapter = new DesktopAdapter({
|
|
52
|
+
port: WS_PORT,
|
|
53
|
+
db: null,
|
|
54
|
+
cityCatalogPath: CITY_CATALOG_PATH,
|
|
55
|
+
});
|
|
56
|
+
_adapter = adapter;
|
|
57
|
+
|
|
58
|
+
// 3. Create orchestrator with review session support
|
|
59
|
+
const reviewSessions = new ReviewSessionManager();
|
|
60
|
+
const orchestrator = new Orchestrator({ adapter, reviewSessions });
|
|
61
|
+
_orchestrator = orchestrator;
|
|
62
|
+
adapter.setOrchestrator(orchestrator);
|
|
63
|
+
|
|
64
|
+
// 4. Start artifact portal
|
|
65
|
+
const portal = new ArtifactPortal({ reviewSessions });
|
|
66
|
+
_portal = portal;
|
|
67
|
+
await portal.start(ARTIFACT_PORT);
|
|
68
|
+
|
|
69
|
+
// 5. Run recovery (process stale signals, reattach running agents)
|
|
70
|
+
const recovery = new Recovery({ orchestrator, adapter });
|
|
71
|
+
await recovery.run();
|
|
72
|
+
|
|
73
|
+
// 6. Start stale signal safety net
|
|
74
|
+
orchestrator.startStaleScan();
|
|
75
|
+
|
|
76
|
+
// 7. Start WebSocket server
|
|
77
|
+
await adapter.start();
|
|
78
|
+
|
|
79
|
+
console.log(`Desktop bridge ready on ws://localhost:${WS_PORT}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── Graceful Shutdown ───────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
async function shutdown(signal) {
|
|
85
|
+
console.log(`\n[desktop-bridge] ${signal} received — shutting down gracefully`);
|
|
86
|
+
|
|
87
|
+
if (_adapter) {
|
|
88
|
+
await _adapter.stop();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (_portal) {
|
|
92
|
+
await _portal.stop();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (_orchestrator) {
|
|
96
|
+
await _orchestrator.shutdown();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
db.close();
|
|
100
|
+
console.log("[desktop-bridge] Database closed. Exiting.");
|
|
101
|
+
process.exit(0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
105
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
106
|
+
|
|
107
|
+
process.on("unhandledRejection", (err) => {
|
|
108
|
+
console.error("[desktop-bridge] Unhandled rejection (non-fatal):", err?.message || err);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
start().catch((err) => {
|
|
112
|
+
console.error("[desktop-bridge] Fatal:", err);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
});
|
package/v3/operator.js
CHANGED
|
@@ -30,6 +30,7 @@ export async function invokeOperator({ feature, operatorDir, flDir, featureDir,
|
|
|
30
30
|
const history = await assembleHistory(feature.id);
|
|
31
31
|
const activeAgents = assembleActiveAgents(feature.id);
|
|
32
32
|
const pendingDecision = assemblePendingDecision(feature.id);
|
|
33
|
+
const routingTable = assembleRoutingTable(feature.id);
|
|
33
34
|
|
|
34
35
|
const projectRoot = process.env.PROJECT_ROOT || process.cwd();
|
|
35
36
|
const directoryMap = path.join(projectRoot, "DIRECTORY_MAP.MD");
|
|
@@ -47,6 +48,7 @@ export async function invokeOperator({ feature, operatorDir, flDir, featureDir,
|
|
|
47
48
|
planDir,
|
|
48
49
|
activePlanningRole,
|
|
49
50
|
directoryMap,
|
|
51
|
+
routingTable,
|
|
50
52
|
});
|
|
51
53
|
|
|
52
54
|
// 3. Spawn claude via supervisor — default to --continue for session context,
|
|
@@ -232,6 +234,20 @@ export function assembleActiveAgents(featureId) {
|
|
|
232
234
|
}).join("\n");
|
|
233
235
|
}
|
|
234
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Assemble a routing table of all agent keys for this feature.
|
|
239
|
+
* Gives the Operator exact keys to use in [ROUTE:] blocks.
|
|
240
|
+
*/
|
|
241
|
+
export function assembleRoutingTable(featureId) {
|
|
242
|
+
const agents = queries.getAgentsByFeature(featureId);
|
|
243
|
+
if (!agents.length) return "(no agents registered)";
|
|
244
|
+
|
|
245
|
+
return agents
|
|
246
|
+
.filter(a => a.agent_type !== "operator") // exclude self
|
|
247
|
+
.map(a => `- [ROUTE:${a.agent_key}] → ${a.role_name || a.agent_type}${a.team_num ? ` (team ${a.team_num})` : ""} — ${a.status}`)
|
|
248
|
+
.join("\n");
|
|
249
|
+
}
|
|
250
|
+
|
|
235
251
|
/**
|
|
236
252
|
* Assemble pending decision info.
|
|
237
253
|
*/
|