u-foo 3.0.9 → 3.0.11
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/README.md +25 -9
- package/README.zh-CN.md +22 -9
- package/dist/tui/darwin-arm64/ufoo-tui +0 -0
- package/dist/tui/darwin-x64/ufoo-tui +0 -0
- package/dist/tui/linux-arm64/ufoo-tui +0 -0
- package/dist/tui/linux-x64/ufoo-tui +0 -0
- package/package.json +12 -4
- package/scripts/pack-tui.js +112 -0
- package/scripts/postinstall.js +11 -0
- package/src/agents/activity/activityReconcile.js +106 -0
- package/src/agents/activity/activityStatePublisher.js +31 -2
- package/src/agents/activity/index.js +1 -0
- package/src/agents/launch/launcher.js +19 -0
- package/src/agents/launch/ptyRunner.js +20 -1
- package/src/app/chat/ChatController.js +433 -0
- package/src/app/chat/agentDirectory.js +63 -0
- package/src/app/chat/agentEnter.js +70 -0
- package/src/app/chat/agentIdentity.js +50 -0
- package/src/app/chat/bootstrap.js +66 -0
- package/src/app/chat/commandExecutor.js +108 -0
- package/src/app/chat/commands.js +38 -1
- package/src/app/chat/dashboardView.js +6 -2
- package/src/app/chat/historyStore.js +181 -0
- package/src/app/chat/index.js +14 -2
- package/src/app/chat/inputSubmitHandler.js +21 -7
- package/src/app/chat/ipcBuilders.js +52 -0
- package/src/app/chat/multiWindow/paneManager.js +10 -1
- package/src/app/chat/multiWindow/renderer.js +1 -1
- package/src/app/chat/multiWindow/vtFrame.js +93 -0
- package/src/app/chat/streamState.js +182 -0
- package/src/app/cli/features/doctor.js +22 -0
- package/src/code/UcodeController.js +156 -0
- package/src/code/context/planGraphService.js +4 -0
- package/src/code/repl.js +4 -3
- package/src/code/runtime/taskLoop.js +46 -50
- package/src/code/tui.js +13 -2
- package/src/code/ucodeSlashDispatch.js +241 -0
- package/src/coordination/bus/activate.js +3 -0
- package/src/runtime/contracts/schemas/ufoo-ui-v1/envelope.json +28 -0
- package/src/runtime/contracts/uiProtocol.js +190 -0
- package/src/ui/{ink/chatLogModel.js → chatLogModel.js} +2 -2
- package/src/ui/dashboardBridge.js +81 -0
- package/src/ui/format/index.js +2 -2
- package/src/ui/index.js +8 -4
- package/src/ui/multiPaneBusMirror.js +137 -0
- package/src/ui/multiWindowHandoff.js +232 -0
- package/src/ui/ptyHandoff.js +23 -0
- package/src/ui/rustChatHost.js +1520 -0
- package/src/ui/rustMultiSession.js +497 -0
- package/src/ui/rustUcodeHost.js +999 -0
- package/src/ui/scrollbackReplay.js +82 -0
- package/src/ui/settingsBridge.js +49 -0
- package/src/ui/toolMergeBridge.js +66 -0
- package/src/ui/tuiLauncher.js +105 -0
- package/src/ui/ucodeStatusLine.js +74 -0
- package/src/ui/uiHostServer.js +339 -0
- package/src/ui/MIGRATION.md +0 -334
- package/src/ui/ink/ChatApp.js +0 -4152
- package/src/ui/ink/DashboardBar.js +0 -691
- package/src/ui/ink/InkDemo.js +0 -96
- package/src/ui/ink/MultilineInput.js +0 -662
- package/src/ui/ink/UcodeApp.js +0 -1675
- package/src/ui/ink/agentMirror.js +0 -730
- package/src/ui/ink/chatReducer.js +0 -473
- package/src/ui/runInk.js +0 -66
|
@@ -0,0 +1,1520 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase 1 Rust chat host: Node owns daemon + history; ufoo-tui owns TTY.
|
|
5
|
+
*
|
|
6
|
+
* Opt-in via UFOO_TUI=rust; UFOO_TUI=auto prefers Rust when binary exists.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const { spawn } = require("child_process");
|
|
13
|
+
const { createUiHostServer, createAuthToken } = require("./uiHostServer");
|
|
14
|
+
const { resolveTuiLaunchPlan } = require("./tuiLauncher");
|
|
15
|
+
const { createToolMergePublisher } = require("./toolMergeBridge");
|
|
16
|
+
const { createRustMultiSession } = require("./rustMultiSession");
|
|
17
|
+
const { writeMultiPaneBusEvent } = require("./multiPaneBusMirror");
|
|
18
|
+
const { buildSettingsSnapshot, applySettingsPatch } = require("./settingsBridge");
|
|
19
|
+
const fmt = require("./format");
|
|
20
|
+
const {
|
|
21
|
+
loadGlobalProjectRows,
|
|
22
|
+
buildDashboardPublishPayload,
|
|
23
|
+
} = require("./dashboardBridge");
|
|
24
|
+
const { createChatController } = require("../app/chat/ChatController");
|
|
25
|
+
const {
|
|
26
|
+
resolveAgentEnterRequest,
|
|
27
|
+
resolveDashboardAgentEnterAction,
|
|
28
|
+
} = require("../app/chat/agentEnter");
|
|
29
|
+
const { loadConfig } = require("../config");
|
|
30
|
+
const { bootstrapEnvironment, ensureSubscriberId } = require("../app/chat/bootstrap");
|
|
31
|
+
const {
|
|
32
|
+
createEnvelope,
|
|
33
|
+
encodeMessage,
|
|
34
|
+
MULTI_FRAMES_CAPABILITY,
|
|
35
|
+
} = require("../runtime/contracts/uiProtocol");
|
|
36
|
+
const { IPC_REQUEST_TYPES, IPC_RESPONSE_TYPES } = require("../runtime/contracts/eventContract");
|
|
37
|
+
const { createDaemonMessageRouter } = require("../app/chat/daemonMessageRouter");
|
|
38
|
+
|
|
39
|
+
function stripTags(value) {
|
|
40
|
+
return String(value || "").replace(/\{[^}]+\}/g, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function historyToEntries(rows) {
|
|
44
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
45
|
+
const markdownState = { inCodeBlock: false };
|
|
46
|
+
const MARKDOWN_KINDS = new Set(["assistant", "agent", "report", "error"]);
|
|
47
|
+
const out = [];
|
|
48
|
+
list.forEach((row, index) => {
|
|
49
|
+
if (typeof row === "string") {
|
|
50
|
+
out.push({
|
|
51
|
+
id: `hist-${index}`,
|
|
52
|
+
kind: "system",
|
|
53
|
+
text: stripTags(row),
|
|
54
|
+
speaker: "",
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const sourceType = String(row && (row.sourceType || row.type || row.kind) || "system");
|
|
59
|
+
const kind = sourceType === "user" || sourceType === "assistant" || sourceType === "error"
|
|
60
|
+
|| sourceType === "agent" || sourceType === "report"
|
|
61
|
+
? sourceType
|
|
62
|
+
: "system";
|
|
63
|
+
const raw = String(row && (row.text || row.content || ""));
|
|
64
|
+
const speaker = String(row && row.speaker || "");
|
|
65
|
+
if (MARKDOWN_KINDS.has(kind)) {
|
|
66
|
+
let lines;
|
|
67
|
+
try {
|
|
68
|
+
lines = fmt.renderLogLinesWithMarkdownAnsi(raw, markdownState);
|
|
69
|
+
if (!Array.isArray(lines) || lines.length === 0) lines = raw.split(/\r?\n/);
|
|
70
|
+
} catch {
|
|
71
|
+
lines = raw.split(/\r?\n/);
|
|
72
|
+
}
|
|
73
|
+
lines.forEach((line, lineIdx) => {
|
|
74
|
+
out.push({
|
|
75
|
+
id: `hist-${index}-${lineIdx}`,
|
|
76
|
+
kind,
|
|
77
|
+
text: String(line || ""),
|
|
78
|
+
speaker: lineIdx === 0 ? speaker : "",
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
out.push({
|
|
84
|
+
id: `hist-${index}`,
|
|
85
|
+
kind,
|
|
86
|
+
text: stripTags(raw),
|
|
87
|
+
speaker,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Ink parity: `/group run` / `/solo run` dynamic argument lists. */
|
|
94
|
+
function loadDynamicCompletionSources(root) {
|
|
95
|
+
const sources = { groupTemplates: [], soloProfiles: [] };
|
|
96
|
+
try {
|
|
97
|
+
const { loadTemplateRegistry } = require("../orchestration/groups/templates");
|
|
98
|
+
const reg = typeof loadTemplateRegistry === "function" ? loadTemplateRegistry(root) : null;
|
|
99
|
+
if (reg && Array.isArray(reg.templates)) {
|
|
100
|
+
sources.groupTemplates = reg.templates.map((item) => ({
|
|
101
|
+
alias: item.alias,
|
|
102
|
+
cmd: item.alias,
|
|
103
|
+
desc: item.templateDescription || "",
|
|
104
|
+
source: item.source || "",
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// ignore registry load failures
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const { loadPromptProfileRegistry } = require("../orchestration/groups/promptProfiles");
|
|
112
|
+
const { buildPromptProfileCandidates } = require("../orchestration/solo/commands");
|
|
113
|
+
const reg = typeof loadPromptProfileRegistry === "function"
|
|
114
|
+
? loadPromptProfileRegistry(root)
|
|
115
|
+
: null;
|
|
116
|
+
if (reg && typeof buildPromptProfileCandidates === "function") {
|
|
117
|
+
sources.soloProfiles = buildPromptProfileCandidates(reg) || [];
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// ignore registry load failures
|
|
121
|
+
}
|
|
122
|
+
return sources;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function runChatRust(projectRoot, options = {}) {
|
|
126
|
+
const plan = resolveTuiLaunchPlan({
|
|
127
|
+
mode: options.tuiMode || process.env.UFOO_TUI || "rust",
|
|
128
|
+
requireRust: true,
|
|
129
|
+
});
|
|
130
|
+
if (plan.mode !== "rust" || !plan.binary) {
|
|
131
|
+
const err = new Error(`Rust TUI unavailable (${plan.reason || "unknown"})`);
|
|
132
|
+
err.code = "UFOO_TUI_UNAVAILABLE";
|
|
133
|
+
err.plan = plan;
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const env = bootstrapEnvironment(projectRoot, options);
|
|
138
|
+
if (env.needsBootstrap || !fs.existsSync(env.runtimePaths.ufooDir)) {
|
|
139
|
+
const repoRoot = path.join(__dirname, "..", "..");
|
|
140
|
+
const init = new env.UfooInit(repoRoot);
|
|
141
|
+
await init.init({
|
|
142
|
+
targets: "context,bus",
|
|
143
|
+
project: projectRoot,
|
|
144
|
+
controllerMode: env.globalMode,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
await ensureSubscriberId(projectRoot);
|
|
148
|
+
if (!env.isRunning(projectRoot)) {
|
|
149
|
+
env.startDaemon(projectRoot);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const { socketPath } = require("../runtime/daemon");
|
|
153
|
+
const { connectWithRetry } = require("../app/chat/transport");
|
|
154
|
+
const { createDaemonTransport } = require("../app/chat/daemonTransport");
|
|
155
|
+
const { createDaemonConnection } = require("../app/chat/daemonConnection");
|
|
156
|
+
|
|
157
|
+
const sock = socketPath(projectRoot);
|
|
158
|
+
const daemonTransport = createDaemonTransport({
|
|
159
|
+
projectRoot,
|
|
160
|
+
sockPath: sock,
|
|
161
|
+
isRunning: env.isRunning,
|
|
162
|
+
startDaemon: env.startDaemon,
|
|
163
|
+
connectWithRetry,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
let entrySeq = 0;
|
|
167
|
+
const streamIds = new Map();
|
|
168
|
+
let hostRef = null;
|
|
169
|
+
let daemonSend = () => {};
|
|
170
|
+
let daemonCoordinator = null;
|
|
171
|
+
let routedMessageHandler = () => {};
|
|
172
|
+
let activeProjectRoot = projectRoot;
|
|
173
|
+
let globalScope = "controller";
|
|
174
|
+
let agentViewId = "";
|
|
175
|
+
let multiSession = null;
|
|
176
|
+
/** Internal pane agents currently BUS_WATCH'd for multi/side inbound. */
|
|
177
|
+
const watchedInternalAgents = new Set();
|
|
178
|
+
const settings = (() => {
|
|
179
|
+
try {
|
|
180
|
+
return loadConfig(projectRoot) || {};
|
|
181
|
+
} catch {
|
|
182
|
+
return {};
|
|
183
|
+
}
|
|
184
|
+
})();
|
|
185
|
+
|
|
186
|
+
function historyOptions() {
|
|
187
|
+
const { chatHistoryOptionsForScope } = require("../app/chat/historyStore");
|
|
188
|
+
return chatHistoryOptionsForScope({
|
|
189
|
+
globalMode: Boolean(env.globalMode),
|
|
190
|
+
globalScope,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function sendInternalAgentWatch(agentId, enabled) {
|
|
195
|
+
const id = String(agentId || "").trim();
|
|
196
|
+
if (!id) return;
|
|
197
|
+
try {
|
|
198
|
+
daemonSend({
|
|
199
|
+
type: IPC_REQUEST_TYPES.BUS_WATCH,
|
|
200
|
+
agent_id: id,
|
|
201
|
+
enabled: enabled !== false,
|
|
202
|
+
});
|
|
203
|
+
} catch {
|
|
204
|
+
// ignore
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function reconcileMultiInternalWatches() {
|
|
209
|
+
const next = new Set();
|
|
210
|
+
if (multiSession && multiSession.isActive()
|
|
211
|
+
&& typeof multiSession.listInternalAgentIds === "function") {
|
|
212
|
+
for (const id of multiSession.listInternalAgentIds()) {
|
|
213
|
+
next.add(id);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
for (const id of next) {
|
|
217
|
+
if (!watchedInternalAgents.has(id)) sendInternalAgentWatch(id, true);
|
|
218
|
+
}
|
|
219
|
+
for (const id of watchedInternalAgents) {
|
|
220
|
+
if (!next.has(id)) sendInternalAgentWatch(id, false);
|
|
221
|
+
}
|
|
222
|
+
watchedInternalAgents.clear();
|
|
223
|
+
for (const id of next) watchedInternalAgents.add(id);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function mirrorBusToMultiPanes(data = {}) {
|
|
227
|
+
if (!multiSession || !multiSession.isActive()) return false;
|
|
228
|
+
if (typeof multiSession.writeToPane !== "function") return false;
|
|
229
|
+
const ids = typeof multiSession.listInternalAgentIds === "function"
|
|
230
|
+
? multiSession.listInternalAgentIds()
|
|
231
|
+
: [...watchedInternalAgents];
|
|
232
|
+
if (!ids || ids.length === 0) return false;
|
|
233
|
+
return writeMultiPaneBusEvent(data, {
|
|
234
|
+
agentIds: ids,
|
|
235
|
+
getMeta: (agentId) => {
|
|
236
|
+
try { return controller.session.metaMap.get(agentId) || {}; } catch { return {}; }
|
|
237
|
+
},
|
|
238
|
+
writeToPane: (agentId, text) => multiSession.writeToPane(agentId, text),
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function publishDashboardFromStatus(data = {}) {
|
|
243
|
+
const payload = buildDashboardPublishPayload(controller, data);
|
|
244
|
+
publish("agents.snapshot", {
|
|
245
|
+
agents: payload.agents,
|
|
246
|
+
footer: payload.footer,
|
|
247
|
+
});
|
|
248
|
+
if (multiSession && multiSession.isActive()) {
|
|
249
|
+
try { multiSession.syncAgents(); } catch {}
|
|
250
|
+
try { reconcileMultiInternalWatches(); } catch {}
|
|
251
|
+
}
|
|
252
|
+
publish("cron.snapshot", {
|
|
253
|
+
tasks: payload.cron,
|
|
254
|
+
cron: payload.cron,
|
|
255
|
+
loop: payload.loop,
|
|
256
|
+
loop_summary: payload.loop_summary,
|
|
257
|
+
});
|
|
258
|
+
if (payload.loop_summary) {
|
|
259
|
+
publish("loop.set", { text: payload.loop_summary, loop_summary: payload.loop_summary });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function publishProjects() {
|
|
264
|
+
if (!env.globalMode) {
|
|
265
|
+
publish("projects.snapshot", {
|
|
266
|
+
global_mode: false,
|
|
267
|
+
controller_root: projectRoot,
|
|
268
|
+
active_root: activeProjectRoot,
|
|
269
|
+
scope: globalScope,
|
|
270
|
+
projects: [],
|
|
271
|
+
});
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
const projects = loadGlobalProjectRows(activeProjectRoot);
|
|
275
|
+
publish("projects.snapshot", {
|
|
276
|
+
global_mode: true,
|
|
277
|
+
controller_root: projectRoot,
|
|
278
|
+
active_root: activeProjectRoot,
|
|
279
|
+
scope: globalScope,
|
|
280
|
+
projects,
|
|
281
|
+
});
|
|
282
|
+
return projects;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function publish(name, payload) {
|
|
286
|
+
if (!hostRef) return;
|
|
287
|
+
hostRef.broadcast(hostRef.createEvent(name, payload, {
|
|
288
|
+
surface: "chat",
|
|
289
|
+
project_id: projectRoot,
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function publishLossy(name, payload) {
|
|
294
|
+
if (!hostRef) return;
|
|
295
|
+
hostRef.broadcast(hostRef.createLossyEvent(name, payload, {
|
|
296
|
+
surface: "chat",
|
|
297
|
+
project_id: projectRoot,
|
|
298
|
+
}));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function listProjectsForCommands() {
|
|
302
|
+
return loadGlobalProjectRows(activeProjectRoot).map((row) => ({
|
|
303
|
+
project_root: row.root,
|
|
304
|
+
project_name: row.label,
|
|
305
|
+
status: row.status,
|
|
306
|
+
label: row.label,
|
|
307
|
+
root: row.root,
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function resolveSwitchTargetRoot(target = {}) {
|
|
312
|
+
const rawTarget = String(
|
|
313
|
+
(target && (target.projectRoot || target.project_root || target.root || target.target))
|
|
314
|
+
|| target
|
|
315
|
+
|| ""
|
|
316
|
+
).trim();
|
|
317
|
+
if (!rawTarget) return "";
|
|
318
|
+
if (/^\d+$/.test(rawTarget)) {
|
|
319
|
+
const idx = Number.parseInt(rawTarget, 10) - 1;
|
|
320
|
+
const projects = listProjectsForCommands();
|
|
321
|
+
return String((projects[idx] && (projects[idx].project_root || projects[idx].root)) || "");
|
|
322
|
+
}
|
|
323
|
+
return rawTarget;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const tools = createToolMergePublisher((name, payload) => publish(name, payload));
|
|
327
|
+
|
|
328
|
+
const hostApi = {
|
|
329
|
+
async switchToProjectRoot(targetRoot, options = {}) {
|
|
330
|
+
const root = String(targetRoot || "").trim();
|
|
331
|
+
if (!root) return { ok: false, error: "project root unavailable" };
|
|
332
|
+
const pathMod = require("path");
|
|
333
|
+
const label = pathMod.basename(root) || root;
|
|
334
|
+
|
|
335
|
+
if (multiSession && multiSession.isActive()) {
|
|
336
|
+
multiSession.stop();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (env.globalMode && typeof env.isRunning === "function" && !env.isRunning(root)) {
|
|
340
|
+
try {
|
|
341
|
+
const { markProjectStopped } = require("../runtime/projects");
|
|
342
|
+
markProjectStopped(root);
|
|
343
|
+
} catch {
|
|
344
|
+
// ignore
|
|
345
|
+
}
|
|
346
|
+
publishProjects();
|
|
347
|
+
appendLocal("system", `Project ${label} is not running; removed stale dashboard entry`);
|
|
348
|
+
return { ok: false, error: `project is not running: ${label}`, stopped: true };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (agentViewId) {
|
|
352
|
+
try {
|
|
353
|
+
daemonSend({
|
|
354
|
+
type: IPC_REQUEST_TYPES.BUS_WATCH,
|
|
355
|
+
agent_id: agentViewId,
|
|
356
|
+
enabled: false,
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
// ignore
|
|
360
|
+
}
|
|
361
|
+
agentViewId = "";
|
|
362
|
+
publish("agent.view.close", {});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (daemonCoordinator && typeof daemonCoordinator.switchProject === "function") {
|
|
366
|
+
const res = await daemonCoordinator.switchProject({
|
|
367
|
+
projectRoot: root,
|
|
368
|
+
sockPath: socketPath(root),
|
|
369
|
+
autoStart: options.autoStart === true,
|
|
370
|
+
});
|
|
371
|
+
if (!res || res.ok !== true) {
|
|
372
|
+
appendLocal("error", `Switch failed: ${(res && res.error) || "switch failed"}`);
|
|
373
|
+
return res || { ok: false, error: "switch failed" };
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
activeProjectRoot = root;
|
|
378
|
+
globalScope = root === projectRoot ? "controller" : "project";
|
|
379
|
+
controller.session.targetAgent = null;
|
|
380
|
+
controller.session.agents = [];
|
|
381
|
+
controller.session.metaMap.clear();
|
|
382
|
+
controller.session.labelMap.clear();
|
|
383
|
+
controller.session.footer = "";
|
|
384
|
+
publish("agents.snapshot", { agents: [], footer: "no agents" });
|
|
385
|
+
publish("prompt.set_prefix", { prefix: "› " });
|
|
386
|
+
publishProjects();
|
|
387
|
+
publish("app.snapshot", buildSnapshot());
|
|
388
|
+
publish("status.set", { text: `project ${options.label || label}` });
|
|
389
|
+
if (typeof controller.requestDaemonStatus === "function") {
|
|
390
|
+
controller.requestDaemonStatus();
|
|
391
|
+
}
|
|
392
|
+
return { ok: true, project_root: root, root };
|
|
393
|
+
},
|
|
394
|
+
async switchToControllerRoot() {
|
|
395
|
+
if (multiSession && multiSession.isActive()) {
|
|
396
|
+
multiSession.stop();
|
|
397
|
+
}
|
|
398
|
+
if (!env.globalMode) {
|
|
399
|
+
return { ok: true, project_root: projectRoot, root: projectRoot };
|
|
400
|
+
}
|
|
401
|
+
if (daemonCoordinator && typeof daemonCoordinator.switchProject === "function") {
|
|
402
|
+
const res = await daemonCoordinator.switchProject({
|
|
403
|
+
projectRoot,
|
|
404
|
+
sockPath: socketPath(projectRoot),
|
|
405
|
+
});
|
|
406
|
+
if (!res || res.ok !== true) {
|
|
407
|
+
appendLocal("error", `Switch to global failed: ${(res && res.error) || "switch failed"}`);
|
|
408
|
+
return res || { ok: false, error: "switch to global failed" };
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (agentViewId) {
|
|
412
|
+
try {
|
|
413
|
+
daemonSend({
|
|
414
|
+
type: IPC_REQUEST_TYPES.BUS_WATCH,
|
|
415
|
+
agent_id: agentViewId,
|
|
416
|
+
enabled: false,
|
|
417
|
+
});
|
|
418
|
+
} catch {
|
|
419
|
+
// ignore
|
|
420
|
+
}
|
|
421
|
+
agentViewId = "";
|
|
422
|
+
publish("agent.view.close", {});
|
|
423
|
+
}
|
|
424
|
+
activeProjectRoot = projectRoot;
|
|
425
|
+
globalScope = "controller";
|
|
426
|
+
controller.session.targetAgent = null;
|
|
427
|
+
controller.session.agents = [];
|
|
428
|
+
controller.session.metaMap.clear();
|
|
429
|
+
controller.session.labelMap.clear();
|
|
430
|
+
controller.session.footer = "";
|
|
431
|
+
publish("agents.snapshot", { agents: [], footer: "no agents" });
|
|
432
|
+
publish("prompt.set_prefix", { prefix: "› " });
|
|
433
|
+
publishProjects();
|
|
434
|
+
publish("app.snapshot", buildSnapshot());
|
|
435
|
+
publish("status.set", { text: "global controller" });
|
|
436
|
+
if (typeof controller.requestDaemonStatus === "function") {
|
|
437
|
+
controller.requestDaemonStatus();
|
|
438
|
+
}
|
|
439
|
+
return { ok: true, project_root: projectRoot, root: projectRoot };
|
|
440
|
+
},
|
|
441
|
+
openBusAgentView(agentId, label = "") {
|
|
442
|
+
const id = String(agentId || "").trim();
|
|
443
|
+
if (!id) return { ok: false, error: "missing agent_id" };
|
|
444
|
+
const viewLabel = String(label || id);
|
|
445
|
+
if (agentViewId && agentViewId !== id) {
|
|
446
|
+
try {
|
|
447
|
+
daemonSend({
|
|
448
|
+
type: IPC_REQUEST_TYPES.BUS_WATCH,
|
|
449
|
+
agent_id: agentViewId,
|
|
450
|
+
enabled: false,
|
|
451
|
+
});
|
|
452
|
+
} catch {
|
|
453
|
+
// ignore
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
agentViewId = id;
|
|
457
|
+
controller.session.targetAgent = null;
|
|
458
|
+
publish("prompt.set_prefix", { prefix: "› " });
|
|
459
|
+
try {
|
|
460
|
+
const { loadInternalAgentLogHistory } = require("../app/chat/internalAgentLogHistory");
|
|
461
|
+
const history = loadInternalAgentLogHistory(activeProjectRoot, id, {
|
|
462
|
+
width: 80,
|
|
463
|
+
}) || [];
|
|
464
|
+
const entries = historyToEntries(
|
|
465
|
+
Array.isArray(history) ? history.map((line) => String(line || "")) : []
|
|
466
|
+
);
|
|
467
|
+
publish("agent.view.open", {
|
|
468
|
+
agent_id: id,
|
|
469
|
+
label: viewLabel,
|
|
470
|
+
status: "ready",
|
|
471
|
+
entries,
|
|
472
|
+
});
|
|
473
|
+
} catch {
|
|
474
|
+
publish("agent.view.open", {
|
|
475
|
+
agent_id: id,
|
|
476
|
+
label: viewLabel,
|
|
477
|
+
status: "ready",
|
|
478
|
+
entries: [],
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
try {
|
|
482
|
+
daemonSend({
|
|
483
|
+
type: IPC_REQUEST_TYPES.BUS_WATCH,
|
|
484
|
+
agent_id: id,
|
|
485
|
+
enabled: true,
|
|
486
|
+
});
|
|
487
|
+
} catch {
|
|
488
|
+
// ignore
|
|
489
|
+
}
|
|
490
|
+
return { ok: true, mode: "agent_view", agent_id: id };
|
|
491
|
+
},
|
|
492
|
+
isInternalAgent(agentId) {
|
|
493
|
+
const id = String(agentId || "").trim();
|
|
494
|
+
if (!id) return false;
|
|
495
|
+
const enter = resolveAgentEnterRequest({
|
|
496
|
+
agentId: id,
|
|
497
|
+
projectRoot: activeProjectRoot,
|
|
498
|
+
activeAgentMeta: controller.session.metaMap,
|
|
499
|
+
settings,
|
|
500
|
+
});
|
|
501
|
+
if (enter && enter.useBus) return true;
|
|
502
|
+
// UI launch mode internal + agent without activate/socket → treat as bus.
|
|
503
|
+
const mode = String(settings.launchMode || settings.launch_mode || "").trim();
|
|
504
|
+
if (mode === "internal" && enter && !enter.supportsActivate && !enter.supportsSocket) {
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
return false;
|
|
508
|
+
},
|
|
509
|
+
/** Internal activate: split like multi-with-1-agent (kind=side). Not /multi. */
|
|
510
|
+
startSide(agentId) {
|
|
511
|
+
const id = String(agentId || "").trim();
|
|
512
|
+
if (!id) return { ok: false, error: "missing agent_id" };
|
|
513
|
+
if (!multiSession) return { ok: false, error: "split session not initialised" };
|
|
514
|
+
// Prefer capability check, but do not hard-fail — connected Rust child
|
|
515
|
+
// may be mid-handshake; multi.set is still the right wire.
|
|
516
|
+
const caps = hostRef && typeof hostRef.getClientCapabilities === "function"
|
|
517
|
+
? hostRef.getClientCapabilities()
|
|
518
|
+
: [];
|
|
519
|
+
if (caps.length > 0 && !caps.includes(MULTI_FRAMES_CAPABILITY)) {
|
|
520
|
+
return {
|
|
521
|
+
ok: false,
|
|
522
|
+
error: `Rust TUI lacks ${MULTI_FRAMES_CAPABILITY}; rebuild ufoo-tui`,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
// Close fullscreen AgentView if it was open.
|
|
526
|
+
if (agentViewId) {
|
|
527
|
+
try {
|
|
528
|
+
daemonSend({
|
|
529
|
+
type: IPC_REQUEST_TYPES.BUS_WATCH,
|
|
530
|
+
agent_id: agentViewId,
|
|
531
|
+
enabled: false,
|
|
532
|
+
});
|
|
533
|
+
} catch {
|
|
534
|
+
// ignore
|
|
535
|
+
}
|
|
536
|
+
agentViewId = "";
|
|
537
|
+
publish("agent.view.close", {});
|
|
538
|
+
}
|
|
539
|
+
// /multi stays /multi — stop it before entering side.
|
|
540
|
+
if (multiSession.isMultiKind && multiSession.isMultiKind()) {
|
|
541
|
+
multiSession.stop();
|
|
542
|
+
}
|
|
543
|
+
const result = multiSession.start({
|
|
544
|
+
kind: "side",
|
|
545
|
+
agentIds: [id],
|
|
546
|
+
focus: { target: "agent", agent_id: id },
|
|
547
|
+
});
|
|
548
|
+
if (result && result.ok) {
|
|
549
|
+
controller.session.targetAgent = null;
|
|
550
|
+
publish("prompt.set_prefix", { prefix: "› " });
|
|
551
|
+
publish("status.set", { text: "ready" });
|
|
552
|
+
try { reconcileMultiInternalWatches(); } catch { /* ignore */ }
|
|
553
|
+
return result;
|
|
554
|
+
}
|
|
555
|
+
return result || { ok: false, error: "side start failed" };
|
|
556
|
+
},
|
|
557
|
+
enterAgentView(agentId, options = {}) {
|
|
558
|
+
const id = String(agentId || "").trim();
|
|
559
|
+
if (!id) return;
|
|
560
|
+
const enter = resolveAgentEnterRequest({
|
|
561
|
+
agentId: id,
|
|
562
|
+
projectRoot: activeProjectRoot,
|
|
563
|
+
activeAgentMeta: controller.session.metaMap,
|
|
564
|
+
settings,
|
|
565
|
+
});
|
|
566
|
+
const label = (() => {
|
|
567
|
+
const meta = controller.session.metaMap.get(id) || {};
|
|
568
|
+
return meta.display_nickname || meta.nickname || id;
|
|
569
|
+
})();
|
|
570
|
+
const action = options.useBus
|
|
571
|
+
? "internal"
|
|
572
|
+
: resolveDashboardAgentEnterAction(enter);
|
|
573
|
+
const isInternal = action === "internal"
|
|
574
|
+
|| (enter && enter.useBus)
|
|
575
|
+
|| options.useBus;
|
|
576
|
+
|
|
577
|
+
// /multi (including internal panes): focus the in-window pane.
|
|
578
|
+
if (multiSession && multiSession.isMultiKind && multiSession.isMultiKind()) {
|
|
579
|
+
try { multiSession.syncAgents(); } catch {}
|
|
580
|
+
const focused = multiSession.focusAgent(id);
|
|
581
|
+
if (focused && focused.ok) {
|
|
582
|
+
appendLocal("system", `Multi focus → ${id}`);
|
|
583
|
+
return { ok: true, mode: "multi_focus", agent_id: id };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Non-multi internal activate → side (same chrome as multi×1).
|
|
588
|
+
if (isInternal) {
|
|
589
|
+
const side = hostApi.startSide(id);
|
|
590
|
+
if (side && side.ok) {
|
|
591
|
+
return { ok: true, mode: "side", agent_id: id };
|
|
592
|
+
}
|
|
593
|
+
appendLocal("error", (side && side.error) || "side start failed");
|
|
594
|
+
return side || { ok: false, error: "side start failed" };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Already in side for this agent: re-focus.
|
|
598
|
+
if (multiSession && multiSession.isSideKind && multiSession.isSideKind()) {
|
|
599
|
+
const focused = multiSession.focusAgent(id);
|
|
600
|
+
if (focused && focused.ok) {
|
|
601
|
+
return { ok: true, mode: "side", agent_id: id };
|
|
602
|
+
}
|
|
603
|
+
// Different agent — switch side target.
|
|
604
|
+
const side = hostApi.startSide(id);
|
|
605
|
+
if (side && side.ok) return { ok: true, mode: "side", agent_id: id };
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Ink parity: activate = focus the agent's external terminal window/tab.
|
|
609
|
+
if (action === "activate") {
|
|
610
|
+
try {
|
|
611
|
+
const AgentActivator = require("../coordination/bus/activate");
|
|
612
|
+
const activator = new AgentActivator(activeProjectRoot || projectRoot);
|
|
613
|
+
void activator.activate(id).catch((err) => {
|
|
614
|
+
appendLocal(
|
|
615
|
+
"error",
|
|
616
|
+
`Failed to activate ${id}: ${err && err.message ? err.message : err}`
|
|
617
|
+
);
|
|
618
|
+
});
|
|
619
|
+
appendLocal("system", `Activated ${label}`);
|
|
620
|
+
return { ok: true, mode: "activate", agent_id: id };
|
|
621
|
+
} catch (err) {
|
|
622
|
+
appendLocal(
|
|
623
|
+
"error",
|
|
624
|
+
`Failed to activate ${id}: ${err && err.message ? err.message : err}`
|
|
625
|
+
);
|
|
626
|
+
return { ok: false, mode: "activate", agent_id: id, error: String(err && err.message || err) };
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// No PTY fullscreen handoff — host/socket agents need activate capability.
|
|
631
|
+
appendLocal(
|
|
632
|
+
"error",
|
|
633
|
+
`Cannot enter ${label}: no activate support (host should expose activate; use /mode terminal|tmux|internal otherwise)`
|
|
634
|
+
);
|
|
635
|
+
return { ok: false, mode: "none", agent_id: id, error: "no activate support" };
|
|
636
|
+
},
|
|
637
|
+
getAgentAdapter(agentId) {
|
|
638
|
+
try {
|
|
639
|
+
const { createTerminalAdapterRouter } = require("../runtime/terminal/adapterRouter");
|
|
640
|
+
const meta = controller.session.metaMap.get(agentId) || {};
|
|
641
|
+
const launchMode = String(
|
|
642
|
+
meta.launch_mode || meta.launchMode || settings.launchMode || ""
|
|
643
|
+
).trim();
|
|
644
|
+
return createTerminalAdapterRouter().getAdapter({ launchMode, agentId, meta });
|
|
645
|
+
} catch {
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
function appendLocal(kind, text, speaker = "") {
|
|
652
|
+
const MARKDOWN_KINDS = new Set(["assistant", "agent", "report", "error"]);
|
|
653
|
+
const raw = String(text == null ? "" : text);
|
|
654
|
+
let lines = [raw];
|
|
655
|
+
if (MARKDOWN_KINDS.has(kind)) {
|
|
656
|
+
try {
|
|
657
|
+
if (!appendLocal._mdState) appendLocal._mdState = { inCodeBlock: false };
|
|
658
|
+
lines = fmt.renderLogLinesWithMarkdownAnsi(raw, appendLocal._mdState);
|
|
659
|
+
if (!Array.isArray(lines) || lines.length === 0) lines = raw.split(/\r?\n/);
|
|
660
|
+
} catch {
|
|
661
|
+
lines = raw.split(/\r?\n/);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
let last = null;
|
|
665
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
666
|
+
entrySeq += 1;
|
|
667
|
+
const line = String(lines[i] || "");
|
|
668
|
+
last = {
|
|
669
|
+
id: `live-${entrySeq}`,
|
|
670
|
+
kind,
|
|
671
|
+
text: /\x1b\[/.test(line) ? line : stripTags(line),
|
|
672
|
+
speaker: i === 0 ? speaker : "",
|
|
673
|
+
};
|
|
674
|
+
publish("transcript.append", last);
|
|
675
|
+
}
|
|
676
|
+
try {
|
|
677
|
+
const { appendChatHistory } = require("../app/chat/historyStore");
|
|
678
|
+
appendChatHistory(
|
|
679
|
+
activeProjectRoot || projectRoot,
|
|
680
|
+
kind,
|
|
681
|
+
text,
|
|
682
|
+
{ speaker },
|
|
683
|
+
historyOptions()
|
|
684
|
+
);
|
|
685
|
+
} catch {
|
|
686
|
+
controller.appendChatHistory(kind, text, { speaker });
|
|
687
|
+
}
|
|
688
|
+
return last;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const controller = createChatController({
|
|
692
|
+
projectRoot,
|
|
693
|
+
globalMode: env.globalMode,
|
|
694
|
+
ports: {
|
|
695
|
+
publish,
|
|
696
|
+
logMessage: (kind, text) => {
|
|
697
|
+
const normalized = kind === "error" ? "error"
|
|
698
|
+
: kind === "user" ? "user"
|
|
699
|
+
: kind === "assistant" ? "assistant"
|
|
700
|
+
: "system";
|
|
701
|
+
appendLocal(normalized, text);
|
|
702
|
+
},
|
|
703
|
+
setStatus: (text) => publish("status.set", { text: stripTags(text || "ready") }),
|
|
704
|
+
appendHistory: (type, text, meta = {}) => {
|
|
705
|
+
const { appendChatHistory } = require("../app/chat/historyStore");
|
|
706
|
+
appendChatHistory(activeProjectRoot || projectRoot, type, text, meta, historyOptions());
|
|
707
|
+
},
|
|
708
|
+
getHistoryOptions: () => historyOptions(),
|
|
709
|
+
clearLog: () => {
|
|
710
|
+
try {
|
|
711
|
+
const { chatHistoryFilePath } = require("../app/chat/historyStore");
|
|
712
|
+
const fs = require("fs");
|
|
713
|
+
const file = chatHistoryFilePath(activeProjectRoot || projectRoot, historyOptions());
|
|
714
|
+
if (file && fs.existsSync(file)) fs.writeFileSync(file, "");
|
|
715
|
+
} catch {
|
|
716
|
+
// ignore
|
|
717
|
+
}
|
|
718
|
+
publish("transcript.reset", {});
|
|
719
|
+
},
|
|
720
|
+
restartDaemon: async () => {
|
|
721
|
+
if (daemonCoordinator && typeof daemonCoordinator.restart === "function") {
|
|
722
|
+
await daemonCoordinator.restart();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const { restartDaemonLifecycle } = require("../runtime/daemon/restart");
|
|
726
|
+
const { startDaemon, stopDaemon } = require("../app/chat/transport");
|
|
727
|
+
await restartDaemonLifecycle({
|
|
728
|
+
projectRoot: activeProjectRoot || projectRoot,
|
|
729
|
+
stopDaemon: (root) => stopDaemon(root, { source: "rust-command:/daemon restart" }),
|
|
730
|
+
startDaemon,
|
|
731
|
+
});
|
|
732
|
+
},
|
|
733
|
+
isDaemonRunning: (root) => {
|
|
734
|
+
const { isRunning } = require("../runtime/daemon");
|
|
735
|
+
return isRunning(root || activeProjectRoot || projectRoot);
|
|
736
|
+
},
|
|
737
|
+
enterAgentView: (...args) => hostApi.enterAgentView(...args),
|
|
738
|
+
getAgentAdapter: (...args) => hostApi.getAgentAdapter(...args),
|
|
739
|
+
focusMultiPane: async (agentId) => {
|
|
740
|
+
// /multi: focus pane (terminal or internal — multi unchanged).
|
|
741
|
+
if (multiSession && multiSession.isMultiKind && multiSession.isMultiKind()) {
|
|
742
|
+
try { multiSession.syncAgents(); } catch { /* ignore */ }
|
|
743
|
+
const focused = multiSession.focusAgent(agentId);
|
|
744
|
+
if (focused && focused.ok) {
|
|
745
|
+
appendLocal("system", `Multi focus → ${agentId}`);
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
// Non-multi internal → side split (not user-facing /multi).
|
|
751
|
+
if (hostApi.isInternalAgent(agentId)) {
|
|
752
|
+
const side = hostApi.startSide(agentId);
|
|
753
|
+
return Boolean(side && side.ok);
|
|
754
|
+
}
|
|
755
|
+
return false;
|
|
756
|
+
},
|
|
757
|
+
activateAgent: async (agentId) => {
|
|
758
|
+
if (multiSession && multiSession.isMultiKind && multiSession.isMultiKind()) {
|
|
759
|
+
try { multiSession.syncAgents(); } catch { /* ignore */ }
|
|
760
|
+
const focused = multiSession.focusAgent(agentId);
|
|
761
|
+
if (focused && focused.ok) {
|
|
762
|
+
appendLocal("system", `Multi focus → ${agentId}`);
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
if (hostApi.isInternalAgent(agentId)) {
|
|
767
|
+
const side = hostApi.startSide(agentId);
|
|
768
|
+
if (!side || !side.ok) {
|
|
769
|
+
appendLocal("error", (side && side.error) || "side start failed");
|
|
770
|
+
}
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
const AgentActivator = require("../coordination/bus/activate");
|
|
774
|
+
const activator = new AgentActivator(activeProjectRoot || projectRoot);
|
|
775
|
+
await activator.activate(agentId);
|
|
776
|
+
},
|
|
777
|
+
listProjects: () => listProjectsForCommands(),
|
|
778
|
+
getCurrentProject: () => ({ project_root: activeProjectRoot }),
|
|
779
|
+
getActiveProjectRoot: () => activeProjectRoot,
|
|
780
|
+
switchProject: async (target) => {
|
|
781
|
+
const root = resolveSwitchTargetRoot(target);
|
|
782
|
+
if (!root) return { ok: false, error: "project root unavailable" };
|
|
783
|
+
return hostApi.switchToProjectRoot(root, { focusInput: true });
|
|
784
|
+
},
|
|
785
|
+
dispatch: (action) => {
|
|
786
|
+
if (!action || typeof action !== "object") return;
|
|
787
|
+
if (action.type === "stream/begin") {
|
|
788
|
+
tools.beginScope();
|
|
789
|
+
const speaker = String(action.publisher || "");
|
|
790
|
+
const id = `stream-${speaker || "agent"}-${Date.now()}`;
|
|
791
|
+
streamIds.set(speaker, id);
|
|
792
|
+
publish("stream.start", { id, speaker });
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
if (action.type === "stream/delta") {
|
|
796
|
+
const key = String(action.publisher || "");
|
|
797
|
+
let id = streamIds.get(key);
|
|
798
|
+
if (!id) {
|
|
799
|
+
id = `stream-${key || "agent"}-${Date.now()}`;
|
|
800
|
+
streamIds.set(key, id);
|
|
801
|
+
publish("stream.start", { id, speaker: key });
|
|
802
|
+
}
|
|
803
|
+
publish("stream.delta", { id, text: String(action.delta || ""), speaker: key });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (action.type === "stream/end") {
|
|
807
|
+
tools.flush();
|
|
808
|
+
for (const [key, id] of [...streamIds.entries()]) {
|
|
809
|
+
publish("stream.done", { id });
|
|
810
|
+
streamIds.delete(key);
|
|
811
|
+
}
|
|
812
|
+
publish("status.set", { text: "ready", busy: false });
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
if (action.type === "log/clear") {
|
|
816
|
+
publish("transcript.reset", {});
|
|
817
|
+
}
|
|
818
|
+
},
|
|
819
|
+
toggleMultiWindow: () => {
|
|
820
|
+
if (!multiSession) {
|
|
821
|
+
appendLocal("error", "Multi-window session not initialised");
|
|
822
|
+
return false;
|
|
823
|
+
}
|
|
824
|
+
// Exit /multi when already in multi. If currently in side (internal
|
|
825
|
+
// activate split), fall through and open real /multi instead.
|
|
826
|
+
if (multiSession.isActive() && !(multiSession.isSideKind && multiSession.isSideKind())) {
|
|
827
|
+
multiSession.stop();
|
|
828
|
+
try { reconcileMultiInternalWatches(); } catch { /* ignore */ }
|
|
829
|
+
appendLocal("system", "Exited multi-window.");
|
|
830
|
+
return true;
|
|
831
|
+
}
|
|
832
|
+
if (multiSession.isActive()) {
|
|
833
|
+
multiSession.stop();
|
|
834
|
+
try { reconcileMultiInternalWatches(); } catch { /* ignore */ }
|
|
835
|
+
}
|
|
836
|
+
const caps = hostRef && typeof hostRef.getClientCapabilities === "function"
|
|
837
|
+
? hostRef.getClientCapabilities()
|
|
838
|
+
: [];
|
|
839
|
+
if (!caps.includes(MULTI_FRAMES_CAPABILITY)) {
|
|
840
|
+
appendLocal(
|
|
841
|
+
"error",
|
|
842
|
+
`Rust TUI lacks ${MULTI_FRAMES_CAPABILITY}; upgrade ufoo-tui`
|
|
843
|
+
);
|
|
844
|
+
return false;
|
|
845
|
+
}
|
|
846
|
+
const result = multiSession.start({ kind: "multi" });
|
|
847
|
+
if (!result.ok) {
|
|
848
|
+
appendLocal("error", result.error || "multi-window start failed");
|
|
849
|
+
return false;
|
|
850
|
+
}
|
|
851
|
+
try { reconcileMultiInternalWatches(); } catch { /* ignore */ }
|
|
852
|
+
appendLocal("system", "Entered multi-window (Ctrl+W focus · Ctrl+Q exit).");
|
|
853
|
+
return true;
|
|
854
|
+
},
|
|
855
|
+
applyChatSettings: (patch = {}) => {
|
|
856
|
+
if (patch.launchMode != null) settings.launchMode = patch.launchMode;
|
|
857
|
+
if (patch.agentProvider != null) settings.agentProvider = patch.agentProvider;
|
|
858
|
+
publish("settings.snapshot", buildSettingsSnapshot(settings));
|
|
859
|
+
},
|
|
860
|
+
},
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
function getActiveAgentIds() {
|
|
864
|
+
const ids = [];
|
|
865
|
+
try {
|
|
866
|
+
for (const id of controller.session.metaMap.keys()) {
|
|
867
|
+
const clean = String(id || "").trim();
|
|
868
|
+
if (clean) ids.push(clean);
|
|
869
|
+
}
|
|
870
|
+
} catch {
|
|
871
|
+
// ignore
|
|
872
|
+
}
|
|
873
|
+
return ids;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function getAgentLabel(agentId) {
|
|
877
|
+
try {
|
|
878
|
+
const meta = controller.session.metaMap.get(agentId) || {};
|
|
879
|
+
return String(meta.display_nickname || meta.nickname || agentId);
|
|
880
|
+
} catch {
|
|
881
|
+
return agentId;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function getAgentMetaForMulti(agentId) {
|
|
886
|
+
try {
|
|
887
|
+
return controller.session.metaMap.get(agentId) || {};
|
|
888
|
+
} catch {
|
|
889
|
+
return {};
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function resolveMultiPaneOptions(agentId) {
|
|
894
|
+
const enter = resolveAgentEnterRequest({
|
|
895
|
+
agentId,
|
|
896
|
+
projectRoot: activeProjectRoot,
|
|
897
|
+
activeAgentMeta: controller.session.metaMap,
|
|
898
|
+
settings,
|
|
899
|
+
});
|
|
900
|
+
if (!enter || !enter.useBus) return { mode: "socket" };
|
|
901
|
+
let initialLines = [];
|
|
902
|
+
try {
|
|
903
|
+
const { loadInternalAgentLogHistory } = require("../app/chat/internalAgentLogHistory");
|
|
904
|
+
initialLines = loadInternalAgentLogHistory(activeProjectRoot, agentId, {
|
|
905
|
+
maxEvents: 200,
|
|
906
|
+
maxLines: 200,
|
|
907
|
+
}) || [];
|
|
908
|
+
} catch {
|
|
909
|
+
initialLines = [];
|
|
910
|
+
}
|
|
911
|
+
return {
|
|
912
|
+
mode: "internal",
|
|
913
|
+
initialLines: [
|
|
914
|
+
`ufoo internal agent · ${getAgentLabel(agentId)}`,
|
|
915
|
+
`agent: ${agentId}`,
|
|
916
|
+
"",
|
|
917
|
+
...initialLines,
|
|
918
|
+
],
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
multiSession = createRustMultiSession({
|
|
923
|
+
projectRoot,
|
|
924
|
+
getActiveAgents: getActiveAgentIds,
|
|
925
|
+
getAgentMeta: getAgentMetaForMulti,
|
|
926
|
+
getInjectSockPath: (id) =>
|
|
927
|
+
require("../app/chat/agentEnter").resolveInjectSockPathForAgent(activeProjectRoot, id),
|
|
928
|
+
resolvePaneOptions: resolveMultiPaneOptions,
|
|
929
|
+
onInternalSubmit: (agentId, message) => {
|
|
930
|
+
try {
|
|
931
|
+
daemonSend({
|
|
932
|
+
type: IPC_REQUEST_TYPES.BUS_SEND,
|
|
933
|
+
target: agentId,
|
|
934
|
+
message: String(message || ""),
|
|
935
|
+
injection_mode: "immediate",
|
|
936
|
+
source: "rust-multi-window",
|
|
937
|
+
});
|
|
938
|
+
} catch {
|
|
939
|
+
// ignore
|
|
940
|
+
}
|
|
941
|
+
},
|
|
942
|
+
publish,
|
|
943
|
+
publishLossy,
|
|
944
|
+
getLabel: getAgentLabel,
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
function buildSnapshot() {
|
|
948
|
+
const { loadChatHistory, loadInputHistory } = require("../app/chat/historyStore");
|
|
949
|
+
const historyRoot = activeProjectRoot || projectRoot;
|
|
950
|
+
const historyOpts = historyOptions();
|
|
951
|
+
const history = loadChatHistory(historyRoot, 200, historyOpts);
|
|
952
|
+
const inputHistory = loadInputHistory(historyRoot, 200, historyOpts);
|
|
953
|
+
const agents = controller.getAgentsSnapshot();
|
|
954
|
+
const settingsSnap = buildSettingsSnapshot(settings);
|
|
955
|
+
const projects = env.globalMode ? loadGlobalProjectRows(activeProjectRoot) : [];
|
|
956
|
+
return {
|
|
957
|
+
status: "ready",
|
|
958
|
+
footer: agents.footer || "",
|
|
959
|
+
entries: historyToEntries(history),
|
|
960
|
+
input_history: Array.isArray(inputHistory) ? inputHistory.filter(Boolean) : [],
|
|
961
|
+
agents: agents.agents,
|
|
962
|
+
settings: settingsSnap,
|
|
963
|
+
launch_mode: settingsSnap.launch_mode,
|
|
964
|
+
agent_provider: settingsSnap.agent_provider,
|
|
965
|
+
mode_options: settingsSnap.mode_options,
|
|
966
|
+
provider_options: settingsSnap.provider_options,
|
|
967
|
+
global_mode: Boolean(env.globalMode),
|
|
968
|
+
controller_root: projectRoot,
|
|
969
|
+
active_root: activeProjectRoot,
|
|
970
|
+
scope: globalScope,
|
|
971
|
+
projects,
|
|
972
|
+
cron: agents.cron && agents.cron.tasks ? agents.cron.tasks : [],
|
|
973
|
+
loop: agents.loop || null,
|
|
974
|
+
loop_summary: require("./dashboardBridge").formatLoopSummary(agents.loop || null),
|
|
975
|
+
multi: multiSession ? multiSession.getSnapshot() : { active: false },
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const uiSocketPath = path.join(
|
|
980
|
+
os.tmpdir(),
|
|
981
|
+
`ufoo-ui-chat-${process.pid}-${Date.now()}.sock`
|
|
982
|
+
);
|
|
983
|
+
const uiAuthToken = createAuthToken();
|
|
984
|
+
|
|
985
|
+
const host = createUiHostServer({
|
|
986
|
+
socketPath: uiSocketPath,
|
|
987
|
+
authToken: uiAuthToken,
|
|
988
|
+
capabilities: ["chat", "scrollback", "prompt", MULTI_FRAMES_CAPABILITY],
|
|
989
|
+
onClientReady(socket) {
|
|
990
|
+
const snap = createEnvelope({
|
|
991
|
+
kind: "snapshot",
|
|
992
|
+
name: "app.snapshot",
|
|
993
|
+
seq: host.nextSeq(),
|
|
994
|
+
scope: { surface: "chat", project_id: projectRoot },
|
|
995
|
+
payload: buildSnapshot(),
|
|
996
|
+
});
|
|
997
|
+
socket.write(encodeMessage(snap));
|
|
998
|
+
if (env.globalMode) publishProjects();
|
|
999
|
+
},
|
|
1000
|
+
async onCommand(cmd) {
|
|
1001
|
+
const name = String(cmd.name || "");
|
|
1002
|
+
const payload = cmd.payload && typeof cmd.payload === "object" ? cmd.payload : {};
|
|
1003
|
+
if (name === "app.exit") {
|
|
1004
|
+
return { ok: true };
|
|
1005
|
+
}
|
|
1006
|
+
if (name === "ui.resync.request") {
|
|
1007
|
+
publish("app.snapshot", buildSnapshot());
|
|
1008
|
+
return { ok: true };
|
|
1009
|
+
}
|
|
1010
|
+
if (name === "completion.request") {
|
|
1011
|
+
const fmt = require("./format");
|
|
1012
|
+
const { COMMAND_TREE, COMMAND_REGISTRY } = require("../app/chat/commands");
|
|
1013
|
+
const agents = controller.session.agents.slice();
|
|
1014
|
+
const agentLabels = agents.map((id) => {
|
|
1015
|
+
const meta = controller.session.metaMap.get(id) || {};
|
|
1016
|
+
return meta.display_nickname || meta.nickname || id;
|
|
1017
|
+
});
|
|
1018
|
+
const dynamic = loadDynamicCompletionSources(activeProjectRoot);
|
|
1019
|
+
const items = fmt.buildCompletions({
|
|
1020
|
+
text: String(payload.text || ""),
|
|
1021
|
+
agents,
|
|
1022
|
+
agentLabels,
|
|
1023
|
+
commands: COMMAND_REGISTRY,
|
|
1024
|
+
commandTree: COMMAND_TREE,
|
|
1025
|
+
groupTemplates: dynamic.groupTemplates,
|
|
1026
|
+
soloProfiles: dynamic.soloProfiles,
|
|
1027
|
+
limit: 20,
|
|
1028
|
+
});
|
|
1029
|
+
publish("completions.set", { items });
|
|
1030
|
+
return { ok: true, count: items.length };
|
|
1031
|
+
}
|
|
1032
|
+
if (name === "task.cancel") {
|
|
1033
|
+
publish("status.set", { text: "cancel requested", busy: false });
|
|
1034
|
+
publish("stream.done", { id: "cancel" });
|
|
1035
|
+
return { ok: true };
|
|
1036
|
+
}
|
|
1037
|
+
if (name === "agent.select") {
|
|
1038
|
+
const agentId = String(payload.agent_id || payload.agentId || "").trim();
|
|
1039
|
+
if (agentId) {
|
|
1040
|
+
controller.session.targetAgent = agentId;
|
|
1041
|
+
const label = payload.label || agentId;
|
|
1042
|
+
publish("status.set", {
|
|
1043
|
+
text: `target @${label}`,
|
|
1044
|
+
});
|
|
1045
|
+
publish("prompt.set_prefix", { prefix: `›@${label} ` });
|
|
1046
|
+
} else {
|
|
1047
|
+
controller.session.targetAgent = null;
|
|
1048
|
+
publish("prompt.set_prefix", { prefix: "› " });
|
|
1049
|
+
}
|
|
1050
|
+
return { ok: true, agent_id: agentId };
|
|
1051
|
+
}
|
|
1052
|
+
if (name === "agent.open" || name === "ui.suspend.request") {
|
|
1053
|
+
const agentId = String(payload.agent_id || payload.agentId || "").trim();
|
|
1054
|
+
if (name === "agent.open") {
|
|
1055
|
+
return hostApi.enterAgentView(agentId, payload) || { ok: true, agent_id: agentId };
|
|
1056
|
+
}
|
|
1057
|
+
// Suspend handoff removed (no PTY mirror). Ignore explicit suspend.
|
|
1058
|
+
appendLocal("system", "ui.suspend ignored (PTY handoff removed)");
|
|
1059
|
+
return { ok: true, suspend: false, agent_id: agentId };
|
|
1060
|
+
}
|
|
1061
|
+
if (name === "multi.exit") {
|
|
1062
|
+
if (multiSession && multiSession.isActive()) {
|
|
1063
|
+
multiSession.stop();
|
|
1064
|
+
try { reconcileMultiInternalWatches(); } catch { /* ignore */ }
|
|
1065
|
+
}
|
|
1066
|
+
return { ok: true };
|
|
1067
|
+
}
|
|
1068
|
+
if (name === "multi.focus") {
|
|
1069
|
+
return multiSession
|
|
1070
|
+
? multiSession.handleFocus(payload)
|
|
1071
|
+
: { ok: false, error: "multi not active" };
|
|
1072
|
+
}
|
|
1073
|
+
if (name === "multi.viewport") {
|
|
1074
|
+
return multiSession
|
|
1075
|
+
? multiSession.handleViewport(payload)
|
|
1076
|
+
: { ok: false, error: "multi not active" };
|
|
1077
|
+
}
|
|
1078
|
+
if (name === "multi.raw") {
|
|
1079
|
+
return multiSession
|
|
1080
|
+
? multiSession.handleRaw(payload)
|
|
1081
|
+
: { ok: false, error: "multi not active" };
|
|
1082
|
+
}
|
|
1083
|
+
if (name === "agent.view.exit") {
|
|
1084
|
+
if (agentViewId) {
|
|
1085
|
+
try {
|
|
1086
|
+
daemonSend({
|
|
1087
|
+
type: IPC_REQUEST_TYPES.BUS_WATCH,
|
|
1088
|
+
agent_id: agentViewId,
|
|
1089
|
+
enabled: false,
|
|
1090
|
+
});
|
|
1091
|
+
} catch {
|
|
1092
|
+
// ignore
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
for (const [key, id] of [...streamIds.entries()]) {
|
|
1096
|
+
if (String(id).startsWith("av-stream-") || key === agentViewId) {
|
|
1097
|
+
publish("stream.done", { id });
|
|
1098
|
+
streamIds.delete(key);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
agentViewId = "";
|
|
1102
|
+
publish("agent.view.close", {});
|
|
1103
|
+
publish("app.snapshot", buildSnapshot());
|
|
1104
|
+
return { ok: true };
|
|
1105
|
+
}
|
|
1106
|
+
if (name === "agent.view.submit") {
|
|
1107
|
+
const agentId = String(payload.agent_id || agentViewId || "").trim();
|
|
1108
|
+
const text = String(payload.text || "").trim();
|
|
1109
|
+
if (!agentId || !text) return { ok: false, error: "missing agent or text" };
|
|
1110
|
+
publish("agent.view.append", {
|
|
1111
|
+
id: `av-user-${Date.now()}`,
|
|
1112
|
+
kind: "user",
|
|
1113
|
+
text: `> ${text}`,
|
|
1114
|
+
speaker: "",
|
|
1115
|
+
});
|
|
1116
|
+
daemonSend({
|
|
1117
|
+
type: IPC_REQUEST_TYPES.BUS_SEND,
|
|
1118
|
+
target: agentId,
|
|
1119
|
+
message: text,
|
|
1120
|
+
injection_mode: "immediate",
|
|
1121
|
+
source: "chat-internal-agent-view",
|
|
1122
|
+
});
|
|
1123
|
+
publish("agent.view.status", { text: "working" });
|
|
1124
|
+
return { ok: true };
|
|
1125
|
+
}
|
|
1126
|
+
if (name === "agent.close") {
|
|
1127
|
+
const agentId = String(payload.agent_id || payload.agentId || "").trim();
|
|
1128
|
+
if (!agentId) return { ok: false, error: "missing agent_id" };
|
|
1129
|
+
daemonSend({ type: IPC_REQUEST_TYPES.CLOSE_AGENT, agent_id: agentId });
|
|
1130
|
+
publish("status.set", { text: `closing ${agentId}` });
|
|
1131
|
+
return { ok: true };
|
|
1132
|
+
}
|
|
1133
|
+
if (name === "cron.stop") {
|
|
1134
|
+
const id = String(payload.id || "").trim();
|
|
1135
|
+
if (!id) return { ok: false, error: "missing cron id" };
|
|
1136
|
+
daemonSend({ type: IPC_REQUEST_TYPES.CRON, operation: "stop", id });
|
|
1137
|
+
publish("status.set", { text: `stopping cron ${payload.label || id}` });
|
|
1138
|
+
controller.requestDaemonStatus();
|
|
1139
|
+
return { ok: true };
|
|
1140
|
+
}
|
|
1141
|
+
if (name === "project.switch") {
|
|
1142
|
+
const root = String(payload.root || "").trim();
|
|
1143
|
+
if (!root) return { ok: false, error: "missing project root" };
|
|
1144
|
+
return hostApi.switchToProjectRoot(root, { label: payload.label || root });
|
|
1145
|
+
}
|
|
1146
|
+
if (name === "project.return_controller") {
|
|
1147
|
+
return hostApi.switchToControllerRoot();
|
|
1148
|
+
}
|
|
1149
|
+
if (name === "project.close") {
|
|
1150
|
+
const root = String(payload.root || "").trim();
|
|
1151
|
+
if (!root) return { ok: false, error: "missing project root" };
|
|
1152
|
+
try {
|
|
1153
|
+
const { createProjectCloseController } = require("../app/chat/projectCloseController");
|
|
1154
|
+
const { stopDaemon } = require("../app/chat/transport");
|
|
1155
|
+
const { isRunning } = require("../runtime/daemon");
|
|
1156
|
+
const projects = loadGlobalProjectRows(activeProjectRoot);
|
|
1157
|
+
const index = projects.findIndex((row) => String(row.root || "") === root);
|
|
1158
|
+
if (index < 0) {
|
|
1159
|
+
return { ok: false, error: "project not found" };
|
|
1160
|
+
}
|
|
1161
|
+
const closer = createProjectCloseController({
|
|
1162
|
+
getProjects: () => projects.map((row) => ({
|
|
1163
|
+
...row,
|
|
1164
|
+
project_name: row.label || row.root,
|
|
1165
|
+
project_root: row.root,
|
|
1166
|
+
})),
|
|
1167
|
+
getActiveProjectRoot: () => activeProjectRoot,
|
|
1168
|
+
resolveProjectRoot: (row) => String((row && (row.root || row.project_root)) || ""),
|
|
1169
|
+
isRunning,
|
|
1170
|
+
stopDaemon,
|
|
1171
|
+
switchProject: async (fallbackRoot) => hostApi.switchToProjectRoot(fallbackRoot),
|
|
1172
|
+
refreshProjects: () => publishProjects(),
|
|
1173
|
+
logMessage: (kind, text) => {
|
|
1174
|
+
const normalized = kind === "error" ? "error" : "system";
|
|
1175
|
+
appendLocal(normalized, stripTags(text));
|
|
1176
|
+
},
|
|
1177
|
+
resolveStatusLine: (text) => publish("status.set", { text: stripTags(text) }),
|
|
1178
|
+
escapeBlessed: (value) => String(value || ""),
|
|
1179
|
+
});
|
|
1180
|
+
const result = await closer.requestCloseProject(index);
|
|
1181
|
+
if (result && result.ok) {
|
|
1182
|
+
publish("app.snapshot", buildSnapshot());
|
|
1183
|
+
}
|
|
1184
|
+
return result;
|
|
1185
|
+
} catch (err) {
|
|
1186
|
+
appendLocal("error", `Close failed: ${err && err.message ? err.message : err}`);
|
|
1187
|
+
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
if (name === "settings.set") {
|
|
1191
|
+
try {
|
|
1192
|
+
const applied = applySettingsPatch(projectRoot, payload);
|
|
1193
|
+
if (!applied.ok) return applied;
|
|
1194
|
+
Object.assign(settings, {
|
|
1195
|
+
launchMode: applied.settings.launch_mode,
|
|
1196
|
+
agentProvider: applied.settings.agent_provider,
|
|
1197
|
+
});
|
|
1198
|
+
publish("settings.snapshot", applied.settings);
|
|
1199
|
+
if (payload.launch_mode || payload.launchMode) {
|
|
1200
|
+
appendLocal("system", `Launch mode: ${applied.settings.launch_mode}`);
|
|
1201
|
+
}
|
|
1202
|
+
if (payload.agent_provider || payload.agentProvider) {
|
|
1203
|
+
const label = applied.settings.provider_options.find(
|
|
1204
|
+
(opt) => opt.value === applied.settings.agent_provider
|
|
1205
|
+
);
|
|
1206
|
+
appendLocal(
|
|
1207
|
+
"system",
|
|
1208
|
+
`ufoo-agent: ${(label && label.label) || applied.settings.agent_provider}`
|
|
1209
|
+
);
|
|
1210
|
+
try {
|
|
1211
|
+
const { getUfooPaths } = require("../coordination/state/paths");
|
|
1212
|
+
const fs = require("fs");
|
|
1213
|
+
const pathMod = require("path");
|
|
1214
|
+
const agentDir = getUfooPaths(projectRoot).agentDir;
|
|
1215
|
+
fs.rmSync(pathMod.join(agentDir, "ufoo-agent.json"), { force: true });
|
|
1216
|
+
fs.rmSync(pathMod.join(agentDir, "ufoo-agent.history.jsonl"), { force: true });
|
|
1217
|
+
} catch {
|
|
1218
|
+
// ignore
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
publish("status.set", { text: "settings saved · restarting daemon…" });
|
|
1222
|
+
try {
|
|
1223
|
+
if (daemonCoordinator && typeof daemonCoordinator.restart === "function") {
|
|
1224
|
+
await daemonCoordinator.restart();
|
|
1225
|
+
}
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
appendLocal("error", `Daemon restart failed: ${err && err.message ? err.message : err}`);
|
|
1228
|
+
}
|
|
1229
|
+
try {
|
|
1230
|
+
daemonSend({ type: IPC_REQUEST_TYPES.STATUS });
|
|
1231
|
+
} catch {
|
|
1232
|
+
// ignore
|
|
1233
|
+
}
|
|
1234
|
+
publish("status.set", { text: "settings saved" });
|
|
1235
|
+
return { ok: true, settings: applied.settings };
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
return { ok: false, error: err && err.message ? err.message : String(err) };
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
if (name === "interaction.respond") {
|
|
1241
|
+
appendLocal("system", payload.cancelled
|
|
1242
|
+
? "interaction cancelled"
|
|
1243
|
+
: `interaction answer: ${String(payload.text || "").slice(0, 200)}`);
|
|
1244
|
+
publish("interaction.clear", {});
|
|
1245
|
+
return { ok: true };
|
|
1246
|
+
}
|
|
1247
|
+
if (name === "input.submit") {
|
|
1248
|
+
const text = String(payload.text || "");
|
|
1249
|
+
const payloadTarget = String(payload.target_agent || payload.targetAgent || "").trim();
|
|
1250
|
+
if (payloadTarget && !controller.session.targetAgent) {
|
|
1251
|
+
controller.session.targetAgent = payloadTarget;
|
|
1252
|
+
}
|
|
1253
|
+
// Empty ›@ Enter for internal: open side split directly (do not
|
|
1254
|
+
// depend solely on tryActivate → focusMultiPane chain).
|
|
1255
|
+
if (!text.trim()) {
|
|
1256
|
+
const target = controller.session.targetAgent || payloadTarget;
|
|
1257
|
+
if (target && hostApi.isInternalAgent(target)
|
|
1258
|
+
&& !(multiSession && multiSession.isMultiKind && multiSession.isMultiKind())) {
|
|
1259
|
+
const side = hostApi.startSide(target);
|
|
1260
|
+
if (!side || !side.ok) {
|
|
1261
|
+
appendLocal("error", `side failed: ${(side && side.error) || "unknown"}`);
|
|
1262
|
+
// Fall through to submitInput for bus AgentView fallback.
|
|
1263
|
+
} else {
|
|
1264
|
+
publish("status.set", { text: "ready", busy: false });
|
|
1265
|
+
return { ok: true, routed: "side", agent_id: target };
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
await controller.submitInput(text);
|
|
1270
|
+
publish("status.set", { text: "ready", busy: false });
|
|
1271
|
+
return { ok: true, routed: text.trim() ? "submit" : "empty" };
|
|
1272
|
+
}
|
|
1273
|
+
return { ok: false, error: `unsupported command ${name}` };
|
|
1274
|
+
},
|
|
1275
|
+
});
|
|
1276
|
+
hostRef = host;
|
|
1277
|
+
await host.listen();
|
|
1278
|
+
|
|
1279
|
+
const router = createDaemonMessageRouter({
|
|
1280
|
+
escapeBlessed: (value) => String(value || ""),
|
|
1281
|
+
stripBlessedTags: stripTags,
|
|
1282
|
+
logMessage: (kind, text) => {
|
|
1283
|
+
const normalized = kind === "error" ? "error"
|
|
1284
|
+
: kind === "user" ? "user"
|
|
1285
|
+
: kind === "assistant" ? "assistant"
|
|
1286
|
+
: "system";
|
|
1287
|
+
if (agentViewId) {
|
|
1288
|
+
publish("agent.view.append", {
|
|
1289
|
+
id: `av-log-${Date.now()}`,
|
|
1290
|
+
kind: normalized,
|
|
1291
|
+
text: stripTags(text),
|
|
1292
|
+
speaker: "",
|
|
1293
|
+
});
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
appendLocal(normalized, text);
|
|
1297
|
+
},
|
|
1298
|
+
renderScreen: () => {},
|
|
1299
|
+
updateDashboard: (data) => {
|
|
1300
|
+
controller.applyStatus(data);
|
|
1301
|
+
publishDashboardFromStatus(data);
|
|
1302
|
+
if (env.globalMode) publishProjects();
|
|
1303
|
+
},
|
|
1304
|
+
requestStatus: () => controller.requestDaemonStatus(),
|
|
1305
|
+
getPending: () => controller.session.pending,
|
|
1306
|
+
setPending: (value) => {
|
|
1307
|
+
controller.session.pending = value || null;
|
|
1308
|
+
},
|
|
1309
|
+
resolveStatusLine: (text) => {
|
|
1310
|
+
if (agentViewId) {
|
|
1311
|
+
publish("agent.view.status", { text: stripTags(text || "ready") });
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
publish("status.set", { text: stripTags(text || "ready") });
|
|
1315
|
+
},
|
|
1316
|
+
enqueueBusStatus: (text) => {
|
|
1317
|
+
if (agentViewId) {
|
|
1318
|
+
publish("agent.view.status", { text: stripTags(text) });
|
|
1319
|
+
return;
|
|
1320
|
+
}
|
|
1321
|
+
publish("status.set", { text: stripTags(text) });
|
|
1322
|
+
},
|
|
1323
|
+
resolveBusStatus: () => {
|
|
1324
|
+
if (agentViewId) {
|
|
1325
|
+
publish("agent.view.status", { text: "ready" });
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
publish("status.set", { text: "ready" });
|
|
1329
|
+
},
|
|
1330
|
+
getCurrentView: () => (agentViewId ? "agent" : "main"),
|
|
1331
|
+
isAgentViewUsesBus: () => Boolean(agentViewId),
|
|
1332
|
+
getViewingAgent: () => agentViewId || "",
|
|
1333
|
+
isAgentEventForViewingAgent: (data, viewingAgent, publisher) => {
|
|
1334
|
+
if (!viewingAgent) return false;
|
|
1335
|
+
const candidates = [
|
|
1336
|
+
viewingAgent,
|
|
1337
|
+
publisher,
|
|
1338
|
+
data && data.publisher,
|
|
1339
|
+
data && data.target,
|
|
1340
|
+
data && data.subscriber,
|
|
1341
|
+
].filter(Boolean).map(String);
|
|
1342
|
+
return candidates.some((id) => (
|
|
1343
|
+
id === viewingAgent
|
|
1344
|
+
|| id.endsWith(`:${viewingAgent}`)
|
|
1345
|
+
|| viewingAgent.endsWith(`:${id}`)
|
|
1346
|
+
|| viewingAgent === id
|
|
1347
|
+
));
|
|
1348
|
+
},
|
|
1349
|
+
writeToAgentTerm: (text, meta = {}) => {
|
|
1350
|
+
if (!agentViewId) return;
|
|
1351
|
+
const streamPayload = meta && meta.streamPayload && typeof meta.streamPayload === "object"
|
|
1352
|
+
? meta.streamPayload
|
|
1353
|
+
: null;
|
|
1354
|
+
const publisher = String((meta && meta.publisher) || agentViewId);
|
|
1355
|
+
const raw = stripTags(text);
|
|
1356
|
+
if (streamPayload) {
|
|
1357
|
+
let id = streamIds.get(publisher);
|
|
1358
|
+
if (!id) {
|
|
1359
|
+
id = `av-stream-${publisher || "agent"}-${Date.now()}`;
|
|
1360
|
+
streamIds.set(publisher, id);
|
|
1361
|
+
publish("stream.start", { id, speaker: publisher });
|
|
1362
|
+
}
|
|
1363
|
+
if (raw) {
|
|
1364
|
+
publish("stream.delta", { id, text: raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n"), speaker: publisher });
|
|
1365
|
+
}
|
|
1366
|
+
if (streamPayload.done || meta.done) {
|
|
1367
|
+
publish("stream.done", { id });
|
|
1368
|
+
streamIds.delete(publisher);
|
|
1369
|
+
publish("agent.view.status", { text: "ready" });
|
|
1370
|
+
} else {
|
|
1371
|
+
publish("agent.view.status", { text: "working" });
|
|
1372
|
+
}
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
if (!raw) return;
|
|
1376
|
+
publish("agent.view.append", {
|
|
1377
|
+
id: `av-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
|
1378
|
+
kind: meta && meta.kind ? meta.kind : "assistant",
|
|
1379
|
+
text: raw.replace(/\r\n/g, "\n").replace(/\r/g, "\n"),
|
|
1380
|
+
speaker: publisher,
|
|
1381
|
+
});
|
|
1382
|
+
},
|
|
1383
|
+
beginStream: (...args) => controller.getStreamState().beginStream(...args),
|
|
1384
|
+
appendStreamDelta: (...args) => controller.getStreamState().appendStreamDelta(...args),
|
|
1385
|
+
finalizeStream: (...args) => controller.getStreamState().finalizeStream(...args),
|
|
1386
|
+
hasStream: (...args) => controller.getStreamState().hasStream(...args),
|
|
1387
|
+
getPendingState: (...args) => controller.getStreamState().getPendingState(...args),
|
|
1388
|
+
consumePendingDelivery: (...args) => controller.getStreamState().consumePendingDelivery(...args),
|
|
1389
|
+
setTransientAgentState: (agentId, value, options = {}) => {
|
|
1390
|
+
controller.patchAgentActivity(agentId, {
|
|
1391
|
+
activity_state: value,
|
|
1392
|
+
activity_detail: options.detail || "",
|
|
1393
|
+
});
|
|
1394
|
+
if (agentViewId && agentId === agentViewId) {
|
|
1395
|
+
publish("agent.view.status", { text: value || "ready" });
|
|
1396
|
+
}
|
|
1397
|
+
},
|
|
1398
|
+
clearTransientAgentState: (agentId) => {
|
|
1399
|
+
controller.patchAgentActivity(agentId, {
|
|
1400
|
+
activity_state: "",
|
|
1401
|
+
activity_detail: "",
|
|
1402
|
+
});
|
|
1403
|
+
if (agentViewId && agentId === agentViewId) {
|
|
1404
|
+
publish("agent.view.status", { text: "ready" });
|
|
1405
|
+
}
|
|
1406
|
+
},
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
const daemonConnection = createDaemonConnection({
|
|
1410
|
+
connectClient: daemonTransport.connectClient.bind(daemonTransport),
|
|
1411
|
+
handleMessage: (msg) => {
|
|
1412
|
+
if (typeof routedMessageHandler === "function" && routedMessageHandler(msg)) {
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
if (!msg || typeof msg !== "object") return;
|
|
1416
|
+
if (msg.type === IPC_RESPONSE_TYPES.BUS) {
|
|
1417
|
+
try { mirrorBusToMultiPanes(msg.data || {}); } catch { /* ignore */ }
|
|
1418
|
+
}
|
|
1419
|
+
if (msg.type === IPC_RESPONSE_TYPES.BUS_SEND_OK) {
|
|
1420
|
+
if (agentViewId) {
|
|
1421
|
+
publish("agent.view.append", {
|
|
1422
|
+
id: `av-ok-${Date.now()}`,
|
|
1423
|
+
kind: "system",
|
|
1424
|
+
text: "✓ Message delivered",
|
|
1425
|
+
speaker: "",
|
|
1426
|
+
});
|
|
1427
|
+
publish("agent.view.status", { text: "ready" });
|
|
1428
|
+
} else {
|
|
1429
|
+
appendLocal("system", "✓ Message delivered");
|
|
1430
|
+
publish("status.set", { text: "ready" });
|
|
1431
|
+
}
|
|
1432
|
+
controller.requestDaemonStatus();
|
|
1433
|
+
return;
|
|
1434
|
+
}
|
|
1435
|
+
router.handleMessage(msg);
|
|
1436
|
+
},
|
|
1437
|
+
queueStatusLine: (text) => publish("status.set", { text: stripTags(text) }),
|
|
1438
|
+
resolveStatusLine: (text) => publish("status.set", { text: stripTags(text || "ready") }),
|
|
1439
|
+
logMessage: (kind, text) => {
|
|
1440
|
+
const normalized = kind === "error" ? "error" : "system";
|
|
1441
|
+
appendLocal(normalized, stripTags(text));
|
|
1442
|
+
},
|
|
1443
|
+
});
|
|
1444
|
+
|
|
1445
|
+
const { createDaemonCoordinator } = require("../app/chat/daemonCoordinator");
|
|
1446
|
+
const { startDaemon, stopDaemon } = require("../app/chat/transport");
|
|
1447
|
+
const { isRunning } = require("../runtime/daemon");
|
|
1448
|
+
daemonCoordinator = createDaemonCoordinator({
|
|
1449
|
+
projectRoot,
|
|
1450
|
+
daemonTransport,
|
|
1451
|
+
daemonConnection,
|
|
1452
|
+
stopDaemon,
|
|
1453
|
+
startDaemon,
|
|
1454
|
+
isDaemonRunning: isRunning,
|
|
1455
|
+
queueStatusLine: (text) => publish("status.set", { text: stripTags(text) }),
|
|
1456
|
+
resolveStatusLine: (text) => publish("status.set", { text: stripTags(text || "ready") }),
|
|
1457
|
+
logMessage: (kind, text) => {
|
|
1458
|
+
const normalized = kind === "error" ? "error" : "system";
|
|
1459
|
+
appendLocal(normalized, stripTags(text));
|
|
1460
|
+
},
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
await daemonConnection.connect();
|
|
1464
|
+
daemonSend = (req) => daemonConnection.send(req);
|
|
1465
|
+
controller.setSend(daemonSend);
|
|
1466
|
+
|
|
1467
|
+
controller.start({
|
|
1468
|
+
send: daemonSend,
|
|
1469
|
+
sendStatus: () => daemonSend({ type: IPC_REQUEST_TYPES.STATUS }),
|
|
1470
|
+
statusIntervalMs: 3000,
|
|
1471
|
+
});
|
|
1472
|
+
|
|
1473
|
+
async function spawnTuiOnce() {
|
|
1474
|
+
const child = spawn(plan.binary, [
|
|
1475
|
+
"--surface", "chat",
|
|
1476
|
+
"--ui-socket", uiSocketPath,
|
|
1477
|
+
], {
|
|
1478
|
+
stdio: "inherit",
|
|
1479
|
+
env: {
|
|
1480
|
+
...process.env,
|
|
1481
|
+
UFOO_UI_PROTOCOL: plan.protocol,
|
|
1482
|
+
UFOO_UI_TOKEN: uiAuthToken,
|
|
1483
|
+
},
|
|
1484
|
+
});
|
|
1485
|
+
return new Promise((resolve) => {
|
|
1486
|
+
child.on("error", (err) => {
|
|
1487
|
+
// eslint-disable-next-line no-console
|
|
1488
|
+
console.error("ufoo-tui spawn failed");
|
|
1489
|
+
resolve(1);
|
|
1490
|
+
});
|
|
1491
|
+
child.on("close", (code, signal) => {
|
|
1492
|
+
resolve(signal ? 1 : (code == null ? 0 : code));
|
|
1493
|
+
});
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// PTY suspend handoff removed. If an old ufoo-tui still exits 75, respawn once.
|
|
1498
|
+
const EXIT_SUSPEND = 75;
|
|
1499
|
+
let exitCode = await spawnTuiOnce();
|
|
1500
|
+
if (exitCode === EXIT_SUSPEND) {
|
|
1501
|
+
appendLocal("system", "Suspend exit ignored (PTY handoff removed); resuming chat.");
|
|
1502
|
+
publish("ui.resume", { ok: true });
|
|
1503
|
+
exitCode = await spawnTuiOnce();
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
if (multiSession && multiSession.isActive()) {
|
|
1507
|
+
try { multiSession.stop(); } catch {}
|
|
1508
|
+
}
|
|
1509
|
+
controller.stop();
|
|
1510
|
+
daemonConnection.markExit();
|
|
1511
|
+
daemonConnection.close();
|
|
1512
|
+
await host.close();
|
|
1513
|
+
return exitCode;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
module.exports = {
|
|
1517
|
+
runChatRust,
|
|
1518
|
+
historyToEntries,
|
|
1519
|
+
loadDynamicCompletionSources,
|
|
1520
|
+
};
|