u-foo 1.4.1 → 1.6.0
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 +21 -0
- package/README.zh-CN.md +21 -0
- package/bin/ufoo.js +15 -7
- package/modules/AGENTS.template.md +4 -102
- package/package.json +3 -2
- package/scripts/global-chat-switch-benchmark.js +406 -0
- package/src/agent/activityDetector.js +328 -0
- package/src/agent/activityStatePublisher.js +67 -0
- package/src/agent/activityStateWriter.js +40 -0
- package/src/agent/internalRunner.js +13 -0
- package/src/agent/launcher.js +47 -7
- package/src/agent/notifier.js +73 -4
- package/src/agent/ptyRunner.js +81 -34
- package/src/agent/ufooAgent.js +192 -6
- package/src/bus/message.js +1 -9
- package/src/bus/subscriber.js +2 -0
- package/src/bus/utils.js +10 -0
- package/src/chat/agentBar.js +21 -3
- package/src/chat/agentViewController.js +2 -0
- package/src/chat/chatLogController.js +28 -5
- package/src/chat/commandExecutor.js +127 -3
- package/src/chat/commands.js +8 -0
- package/src/chat/daemonConnection.js +77 -4
- package/src/chat/daemonCoordinator.js +36 -0
- package/src/chat/daemonMessageRouter.js +22 -0
- package/src/chat/daemonTransport.js +47 -5
- package/src/chat/daemonTransportDefaults.js +1 -0
- package/src/chat/dashboardKeyController.js +89 -1
- package/src/chat/dashboardView.js +312 -93
- package/src/chat/index.js +683 -41
- package/src/chat/inputHistoryController.js +33 -3
- package/src/chat/inputListenerController.js +22 -12
- package/src/chat/layout.js +12 -7
- package/src/chat/projectCloseController.js +119 -0
- package/src/chat/projectRuntimes.js +55 -0
- package/src/chat/statusLineController.js +52 -6
- package/src/chat/streamTracker.js +6 -0
- package/src/chat/transport.js +41 -5
- package/src/cli.js +167 -4
- package/src/daemon/index.js +54 -5
- package/src/daemon/ipcServer.js +6 -1
- package/src/daemon/ops.js +245 -35
- package/src/daemon/status.js +3 -1
- package/src/init/index.js +32 -3
- package/src/projects/projectId.js +29 -0
- package/src/projects/registry.js +279 -0
- package/src/ufoo/agentsStore.js +44 -0
package/src/chat/agentBar.js
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
const { stripAnsi, truncateAnsi } = require("./text");
|
|
2
2
|
|
|
3
|
+
const ACTIVITY_INDICATORS = {
|
|
4
|
+
working: "*",
|
|
5
|
+
waiting_input: "?",
|
|
6
|
+
blocked: "!",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const ACTIVITY_COLORS = {
|
|
10
|
+
waiting_input: "\x1b[33m", // yellow
|
|
11
|
+
blocked: "\x1b[31m", // red
|
|
12
|
+
};
|
|
13
|
+
|
|
3
14
|
function computeAgentBar(options = {}) {
|
|
4
15
|
const {
|
|
5
16
|
cols = 80,
|
|
@@ -11,6 +22,7 @@ function computeAgentBar(options = {}) {
|
|
|
11
22
|
agentListWindowStart = 0,
|
|
12
23
|
maxAgentWindow = 4,
|
|
13
24
|
getAgentLabel = (id) => id,
|
|
25
|
+
agentStates = {},
|
|
14
26
|
} = options;
|
|
15
27
|
|
|
16
28
|
const hintAnsi = `\x1b[90m│ ${hintText}\x1b[0m`;
|
|
@@ -60,12 +72,18 @@ function computeAgentBar(options = {}) {
|
|
|
60
72
|
agentParts = visible.map((agent, i) => {
|
|
61
73
|
const rawLabel = getAgentLabel(agent);
|
|
62
74
|
const label = maxLabelLen ? truncateLabel(rawLabel, maxLabelLen) : rawLabel;
|
|
75
|
+
const actState = agentStates[agent] || "";
|
|
76
|
+
const indicator = ACTIVITY_INDICATORS[actState] || "";
|
|
77
|
+
const indicatorColor = ACTIVITY_COLORS[actState] || "";
|
|
78
|
+
const prefix = indicator
|
|
79
|
+
? `${indicatorColor}${indicator}\x1b[0m`
|
|
80
|
+
: "";
|
|
63
81
|
const idx = s + i + 1; // +1 for ucode at index 0
|
|
64
82
|
if (focusMode === "dashboard" && idx === selectedAgentIndex) {
|
|
65
|
-
return
|
|
83
|
+
return `${prefix}\x1b[90;7m${label}\x1b[0m`;
|
|
66
84
|
}
|
|
67
|
-
if (agent === viewingAgent) return
|
|
68
|
-
return
|
|
85
|
+
if (agent === viewingAgent) return `${prefix}\x1b[1;36m${label}\x1b[0m`;
|
|
86
|
+
return `${prefix}\x1b[36m${label}\x1b[0m`;
|
|
69
87
|
});
|
|
70
88
|
}
|
|
71
89
|
const agentsText = activeAgents.length > 0
|
|
@@ -16,6 +16,7 @@ function createAgentViewController(options = {}) {
|
|
|
16
16
|
getAgentListWindowStart = () => 0,
|
|
17
17
|
setAgentListWindowStart = () => {},
|
|
18
18
|
getAgentLabel = (id) => id,
|
|
19
|
+
getAgentStates = () => ({}),
|
|
19
20
|
setDashboardView = () => {},
|
|
20
21
|
setScreenGrabKeys = (value) => {
|
|
21
22
|
if (screen) screen.grabKeys = Boolean(value);
|
|
@@ -79,6 +80,7 @@ function createAgentViewController(options = {}) {
|
|
|
79
80
|
agentListWindowStart: getAgentListWindowStart(),
|
|
80
81
|
maxAgentWindow,
|
|
81
82
|
getAgentLabel,
|
|
83
|
+
agentStates: getAgentStates(),
|
|
82
84
|
});
|
|
83
85
|
setAgentListWindowStart(computed.windowStart);
|
|
84
86
|
processStdout.write(`\x1b7\x1b[${rows};1H${computed.bar}\x1b8`);
|
|
@@ -2,8 +2,8 @@ function createChatLogController(options = {}) {
|
|
|
2
2
|
const {
|
|
3
3
|
logBox,
|
|
4
4
|
fsModule,
|
|
5
|
-
historyDir,
|
|
6
|
-
historyFile,
|
|
5
|
+
historyDir: historyDirOption,
|
|
6
|
+
historyFile: historyFileOption,
|
|
7
7
|
now = () => new Date().toISOString(),
|
|
8
8
|
} = options;
|
|
9
9
|
|
|
@@ -13,9 +13,11 @@ function createChatLogController(options = {}) {
|
|
|
13
13
|
if (!fsModule) {
|
|
14
14
|
throw new Error("createChatLogController requires fsModule");
|
|
15
15
|
}
|
|
16
|
-
if (!
|
|
16
|
+
if (!historyDirOption || !historyFileOption) {
|
|
17
17
|
throw new Error("createChatLogController requires historyDir/historyFile");
|
|
18
18
|
}
|
|
19
|
+
let historyDir = historyDirOption;
|
|
20
|
+
let historyFile = historyFileOption;
|
|
19
21
|
|
|
20
22
|
const SPACED_TYPES = new Set(["user", "reply", "bus", "dispatch", "error"]);
|
|
21
23
|
let lastLogWasSpacer = false;
|
|
@@ -95,9 +97,28 @@ function createChatLogController(options = {}) {
|
|
|
95
97
|
recordLog(item.type || "unknown", item.text, item.meta || {}, false);
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
|
-
} catch {
|
|
99
|
-
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err && err.code === "ENOENT") {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (err && typeof console !== "undefined" && typeof console.warn === "function") {
|
|
105
|
+
console.warn(`chat history load failed (${historyFile}): ${err.message || err}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function setHistoryTarget(next = {}) {
|
|
111
|
+
if (!next.historyDir || !next.historyFile) {
|
|
112
|
+
throw new Error("setHistoryTarget requires historyDir/historyFile");
|
|
100
113
|
}
|
|
114
|
+
historyDir = next.historyDir;
|
|
115
|
+
historyFile = next.historyFile;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function resetViewState() {
|
|
119
|
+
// Callers are expected to clear logBox separately; this only resets spacing trackers.
|
|
120
|
+
lastLogWasSpacer = false;
|
|
121
|
+
hasLoggedAny = false;
|
|
101
122
|
}
|
|
102
123
|
|
|
103
124
|
return {
|
|
@@ -107,6 +128,8 @@ function createChatLogController(options = {}) {
|
|
|
107
128
|
logMessage,
|
|
108
129
|
markStreamStart,
|
|
109
130
|
loadHistory,
|
|
131
|
+
setHistoryTarget,
|
|
132
|
+
resetViewState,
|
|
110
133
|
};
|
|
111
134
|
}
|
|
112
135
|
|
|
@@ -22,6 +22,13 @@ function defaultCreateSkills(projectRoot) {
|
|
|
22
22
|
return new UfooSkills(projectRoot);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function defaultResolveTerminalApp() {
|
|
26
|
+
const program = String(process.env.TERM_PROGRAM || "").trim();
|
|
27
|
+
if (program === "Apple_Terminal") return "terminal";
|
|
28
|
+
if (program === "iTerm.app" || process.env.ITERM_SESSION_ID) return "iterm2";
|
|
29
|
+
return "";
|
|
30
|
+
}
|
|
31
|
+
|
|
25
32
|
async function withCapturedConsole(capture, fn) {
|
|
26
33
|
const originalLog = console.log;
|
|
27
34
|
const originalError = console.error;
|
|
@@ -72,6 +79,10 @@ function createCommandExecutor(options = {}) {
|
|
|
72
79
|
stopCronTask = () => false,
|
|
73
80
|
runGroupCore = runGroupCoreCommand,
|
|
74
81
|
requestCron = null,
|
|
82
|
+
listProjects = () => [],
|
|
83
|
+
getCurrentProject = () => ({ projectRoot }),
|
|
84
|
+
switchProject = async () => ({ ok: false, error: "project switching unavailable" }),
|
|
85
|
+
resolveTerminalApp = defaultResolveTerminalApp,
|
|
75
86
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
76
87
|
schedule = (fn, ms) => setTimeout(fn, ms),
|
|
77
88
|
} = options;
|
|
@@ -355,7 +366,10 @@ function createCommandExecutor(options = {}) {
|
|
|
355
366
|
|
|
356
367
|
async function handleLaunchCommand(args = []) {
|
|
357
368
|
if (args.length === 0) {
|
|
358
|
-
logMessage(
|
|
369
|
+
logMessage(
|
|
370
|
+
"error",
|
|
371
|
+
"{white-fg}✗{/white-fg} Usage: /launch <claude|codex|ucode> [nickname=<name>] [count=<n>] [scope=inplace|window]"
|
|
372
|
+
);
|
|
359
373
|
return;
|
|
360
374
|
}
|
|
361
375
|
|
|
@@ -375,20 +389,68 @@ function createCommandExecutor(options = {}) {
|
|
|
375
389
|
}
|
|
376
390
|
}
|
|
377
391
|
|
|
392
|
+
function normalizeLaunchScopeOption(value, fallback = "inplace") {
|
|
393
|
+
const raw = String(value || "").trim().toLowerCase();
|
|
394
|
+
if (!raw) return fallback;
|
|
395
|
+
if (raw === "inplace" || raw === "same" || raw === "current" || raw === "tab" || raw === "pane") {
|
|
396
|
+
return "inplace";
|
|
397
|
+
}
|
|
398
|
+
if (
|
|
399
|
+
raw === "window"
|
|
400
|
+
|| raw === "separate"
|
|
401
|
+
|| raw === "new"
|
|
402
|
+
|| raw === "new-window"
|
|
403
|
+
|| raw === "external"
|
|
404
|
+
|| raw === "1"
|
|
405
|
+
|| raw === "true"
|
|
406
|
+
|| raw === "yes"
|
|
407
|
+
|| raw === "y"
|
|
408
|
+
|| raw === "on"
|
|
409
|
+
) {
|
|
410
|
+
return "window";
|
|
411
|
+
}
|
|
412
|
+
if (raw === "0" || raw === "false" || raw === "no" || raw === "n" || raw === "off") {
|
|
413
|
+
return "inplace";
|
|
414
|
+
}
|
|
415
|
+
return "";
|
|
416
|
+
}
|
|
417
|
+
|
|
378
418
|
const nickname = parsedOptions.nickname || "";
|
|
379
419
|
const count = parseInt(parsedOptions.count || "1", 10);
|
|
420
|
+
const scopeRaw = parsedOptions.scope || parsedOptions.launch_scope || parsedOptions.window || "";
|
|
421
|
+
let launchScope = normalizeLaunchScopeOption(scopeRaw, "inplace");
|
|
422
|
+
if (scopeRaw && !launchScope) {
|
|
423
|
+
logMessage("error", "{white-fg}✗{/white-fg} scope must be inplace|window");
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const rawFlags = args
|
|
427
|
+
.slice(1)
|
|
428
|
+
.filter((arg) => !String(arg || "").includes("="))
|
|
429
|
+
.map((arg) => String(arg || "").trim().toLowerCase())
|
|
430
|
+
.filter(Boolean);
|
|
431
|
+
for (const flag of rawFlags) {
|
|
432
|
+
const normalized = normalizeLaunchScopeOption(flag, "");
|
|
433
|
+
if (normalized) launchScope = normalized;
|
|
434
|
+
}
|
|
435
|
+
if (!launchScope) launchScope = "inplace";
|
|
380
436
|
if (nickname && count > 1) {
|
|
381
437
|
logMessage("error", "{white-fg}✗{/white-fg} nickname requires count=1");
|
|
382
438
|
return;
|
|
383
439
|
}
|
|
384
440
|
|
|
385
441
|
try {
|
|
386
|
-
|
|
442
|
+
const request = {
|
|
387
443
|
type: IPC_REQUEST_TYPES.LAUNCH_AGENT,
|
|
388
444
|
agent: normalizedAgent,
|
|
389
445
|
count: Number.isFinite(count) ? count : 1,
|
|
390
446
|
nickname,
|
|
391
|
-
|
|
447
|
+
launch_scope: launchScope,
|
|
448
|
+
};
|
|
449
|
+
const terminalApp = String(resolveTerminalApp() || "").trim().toLowerCase();
|
|
450
|
+
if (terminalApp === "terminal" || terminalApp === "iterm2") {
|
|
451
|
+
request.terminal_app = terminalApp;
|
|
452
|
+
}
|
|
453
|
+
send(request);
|
|
392
454
|
schedule(requestStatus, 1000);
|
|
393
455
|
} catch (err) {
|
|
394
456
|
logMessage("error", `{white-fg}✗{/white-fg} Launch failed: ${escapeBlessed(err.message)}`);
|
|
@@ -413,6 +475,64 @@ function createCommandExecutor(options = {}) {
|
|
|
413
475
|
schedule(requestStatus, 1000);
|
|
414
476
|
}
|
|
415
477
|
|
|
478
|
+
async function handleProjectCommand(args = []) {
|
|
479
|
+
const subcommand = String(args[0] || "list").trim().toLowerCase();
|
|
480
|
+
|
|
481
|
+
if (subcommand === "list") {
|
|
482
|
+
const rowsRaw = await Promise.resolve(listProjects());
|
|
483
|
+
const rows = Array.isArray(rowsRaw) ? rowsRaw : [];
|
|
484
|
+
const current = await Promise.resolve(getCurrentProject());
|
|
485
|
+
const currentRoot = current && current.project_root ? String(current.project_root) : "";
|
|
486
|
+
if (rows.length === 0) {
|
|
487
|
+
logMessage("system", "{white-fg}No projects found{/white-fg}");
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
logMessage("system", `{cyan-fg}Projects:{/cyan-fg} ${rows.length}`);
|
|
491
|
+
rows.forEach((item, idx) => {
|
|
492
|
+
const row = item || {};
|
|
493
|
+
const root = String(row.project_root || "");
|
|
494
|
+
const name = String(row.project_name || root || "-");
|
|
495
|
+
const status = String(row.status || "unknown");
|
|
496
|
+
const marker = root && root === currentRoot ? "*" : " ";
|
|
497
|
+
logMessage(
|
|
498
|
+
"system",
|
|
499
|
+
`${marker}${idx + 1}. {cyan-fg}${escapeBlessed(name)}{/cyan-fg} [{white-fg}${escapeBlessed(status)}{/white-fg}] ${escapeBlessed(root)}`
|
|
500
|
+
);
|
|
501
|
+
});
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
if (subcommand === "current") {
|
|
506
|
+
const current = await Promise.resolve(getCurrentProject());
|
|
507
|
+
if (!current || !current.project_root) {
|
|
508
|
+
logMessage("error", "{white-fg}✗{/white-fg} Current project unavailable");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
logMessage("system", `{cyan-fg}Current:{/cyan-fg} ${escapeBlessed(current.project_root)}`);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (subcommand === "switch") {
|
|
516
|
+
const target = String(args[1] || "").trim();
|
|
517
|
+
if (!target) {
|
|
518
|
+
logMessage("error", "{white-fg}✗{/white-fg} Usage: /project switch <index|path>");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
logMessage("system", `{white-fg}⚙{/white-fg} Switching project: ${escapeBlessed(target)}`);
|
|
522
|
+
const result = await Promise.resolve(switchProject({ target }));
|
|
523
|
+
if (!result || result.ok !== true) {
|
|
524
|
+
const reason = result && result.error ? String(result.error) : "switch failed";
|
|
525
|
+
logMessage("error", `{white-fg}✗{/white-fg} Switch failed: ${escapeBlessed(reason)}`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
const nextRoot = result.project_root || result.projectRoot || "";
|
|
529
|
+
logMessage("system", `{white-fg}✓{/white-fg} Switched project: ${escapeBlessed(nextRoot)}`);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
logMessage("error", "{white-fg}✗{/white-fg} Unknown project command. Use: list, current, switch");
|
|
534
|
+
}
|
|
535
|
+
|
|
416
536
|
function parseKeyValueArgs(args = []) {
|
|
417
537
|
const parsed = {};
|
|
418
538
|
for (const raw of args) {
|
|
@@ -963,6 +1083,9 @@ function createCommandExecutor(options = {}) {
|
|
|
963
1083
|
case "resume":
|
|
964
1084
|
await handleResumeCommand(args);
|
|
965
1085
|
return true;
|
|
1086
|
+
case "project":
|
|
1087
|
+
await handleProjectCommand(args);
|
|
1088
|
+
return true;
|
|
966
1089
|
case "cron":
|
|
967
1090
|
await handleCronCommand(args);
|
|
968
1091
|
return true;
|
|
@@ -992,6 +1115,7 @@ function createCommandExecutor(options = {}) {
|
|
|
992
1115
|
handleSkillsCommand,
|
|
993
1116
|
handleLaunchCommand,
|
|
994
1117
|
handleResumeCommand,
|
|
1118
|
+
handleProjectCommand,
|
|
995
1119
|
handleCronCommand,
|
|
996
1120
|
handleGroupCommand,
|
|
997
1121
|
handleSettingsCommand,
|
package/src/chat/commands.js
CHANGED
|
@@ -55,6 +55,14 @@ const COMMAND_TREE = {
|
|
|
55
55
|
ucode: { desc: "Launch ucode core agent" },
|
|
56
56
|
},
|
|
57
57
|
},
|
|
58
|
+
"/project": {
|
|
59
|
+
desc: "Project switch operations (spike)",
|
|
60
|
+
children: {
|
|
61
|
+
current: { desc: "Show current chat project" },
|
|
62
|
+
list: { desc: "List running projects from registry" },
|
|
63
|
+
switch: { desc: "Switch daemon connection to project index/path" },
|
|
64
|
+
},
|
|
65
|
+
},
|
|
58
66
|
"/resume": {
|
|
59
67
|
desc: "Resume agents (optional nickname) or list recoverable targets",
|
|
60
68
|
children: {
|
|
@@ -2,19 +2,50 @@ const { IPC_REQUEST_TYPES } = require("../shared/eventContract");
|
|
|
2
2
|
|
|
3
3
|
function createDaemonConnection(options = {}) {
|
|
4
4
|
const {
|
|
5
|
-
connectClient,
|
|
5
|
+
connectClient: connectClientOption,
|
|
6
6
|
handleMessage,
|
|
7
7
|
queueStatusLine,
|
|
8
8
|
resolveStatusLine,
|
|
9
9
|
logMessage,
|
|
10
|
+
switchConnectionTimeoutMs = 18000,
|
|
10
11
|
} = options;
|
|
11
12
|
|
|
13
|
+
let connectClient = connectClientOption;
|
|
12
14
|
let client = null;
|
|
13
15
|
let reconnectPromise = null;
|
|
14
16
|
let exitRequested = false;
|
|
15
17
|
let connectionLostNotified = false;
|
|
16
18
|
const pendingRequests = [];
|
|
17
19
|
const MAX_PENDING_REQUESTS = 50;
|
|
20
|
+
const STATUS_KEY_RECONNECT = "daemon-reconnect";
|
|
21
|
+
const STATUS_KEY_SWITCH = "daemon-switch";
|
|
22
|
+
const DEFAULT_SWITCH_TIMEOUT_MS = Number.isFinite(switchConnectionTimeoutMs)
|
|
23
|
+
&& switchConnectionTimeoutMs > 0
|
|
24
|
+
? Math.trunc(switchConnectionTimeoutMs)
|
|
25
|
+
: 18000;
|
|
26
|
+
|
|
27
|
+
function withTimeout(promiseLike, timeoutMs, timeoutMessage) {
|
|
28
|
+
const ms = Number.isFinite(timeoutMs) && timeoutMs > 0
|
|
29
|
+
? Math.trunc(timeoutMs)
|
|
30
|
+
: DEFAULT_SWITCH_TIMEOUT_MS;
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const timer = setTimeout(() => {
|
|
33
|
+
const err = new Error(timeoutMessage || `operation timed out after ${ms}ms`);
|
|
34
|
+
err.code = "UFOO_TIMEOUT";
|
|
35
|
+
reject(err);
|
|
36
|
+
}, ms);
|
|
37
|
+
if (typeof timer.unref === "function") {
|
|
38
|
+
timer.unref();
|
|
39
|
+
}
|
|
40
|
+
Promise.resolve(promiseLike).then((value) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
resolve(value);
|
|
43
|
+
}, (err) => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
reject(err);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
18
49
|
|
|
19
50
|
function enqueueRequest(req) {
|
|
20
51
|
if (!req || req.type === IPC_REQUEST_TYPES.STATUS) return;
|
|
@@ -90,18 +121,18 @@ function createDaemonConnection(options = {}) {
|
|
|
90
121
|
if (client && !client.destroyed) return true;
|
|
91
122
|
if (exitRequested) return false;
|
|
92
123
|
if (reconnectPromise) return reconnectPromise;
|
|
93
|
-
queueStatusLine("Reconnecting to daemon");
|
|
124
|
+
queueStatusLine("Reconnecting to daemon", { key: STATUS_KEY_RECONNECT });
|
|
94
125
|
logMessage("status", "{white-fg}⚙{/white-fg} Reconnecting to daemon...");
|
|
95
126
|
reconnectPromise = (async () => {
|
|
96
127
|
const newClient = await connectClient();
|
|
97
128
|
if (!newClient) {
|
|
98
|
-
resolveStatusLine("{gray-fg}✗{/gray-fg} Daemon offline");
|
|
129
|
+
resolveStatusLine("{gray-fg}✗{/gray-fg} Daemon offline", { key: STATUS_KEY_RECONNECT });
|
|
99
130
|
logMessage("error", "{white-fg}✗{/white-fg} Failed to reconnect to daemon");
|
|
100
131
|
return false;
|
|
101
132
|
}
|
|
102
133
|
attachClient(newClient);
|
|
103
134
|
connectionLostNotified = false;
|
|
104
|
-
resolveStatusLine("{gray-fg}✓{/gray-fg} Daemon reconnected");
|
|
135
|
+
resolveStatusLine("{gray-fg}✓{/gray-fg} Daemon reconnected", { key: STATUS_KEY_RECONNECT });
|
|
105
136
|
requestStatus();
|
|
106
137
|
return true;
|
|
107
138
|
})();
|
|
@@ -120,6 +151,47 @@ function createDaemonConnection(options = {}) {
|
|
|
120
151
|
return true;
|
|
121
152
|
}
|
|
122
153
|
|
|
154
|
+
async function switchConnection(next = {}) {
|
|
155
|
+
const nextConnectClient = typeof next.connectClient === "function"
|
|
156
|
+
? next.connectClient
|
|
157
|
+
: null;
|
|
158
|
+
if (!nextConnectClient) {
|
|
159
|
+
return { ok: false, error: "switchConnection requires connectClient" };
|
|
160
|
+
}
|
|
161
|
+
const previousClient = client;
|
|
162
|
+
try {
|
|
163
|
+
queueStatusLine("Switching daemon connection", { key: STATUS_KEY_SWITCH });
|
|
164
|
+
const timeoutMs = Number.isFinite(next.timeoutMs) && next.timeoutMs > 0
|
|
165
|
+
? Math.trunc(next.timeoutMs)
|
|
166
|
+
: DEFAULT_SWITCH_TIMEOUT_MS;
|
|
167
|
+
const nextClient = await withTimeout(
|
|
168
|
+
nextConnectClient(),
|
|
169
|
+
timeoutMs,
|
|
170
|
+
`Switch connection timed out after ${timeoutMs}ms`
|
|
171
|
+
);
|
|
172
|
+
if (!nextClient) {
|
|
173
|
+
resolveStatusLine("{gray-fg}✗{/gray-fg} Switch failed", { key: STATUS_KEY_SWITCH });
|
|
174
|
+
return { ok: false, error: "Failed to connect target daemon" };
|
|
175
|
+
}
|
|
176
|
+
connectClient = nextConnectClient;
|
|
177
|
+
attachClient(nextClient);
|
|
178
|
+
if (next.callRequestStatus !== false) {
|
|
179
|
+
requestStatus();
|
|
180
|
+
}
|
|
181
|
+
resolveStatusLine("{gray-fg}✓{/gray-fg} Daemon switched", { key: STATUS_KEY_SWITCH });
|
|
182
|
+
return { ok: true };
|
|
183
|
+
} catch (err) {
|
|
184
|
+
// Keep existing connection alive on switch failures.
|
|
185
|
+
if (previousClient && (!client || client.destroyed)) {
|
|
186
|
+
client = previousClient;
|
|
187
|
+
}
|
|
188
|
+
const message = err && err.message ? err.message : String(err || "switch failed");
|
|
189
|
+
resolveStatusLine("{gray-fg}✗{/gray-fg} Switch failed", { key: STATUS_KEY_SWITCH });
|
|
190
|
+
logMessage("error", `{white-fg}✗{/white-fg} ${message}`);
|
|
191
|
+
return { ok: false, error: message };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
123
195
|
function send(req) {
|
|
124
196
|
if (!client || client.destroyed) {
|
|
125
197
|
enqueueRequest(req);
|
|
@@ -155,6 +227,7 @@ function createDaemonConnection(options = {}) {
|
|
|
155
227
|
connect,
|
|
156
228
|
send,
|
|
157
229
|
requestStatus,
|
|
230
|
+
switchConnection,
|
|
158
231
|
close,
|
|
159
232
|
markExit,
|
|
160
233
|
getState,
|
|
@@ -40,6 +40,41 @@ function createDaemonCoordinator(options = {}) {
|
|
|
40
40
|
daemonConnection: connection,
|
|
41
41
|
logMessage,
|
|
42
42
|
});
|
|
43
|
+
let switchProjectChain = Promise.resolve();
|
|
44
|
+
|
|
45
|
+
function switchProject(target = {}) {
|
|
46
|
+
const runSwitch = async () => {
|
|
47
|
+
if (!daemonTransport || typeof daemonTransport.connectClientForTarget !== "function") {
|
|
48
|
+
return { ok: false, error: "project switching requires daemonTransport.connectClientForTarget" };
|
|
49
|
+
}
|
|
50
|
+
if (!target || !target.projectRoot || !target.sockPath) {
|
|
51
|
+
return { ok: false, error: "switchProject requires projectRoot and sockPath" };
|
|
52
|
+
}
|
|
53
|
+
if (!connection || typeof connection.switchConnection !== "function") {
|
|
54
|
+
return { ok: false, error: "daemon connection does not support switching" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = await connection.switchConnection({
|
|
58
|
+
connectClient: () => daemonTransport.connectClientForTarget(target),
|
|
59
|
+
callRequestStatus: false,
|
|
60
|
+
});
|
|
61
|
+
if (!result || result.ok !== true) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
error: (result && result.error) || "switch failed",
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (typeof daemonTransport.setTarget === "function") {
|
|
68
|
+
daemonTransport.setTarget(target);
|
|
69
|
+
}
|
|
70
|
+
connection.requestStatus();
|
|
71
|
+
return { ok: true, target };
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const scheduled = switchProjectChain.then(runSwitch, runSwitch);
|
|
75
|
+
switchProjectChain = scheduled.catch(() => {});
|
|
76
|
+
return scheduled;
|
|
77
|
+
}
|
|
43
78
|
|
|
44
79
|
function isConnected() {
|
|
45
80
|
if (!connection || typeof connection.getState !== "function") return false;
|
|
@@ -54,6 +89,7 @@ function createDaemonCoordinator(options = {}) {
|
|
|
54
89
|
restart: () => restart(),
|
|
55
90
|
close: () => connection.close(),
|
|
56
91
|
markExit: () => connection.markExit(),
|
|
92
|
+
switchProject,
|
|
57
93
|
isConnected,
|
|
58
94
|
getState: () => (typeof connection.getState === "function" ? connection.getState() : null),
|
|
59
95
|
};
|
|
@@ -25,8 +25,17 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
25
25
|
appendStreamDelta = () => {},
|
|
26
26
|
finalizeStream = () => {},
|
|
27
27
|
hasStream = () => false,
|
|
28
|
+
setTransientAgentState = () => {},
|
|
29
|
+
clearTransientAgentState = () => {},
|
|
30
|
+
refreshDashboard = () => {},
|
|
28
31
|
} = options;
|
|
29
32
|
|
|
33
|
+
function isLikelySubscriberId(value) {
|
|
34
|
+
const text = String(value || "");
|
|
35
|
+
if (!text) return false;
|
|
36
|
+
return text.includes(":") && !text.includes(" ");
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
function normalizeDisplayMessage(raw) {
|
|
31
40
|
let displayMessage = raw || "";
|
|
32
41
|
let streamPayload = null;
|
|
@@ -53,6 +62,14 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
53
62
|
if (typeof data.phase === "string") {
|
|
54
63
|
const text = data.text || "";
|
|
55
64
|
const item = { key: data.key, text };
|
|
65
|
+
const key = typeof data.key === "string" ? data.key : "";
|
|
66
|
+
if (isLikelySubscriberId(key)) {
|
|
67
|
+
if (data.phase === BUS_STATUS_PHASES.START) {
|
|
68
|
+
setTransientAgentState(key, "working");
|
|
69
|
+
} else if (data.phase === BUS_STATUS_PHASES.DONE || data.phase === BUS_STATUS_PHASES.ERROR) {
|
|
70
|
+
clearTransientAgentState(key);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
56
73
|
if (data.phase === BUS_STATUS_PHASES.START) {
|
|
57
74
|
enqueueBusStatus(item);
|
|
58
75
|
} else if (data.phase === BUS_STATUS_PHASES.DONE || data.phase === BUS_STATUS_PHASES.ERROR) {
|
|
@@ -66,6 +83,7 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
66
83
|
} else {
|
|
67
84
|
enqueueBusStatus(item);
|
|
68
85
|
}
|
|
86
|
+
refreshDashboard();
|
|
69
87
|
renderScreen();
|
|
70
88
|
return false;
|
|
71
89
|
}
|
|
@@ -301,6 +319,10 @@ function createDaemonMessageRouter(options = {}) {
|
|
|
301
319
|
|
|
302
320
|
function handleBusMessage(msg) {
|
|
303
321
|
const data = msg.data || {};
|
|
322
|
+
if (data.event === "activity_state_changed") {
|
|
323
|
+
requestStatus();
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
304
326
|
const prefix = data.event === "broadcast" ? "{gray-fg}⇢{/gray-fg}" : "{gray-fg}↔{/gray-fg}";
|
|
305
327
|
const publisher = data.publisher && data.publisher !== "unknown"
|
|
306
328
|
? data.publisher
|
|
@@ -11,23 +11,65 @@ function createDaemonTransport(options = {}) {
|
|
|
11
11
|
secondaryRetries = DAEMON_TRANSPORT_DEFAULTS.secondaryRetries,
|
|
12
12
|
retryDelayMs = DAEMON_TRANSPORT_DEFAULTS.retryDelayMs,
|
|
13
13
|
restartDelayMs = DAEMON_TRANSPORT_DEFAULTS.restartDelayMs,
|
|
14
|
+
connectTimeoutMs = DAEMON_TRANSPORT_DEFAULTS.connectTimeoutMs,
|
|
14
15
|
} = options;
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
let activeProjectRoot = projectRoot;
|
|
18
|
+
let activeSockPath = sockPath;
|
|
19
|
+
|
|
20
|
+
function resolveTarget(override = {}) {
|
|
21
|
+
return {
|
|
22
|
+
projectRoot: override.projectRoot || activeProjectRoot,
|
|
23
|
+
sockPath: override.sockPath || activeSockPath,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function connectClientForTarget(override = {}) {
|
|
28
|
+
const target = resolveTarget(override);
|
|
29
|
+
let client = await connectWithRetry(
|
|
30
|
+
target.sockPath,
|
|
31
|
+
primaryRetries,
|
|
32
|
+
retryDelayMs,
|
|
33
|
+
{ timeoutMs: connectTimeoutMs }
|
|
34
|
+
);
|
|
18
35
|
if (!client) {
|
|
19
36
|
// Retry once with a fresh daemon start and longer wait.
|
|
20
|
-
if (!isRunning(projectRoot)) {
|
|
21
|
-
startDaemon(projectRoot);
|
|
37
|
+
if (!isRunning(target.projectRoot)) {
|
|
38
|
+
startDaemon(target.projectRoot);
|
|
22
39
|
await new Promise((resolve) => setTimeout(resolve, restartDelayMs));
|
|
23
40
|
}
|
|
24
|
-
client = await connectWithRetry(
|
|
41
|
+
client = await connectWithRetry(
|
|
42
|
+
target.sockPath,
|
|
43
|
+
secondaryRetries,
|
|
44
|
+
retryDelayMs,
|
|
45
|
+
{ timeoutMs: connectTimeoutMs }
|
|
46
|
+
);
|
|
25
47
|
}
|
|
26
48
|
return client;
|
|
27
49
|
}
|
|
28
50
|
|
|
51
|
+
async function connectClient() {
|
|
52
|
+
return connectClientForTarget();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function setTarget(next = {}) {
|
|
56
|
+
if (next.projectRoot) activeProjectRoot = next.projectRoot;
|
|
57
|
+
if (next.sockPath) activeSockPath = next.sockPath;
|
|
58
|
+
return getTarget();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getTarget() {
|
|
62
|
+
return {
|
|
63
|
+
projectRoot: activeProjectRoot,
|
|
64
|
+
sockPath: activeSockPath,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
29
68
|
return {
|
|
30
69
|
connectClient,
|
|
70
|
+
connectClientForTarget,
|
|
71
|
+
setTarget,
|
|
72
|
+
getTarget,
|
|
31
73
|
};
|
|
32
74
|
}
|
|
33
75
|
|